Skip to content

feat(rbac): redesign the access page and harden security - #138

Merged
WhiteMuush merged 13 commits into
developfrom
feat/rbac-management-ui
Aug 5, 2026
Merged

feat(rbac): redesign the access page and harden security#138
WhiteMuush merged 13 commits into
developfrom
feat/rbac-management-ui

Conversation

@WhiteMuush

@WhiteMuush WhiteMuush commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Follows #137, which shipped RBAC Plan 2 tasks 12 to 14. This branch carries what came after.

Access page

Rebuilt on the app own design language: it used none of the vocabulary the rest of the product has (bare ul lists instead of card-wrapped tables, an unstyled search input, pagination floating outside any container). Three stacked lists ran past 2000px, so they move behind a plain underlined tab strip.

Two defects fixed along the way. The page had no scroll container while the dashboard shell is h-screen with overflow-hidden at every level, so opening the permission editor clipped 1661px of content with no way to reach it and no way to close the panel. And it was the only page capped at max-w-4xl when list pages run full width.

The audit trail finally shows what it recorded: it listed only action, actor and date, so a row read role.update with no way to tell which role or what changed, while the API had been returning targetId, before and after all along.

Sign-in

Loading feedback was in the wrong places. Route-level skeletons flashed on every navigation between entries, while the one genuinely slow moment, the stretch between credentials being accepted and the dashboard painting, showed nothing at all: the pending state was released before the redirect. Skeletons removed, transition screen added.

Security

Four findings from a review of the OWASP categories, brute force, MITM and request tampering. Account lockout and credential stuffing are deliberately out of scope: they belong at the edge, not in application code.

  • bcrypt cost 10 to 12. Production was the only place still below the 12 every seed used, and the only one hashing a real password. Existing hashes keep verifying, bcrypt stores its cost inside the hash.
  • Password minimum 12. Nothing was configured, leaving the default of 8 with no complexity, on a product whose job is finding exposed credentials.
  • Rate limiting moved from memory to Postgres. A per-process Map multiplied the real ceiling by the instance count and reset on every deploy. The counter is now a single INSERT ON CONFLICT that rolls the window inside the statement; an integration test drives 20 concurrent calls against a limit of 5 and asserts exactly 5 pass.
  • SSRF on outbound URLs. Forcing https blocked the AWS metadata endpoint but not an internal host behind TLS. Literal private addresses, embedded credentials and non-https are now refused, and the hostname is resolved and every returned address checked, at write time and again before each delivery, since a name that looks public can be repointed later. Redirects are no longer followed.

Verification

tsc                  0 error
lint                 0 error
vitest unit          192 tests, 37 files
vitest integration   19 tests, 11 files
playwright e2e       10/10

Note for local runs: the rate limiter is persistent now, so a npm run dev server started without E2E=1 will be reused by Playwright, the test-only bypass will not arm, and sign-ins will 429. Run the suite with no dev server up and Playwright starts its own correctly.

Comment thread src/app/api/dashboard/presets/route.ts Fixed
@WhiteMuush WhiteMuush changed the title Feat/rbac management UI feat(rbac): redesign the access page and harden security Aug 4, 2026
@WhiteMuush
WhiteMuush changed the base branch from main to develop August 4, 2026 20:42
@WhiteMuush WhiteMuush closed this Aug 4, 2026
@WhiteMuush WhiteMuush reopened this Aug 4, 2026
The three lists on /access rendered an empty bordered box until their fetch
came back, so the page looked finished and wrong for a beat, then jumped as
rows appeared.

Extract the idiom the dashboard route skeleton already uses (a pulsing muted
block) into a Skeleton primitive plus a SkeletonRows list, sized like the real
rows so nothing shifts when the data lands.

Roles and People clear the flag once and never raise it again: the reloads
that follow a mutation keep the list on screen rather than flashing
placeholders over rows the user is reading. The audit trail is the exception
and re-arms on every page change, because paging swaps the whole set and the
placeholders are the feedback that the page turned. Its Prev/Next are disabled
mid-fetch so a fast double click cannot outrun the response.
Two gaps on the login page. The pending state only swapped the button label,
with nothing moving, and on success setLoading(false) ran before the push to
/dashboard, so the button snapped back to "Sign in" while the navigation was
still under way. The slowest part of signing in had the least feedback.

Add a spinning icon next to the label, and keep the pending state alive
through the redirect on every path that navigates. Error paths and the
two-factor branch still hand control back, since those stay on the page.

Track which action is pending rather than a bare boolean. A shared flag made
the passkey button read "Signing in..." when the password form was the one
submitting, which is wrong on its face and collides with the accessible name
the e2e disambiguates on.
Scope the animation to the one moment that had no feedback: the stretch
between credentials being accepted and the dashboard painting. Nothing covered
it, because the pending state was released as soon as the auth call returned,
so the button read "Sign in" again while the navigation was still running.

A full-screen shield and spinner now replace the form once authentication has
succeeded, on all four paths that navigate: password, passkey, TOTP and email
code. The credential check itself keeps its plain label, and error paths hand
control straight back since they stay on the page.

Amends the previous commit, which put spinners on the buttons themselves. That
animated the wrong moment.
Moving between Employees, Alerts, Reports and the rest flashed a placeholder
grid before the real page. Routes are already prefetched, so the pause it
covered is short, and a shape that briefly pretends to be content reads worse
than a beat of nothing.

Loading feedback now exists in exactly one place: entering the workspace after
signing in, which is the only wait long enough to need it.

Without a loading.tsx, Next keeps the current page on screen until the next
one is ready rather than swapping in a fallback.
Opening the permission editor made /access unusable on any viewport shorter
than the content: the panel could not be closed and the page would not scroll.

The dashboard shell is h-screen with overflow-hidden at every level, so each
page owns its scrolling. Every other page opens with "h-full overflow-y-auto
p-6" and puts its max-width on an inner wrapper. This one opened straight into
"mx-auto max-w-4xl space-y-6 p-6", so the shell clipped it: measured at a 620px
viewport, 2233px of content shown through 572px with no way to reach the rest.
It also nested a second main inside the shell's own.

The regression test walks out from the form and fails on any ancestor clipping
content it is too short to show. Asserting the Cancel button's position instead
would pass or fail on how many roles happen to be seeded, and Playwright can
force a click on a clipped node where a user cannot. It runs at 620px because
the editor does fit in the 720px default.
It was the only page capped at max-w-4xl. The convention splits on content:
list pages run full width (alerts, dashboard, employees, register, reports),
settings pages sit at max-w-3xl (data-api, data-sources, notifications). This
page holds three lists, so it belongs with the first group. The cap came from
the plan and matched neither.
The page read as a prototype next to a finished app. It used none of the
vocabulary the rest of the product already has: bare ul lists instead of the
card-wrapped tables every other list page uses, an unstyled search input where
EmployeeTable insets a Search icon at max-w-sm, pagination floating outside any
container, and stacked h2 headings instead of the page-header shape.

Three lists stacked on one page also ran past 2000px. They move behind a plain
underlined tab strip, no pills and no colour, so each fits a viewport.

Every surface now reuses the existing classes: rounded-xl border-border/60
bg-card, thead on bg-muted/30 with uppercase tracked labels, rows on
hover:bg-muted/40, empty states at py-12 centered, pagination in a bordered
footer with size-7 icon buttons. Row actions reveal on hover, the pattern
PresetTab and SetupGuides already use.

The audit trail finally shows what it recorded. It listed only action, actor
and date, so a row read "role.update" with no way to tell which role or what
changed, while the API had been returning targetId, before and after all along.
It now resolves ids to names and renders a real diff: "+alerts:close,
-reports:export" for a permission change, "Viewer -> Security Manager" for an
assignment. Name lookups degrade to a short id when the viewer holds audit:read
without roles:read or users:read.
"Viewer -> Security Manager" read like terminal output, and gave both values
the same weight when only the destination matters.

The column now returns a fragment rather than a string. A reassignment steps
the previous role back in muted text behind a small arrow glyph, with the new
role in foreground. A permission change lists additions in foreground and
struck-through removals in muted, instead of joining "+a, -b" into one line.
Webhook targets are supplied by an admin, so from the server's side they are
attacker-controlled: a request the app makes reaches whatever the network
reaches. Forcing https already blocked the AWS metadata endpoint, which is http
only, but nothing stopped an internal host behind TLS.

parseOutboundUrl now rejects a literal private address, embedded credentials
and a non-https scheme, covering loopback, RFC1918, link-local (including
169.254.169.254), carrier-grade NAT, unique-local v6, and IPv4-mapped v6 that
wraps a private v4.

A hostname is also resolved and every returned address checked, because a name
that looks public can point into the private space. That runs at write time so
a bad endpoint is refused up front, and again before each delivery so a record
valid when saved cannot be repointed afterwards. Deliveries no longer follow
redirects, which would otherwise route around both checks.
Both limiters counted in a per-process Map. On more than one instance the real
ceiling was the configured limit times the number of nodes, and every deploy
reset it, which makes the protection decorative anywhere but a single
long-lived process.

Better Auth switches to its database storage, and the application limiter
behind scan, SCIM and SIEM now counts in an ApiRateLimit row. The counter is a
single INSERT ON CONFLICT that rolls the window over inside the statement, so
two requests arriving together cannot both read a stale count and both decide
they are under the limit. An integration test drives 20 concurrent calls
against a limit of 5 and asserts exactly 5 get through.

The limiter helpers become async, so their callers await. The SCIM rate-limit
tests move out of the unit suite into an itest: the counter lives in Postgres
now, and a mocked store would only prove the mock counts. Expired rows are
swept by the existing cron tick, which has no timing requirement.
The production hash ran at cost 10 while every seed script used 12. That one
line is the only place a real user's password is hashed, and it was the lowest.
Existing hashes keep verifying unchanged: bcrypt stores its cost inside the
hash, so old and new coexist and rotate naturally on the next password change.

No minimum was configured either, leaving Better Auth's default of 8 with no
complexity requirement. Thin for a product whose job is finding exposed
credentials. The 72-byte cap is bcrypt's own truncation point, made explicit so
a long passphrase is refused rather than silently cut.
CodeQL flagged this as a user-controlled bypass of a security check. The alert
predates this branch, the route last changed in c7b7167, but the reasoning
holds: `scope` arrives as untrusted JSON while its type annotation is only a
compile-time claim, so any value other than the two members skipped the
`scope === "COMPANY"` guard and its permission check.

That was not exploitable, because Prisma rejects an unknown enum member a few
lines later, but it left an authorization decision resting on a database
constraint instead of an explicit check. Narrow the value against a literal set
first and answer 400 otherwise.
@WhiteMuush
WhiteMuush force-pushed the feat/rbac-management-ui branch from 08aea8c to 2bc6f93 Compare August 5, 2026 07:29
@WhiteMuush
WhiteMuush merged commit bec0494 into develop Aug 5, 2026
13 checks passed
@WhiteMuush
WhiteMuush deleted the feat/rbac-management-ui branch August 5, 2026 07:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants