fix(auth): require an MP security role for contact reads and writes (F1, F3, F10) - #83
Merged
Merged
Conversation
…equest
`AuthorizationService` only knew how to gate writes. Generalize it so the same
single source of truth can decide reads too, ahead of wiring it into the read
paths (F1).
What changed:
- `requireSecurityRole({ table, operation })` accepts `read` alongside the three
write verbs and returns the acting MP `User_ID`.
`requireSecurityRoleForWrite` stays as a thin alias, with `operation` narrowed
to the write verbs so a `read` passed at a write boundary is a type error;
every existing call site and test keeps working.
- `hasSecurityRole()` is the non-throwing form the throwing gate is now built
on, returning `{ permitted, userId, reason }`. It is what a redirect or a UI
affordance should ask. It reports a denial but still THROWS on infrastructure
failure (MP unreachable, an unusable acting `User_ID`), so "MP is down" can
never be mistaken for "this user is not allowed" — and it logs nothing, since
it runs on every profile load and the UI asking "may they?" is not an incident.
- Reads resolve the acting user with `SessionContextService.getCurrentUserId`;
writes keep `getActingUserIdForWrite`, so an unattributed write still emits
`mp.write.non_user` before the gate refuses it.
- Denials log `mp.read.unauthorized` for reads, parallel to the existing
`mp.write.unauthorized`. Same shape, same `no_mp_user` / `no_security_role` /
`role_not_permitted` reasons.
- Policy env var is now `MP_SECURITY_ROLES` (reads and writes).
`MP_WRITE_SECURITY_ROLES` is read as a deprecated fallback when the new one is
unset or blank, so an existing deployment is not silently widened to "any
role" — but it now governs reads as well, and the new var wins where both are
set.
Caching: the gate will run at up to three layers per request once it is wired
in, so the `dp_User_Roles` read is memoized PER REQUEST with React's `cache()`,
keyed by `User_ID` — one role read per request regardless of how many layers
ask. There is still no cross-request cache of any kind: roles are re-read on the
next request, so a revoked role stops working immediately, and a per-request
memo cannot outlive the request that created it. Outside a React request scope
(Vitest, plain Node) `cache()` is a passthrough, so the tests observe the
uncached behaviour — which is exactly the behaviour that must hold in both
environments.
Verified: `npx vitest run --coverage` (all thresholds met),
`npx tsc --noEmit`, `npm run lint`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…F1, F10) Every read path in the contact features — search, details, logs — and the page guard checked only that a session existed. That proved nothing: MP's OIDC endpoint authenticates ANY `dp_Users` record, and this app fetches all MP data with its own client-credentials service account (`dataplatform/scopes/all`), so MP's per-user record security never applies to what the app returns. Any MP user in the domain could read every contact's email and phone, and every pastoral contact log, through this app. Writes were already role-gated; reads were not. Policy (from the product owner): any MP user may sign in and use the app shell; the contact-lookup and contact-log features require an MP security role. Sign-in is deliberately NOT role-gated — no check in `getUserInfo`, `mapProfileToUser`, `customSession` or `AuthWrapper` — so a role-less user gets a session, the header, the user menu and a working sign-out. Enforced at three independent layers, because each is independently reachable (a server action is a callable POST endpoint whether or not its page rendered): - Page: `src/app/(web)/contactlookup/layout.tsx` redirects a role-less user to the new `/no-access`. It covers `[guid]/page.tsx` too — React renders the layout first and only renders `children` once it returns, so the detail page's own action calls never run. `/no-access` lives INSIDE the (web) group on purpose: the session is valid, so the user keeps the shell and, crucially, sign-out. No auto-redirect — the fix is an administrator granting a role. - Server action: every exported action in `contact-lookup/`, `contact-lookup-details/` and `contact-logs/` now calls the gate instead of `requireSession()`. The gate implies an authenticated session, so it replaces the session check rather than following it. `shared-actions/domain.ts` (`getMpTimezone`) gains a session check — it had none at all (F11); a session check is sufficient there, since it returns one domain-wide config string and its only consumer is the now-gated contact page. - Service: the read methods on `ContactService` and `ContactLogService` gate too, so a future caller that bypasses the actions still comes up empty. Writes take `$userId` from the gate's return value rather than resolving the acting user separately; the gate routes through `SessionContextService`, so the structured `mp.write.non_user` log still fires before a denial. F10: `ContactService.updateContact` wrote to `Contacts` using only `getActingUserIdForWrite`, which logs and proceeds when no user resolves. It now calls the gate and uses its `User_ID` for `$userId`. Unreferenced today, but it was a loaded footgun. UX layer — explicitly not a security control: the sidebar entry and the new dashboard tile (`components/home-demos/`) are hidden for users without access, so nobody is handed a link that only redirects them. Both read `canAccessContactFeatures`, computed SERVER-SIDE by `getCurrentUserProfile` from the same gate (`hasSecurityRole`), never derived on the client from role names, and tested to fail closed when the profile or the flag is absent. Verified: `npx vitest run --coverage` — 897 tests, 54 files, all thresholds met including the `src/app/**` gate; `npx tsc --noEmit`; `npm run lint`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`callbackUrl` comes straight off the query string and was assigned to `window.location.href` whenever the visitor already had a session, so `/signin?callbackUrl=https://evil.example` bounced the user off-site from a URL that looks like this app's own login page — a credible phishing hop. `sanitizeCallbackUrl` reduces the value to a same-origin relative path: it must start with `/` and must not start with `//` (protocol-relative, another origin) or `/\` (which browsers normalize to `//`). Anything else falls back to `/`. The sanitized value feeds BOTH sinks — the `location.href` assignment, where no server is involved at all, and the `callbackURL` handed to `signIn.social`, which better-auth also validates server-side. Sanitizing at the source rather than at each sink means a future third use cannot miss it. Tests cover absolute http/https, protocol-relative, backslash-escaped, `javascript:` and bare-relative inputs against both sinks, and assert that legitimate deep links (`/contactlookup?x=1`, `/contactlookup/abc?tab=logs`) still survive the round trip. Verified: `npx vitest run --coverage`, `npx tsc --noEmit`, `npm run lint`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Records the policy and the mechanism behind F1/F3/F10 so the next reader does not have to reconstruct it from the diff. - `.claude/references/auth.md`: rewritten "Authorization" section — the decided policy (any MP user may sign in; the contact features need a role), why reads needed a gate at all, the three enforcement layers plus the UX layer in a table with file paths, the gate API, `MP_SECURITY_ROLES` with the deprecated `MP_WRITE_SECURITY_ROLES` fallback, per-request memoization via React `cache()` and why there is still no cross-request cache, `/no-access`, the F3 sanitizer, and a closed-findings table dated 2026-09-12. Also updated the File Map, a new "authentication is not authorization" table under Route Protection, and the "Server Actions" snippet, which now shows the read gate and says which two actions may still use a bare session check. - `.env.example`: `MP_SECURITY_ROLES` with the legacy-fallback note. - `CLAUDE.md`: new key practice — feature server actions AND service methods that touch MP data call `AuthorizationService`, not a bare session check. - `.claude/references/components.md`: `home-demos/`, the new route files, and an authorization note on the actions inventory. - `.claude/references/testing.md`: the gate mock pattern, the `cache()` passthrough caveat for Vitest, and the refreshed inventory and coverage figures (897 tests / 54 files; 99.47% stmts, 96.65% branch). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the three open findings from the 2026-09-12 auth security review: F1 (High), F3 (Medium) and F10 (Low). Also closes F11 (Low) in passing, since it was one line in a file already being touched.
The findings
F1 (High) — reads were gated on nothing but a session. Contact search, contact details and contact logs — plus the page guard — checked only that
auth.api.getSession()returned something. That proves nothing here: MP's OIDC endpoint authenticates anydp_Usersrecord in the domain, and this app fetches all MP data with its own client-credentials service account (dataplatform/scopes/all), so MP's per-user record security never filters what the app returns. Any MP user could read every contact's email and phone number, and every pastoral contact log, through this app. Writes were already role-gated byrequireSecurityRoleForWrite; reads were not.F3 (Medium) — open redirect on
/signin.callbackUrlcame off the query string and was assigned straight towindow.location.hreffor a visitor who already had a session, so/signin?callbackUrl=https://evil.examplebounced the user off-site from a URL that looks like this app's own login page.F10 (Low) —
ContactService.updateContactwrote with no authorization. It took its acting user fromSessionContextService.getActingUserIdForWrite, which logs and proceeds when none resolves, and never consultedAuthorizationService. Unreferenced today, but a loaded footgun.F11 (Low) —
getMpTimezonehad no check at all. The one server action in the app with no gate of any kind.Policy
Sign-in is deliberately not role-gated: no check in
getUserInfo/mapProfileToUser, incustomSession/enrichSessionUser, or inAuthWrapper. A role-less user must be able to reach a page that explains the problem and offers a sign-out, not be bounced off the login screen.AuthWrapperis unchanged and remains the authentication gate for the(web)group.Ownership (
Made_By) remains deliberately irrelevant — contact logs are shared pastoral records and staff need to correct each other's entries.Enforcement layers
Three enforcement layers plus one presentation layer. Each enforcement layer re-checks, because each is independently reachable — a server action is a callable POST endpoint whether or not the page that calls it ever rendered.
src/app/(web)/contactlookup/layout.tsxhasSecurityRole→redirect("/no-access"). Covers[guid]/page.tsxtoo: React renders the layout first and only renderschildrenonce it returns, so the detail page and its server-action calls never runsrc/app/(web)/no-access/page.tsx(web)group so the header and sign-out survive; static, no auto-redirect (the fix is an administrator granting a role in MP)src/components/contact-lookup/actions.tssearchContacts— gate replaces the bare session checksrc/components/contact-lookup-details/actions.tsgetContactDetails,getContactLogsByContactIdsrc/components/contact-logs/actions.tsrequireSession()is gone, reads gate like writessrc/components/shared-actions/domain.tsgetMpTimezone— authenticated-session check (F11). A session check is sufficient: one domain-wide config string, and its only consumer is the now-gated contact pagesrc/services/contactService.tscontactSearch,getContactByGuid, andupdateContact(F10 —$userIdnow comes from the gate)src/services/contactLogService.tsgetContactLogTypes,searchContactLogs,getContactLogById,getContactLogsByContactId,createContactLog,updateContactLog,deleteContactLogsrc/components/layout/sidebar.tsxsrc/components/home-demos/contact-lookup-demo-card.tsxnullwithout access; mounted in a<Suspense>boundary bysrc/app/(web)/page.tsxThe UX layer reads
canAccessContactFeatures, a boolean computed server-side bygetCurrentUserProfilefrom the same gate (hasSecurityRole). The client never derives policy from role names, and both consumers test=== trueso a null or flagless profile fails closed.Gate API
src/services/authorizationService.tsis still the single source of truth.requireSecurityRole(ctx: { table: string; operation: "read" | "create" | "update" | "delete" }) => Promise<number>UnauthorizedError; returns the acting MPUser_IDrequireSecurityRoleForWriteoperationnarrowed to the write verbsreadat a write boundary is a type errorhasSecurityRole(ctx) => Promise<{ permitted, userId, reason }>hasSecurityRolereports a denial aspermitted: falsebut still throws on infrastructure failure (MP unreachable, an unusable actingUser_ID), so an outage can never read as a company-wide permissions change. Denials logmp.read.unauthorized(reads) ormp.write.unauthorized(writes) — same shape, sameno_mp_user/no_security_role/role_not_permittedreasons. Writes still resolve their acting user throughgetActingUserIdForWrite, somp.write.non_userfires before a denial.Caching. The
dp_User_Rolesread is memoized per request with React'scache(), keyed byUser_ID— one MP role read per request no matter how many of the three layers ask. There is still no cross-request cache: no module-level map, no TTL, so a revoked role stops working on the user's very next request.Environment variable
MP_SECURITY_ROLESis the new general variable — a comma-separated allow-list applying to reads and writes, case- and whitespace-insensitive, unset or blank meaning "any security role".MP_WRITE_SECURITY_ROLESis deprecated but still read as a fallback when the new variable is unset or blank, so an existing deployment is not silently widened to "any role". It now governs reads too, andMP_SECURITY_ROLESwins where both are set. Documented in.env.exampleandauth.md.Behaviour change for a role-less user
/contactlookupor a/contactlookup/<guid>deep link → redirected to/no-accessUnauthorizedError, logged asmp.read.unauthorized/mp.write.unauthorizedA user who already holds any MP security role sees no change at all, unless
MP_SECURITY_ROLES/MP_WRITE_SECURITY_ROLESis set to a narrower list.Tests
897 tests across 54 files, all passing. Coverage thresholds all met, including the
src/app/**gate PR #81 added.New files
src/app/(web)/contactlookup/layout.test.tsxsrc/components/home-demos/contact-lookup-demo-card.test.tsxsrc/app/(web)/no-access/page.test.tsxUpdated files
src/services/authorizationService.test.tssrc/components/contact-logs/actions.test.tssrc/services/contactLogService.test.tssrc/components/contact-lookup-details/actions.test.tssrc/app/signin/page.test.tsxsrc/services/contactService.test.tssrc/components/shared-actions/user.test.tssrc/components/layout/sidebar.test.tsxsrc/components/contact-lookup/actions.test.tssrc/app/(web)/page.test.tsxsrc/components/shared-actions/domain.test.tsSpecifically asserted: a session with no MP user is refused by every read action; an MP user with zero roles is refused; a role-holder is allowed;
MP_SECURITY_ROLESrestricts reads and writes andMP_WRITE_SECURITY_ROLESstill works as a fallback (and loses when both are set); service methods deny without a role even when called directly;updateContactpasses the gate'sUser_IDas$userIdand throws without a role;callbackUrl=https://evil.example,//evil.exampleand/\evil.exampleall resolve to/while/contactlookup?x=1survives; and the sidebar and dashboard tile hide/show bycanAccessContactFeatures, failing closed without it.A note on
cache()under Vitest: outside a React request scope it is a passthrough (React calls straight through with no cache dispatcher installed), so the tests assert only what holds in both environments — "no decision is carried across calls" — and never a hit count that depends on a live memo. This is documented intesting.mdand in the service's own comments.Docs
.claude/references/auth.md— rewritten Authorization section, a new "authentication is not authorization" table under Route Protection, updated File Map and Server Actions snippet, and a closed-findings table..env.example—MP_SECURITY_ROLESwith the legacy-fallback note.CLAUDE.md— new key practice: feature server actions and service methods that touch MP data callAuthorizationService, not a bare session check..claude/references/components.md—home-demos/, the new route files, authorization note on the actions inventory..claude/references/testing.md— the gate mock pattern, thecache()caveat, refreshed inventory and coverage figures.Verification
npx vitest run --coverage— 897 passed, 54 files, exit 0; 99.47% stmts / 96.65% branch / 98.92% funcs / 99.73% lines, every threshold met.npx tsc --noEmit -p tsconfig.json— clean.npm run lint— clean.package-lock.json/deps-known-issues.md/deps-auditchanges were left untouched — every commit staged by explicit path.🤖 Generated with Claude Code