11.28.0: unify the 401/403 policy and fix S_SELF/S_CREATOR ownership across the permission layers#559
Conversation
There was a problem hiding this comment.
Pull request overview
Adjusts authorization error semantics so authenticated users who lack permissions receive 403 Forbidden (instead of 401), aligning service-layer rights checks with the existing RolesGuard behavior and preventing frontend “auto-logout on 401” flows from triggering on mere permission failures.
Changes:
- Switch permission-denied paths in
check(),checkRestricted(), andprepareInput()to throwForbiddenExceptionwhenuser/currentUser.idis present, keepingUnauthorizedExceptionfor unauthenticated callers. - Update the affected e2e assertion from 401 → 403 and document the new status-code semantics.
- Bump package version to 11.28.0 and add a migration guide for consumer updates.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/server.e2e-spec.ts | Updates e2e assertion to expect 403 for authenticated permission failure. |
| src/core/common/helpers/service.helper.ts | Throws 403 (ForbiddenException) when authenticated users attempt disallowed role assignment. |
| src/core/common/helpers/input.helper.ts | Throws 403 for authenticated “Missing rights/No access” cases, retains 401 for unauthenticated. |
| src/core/common/decorators/restricted.decorator.ts | Throws 403 for authenticated @Restricted violations, retains 401 for unauthenticated. |
| package.json | Version bump to 11.28.0. |
| migration-guides/11.27.6-to-11.28.0.md | Adds migration documentation for 401 → 403 change. |
| FRAMEWORK-API.md | Regenerated header with new date/version. |
| docs/REQUEST-LIFECYCLE.md | Documents the 401 vs 403 semantics for rights checks starting v11.28.0. |
| | **Breaking Changes** | Permission errors for **authenticated** users now return **403 Forbidden** instead of 401 Unauthorized. Affected paths: `check()` (`checkRights`, "Missing rights" / "No access"), `checkRestricted()` (object- and field-level `@Restricted` violations), and the roles-setting checks in `prepareInput` ("Missing roles of current user" / "Current user not allowed setting roles"). 401 is still returned for **unauthenticated** requesters on all of these paths. Error **messages are unchanged**. | | ||
| | **New Features** | None. | | ||
| | **Bugfixes** | Status-code semantics per RFC 9110: 401 means "not authenticated", 403 means "authenticated but not allowed". Previously a mere permission error was indistinguishable from an expired session — frontends that auto-logout on 401 (e.g. `@lenne.tech/nuxt-extensions`' auth interceptor) kicked logged-in users out of the app when they merely lacked a right, and 401 logs mixed real auth failures with permission failures. | | ||
| | **Behavior Changes** | Only the HTTP status code (and GraphQL `originalError.statusCode`) of the listed rights checks changes, and only when a `currentUser` with an `id` is present. Exceptions thrown for unauthenticated requesters, token errors (`Invalid token`, `Token expired`), and the `RolesGuard` (which already used 401/403 correctly) are unchanged. | |
There was a problem hiding this comment.
Fixed in 5c3eba3: the Behavior-Changes row now names the exception-type switch explicitly and lists all consumer-visible effects (REST body "error": "Forbidden" instead of "Unauthorized", GraphQL originalError.error, and instanceof UnauthorizedException matches in exception filters/interceptors). The "Who is affected" section gained a matching entry for server-side exception filters and extended test guidance.
|
Review follow-up addressed (from the internal Linear review, DEV-2489):
Suites after the refactor: 1380/1380 e2e + 694/694 unit, lint/format/build green. The consuming GEQ project mirrors this state 1:1 in its vendored core. |
Multi-agent review of the full PR (incl. 909f153 + cc3fea5) — findings fixed in df33984Per the nest-server change process, the complete diff ( Security verdict: SHIP. The 401/403 decision is derived from a non-spoofable signal ( Findings that were fixed in df33984:
Validation: full Documented follow-ups (deliberately NOT in this PR):
From my side the PR is ready to merge into |
… the permission layers Rebased onto 11.27.7 (#560). Two related security/consistency fixes to the permission checks. ## 401/403 policy — one rule across all five layers Permission errors for AUTHENTICATED users were thrown as UnauthorizedException (401). Frontends treat 401 as "session expired" and auto-logout (the @lenne.tech/nuxt-extensions interceptor patches $fetch/fetch globally), so a mere permission error kicked logged-in users out of the whole app. `accessDeniedException(user, message?)` — a FACTORY, not a class — now derives the status from the requester's auth state and returns the NATIVE ForbiddenException / UnauthorizedException, so `instanceof` and `@Catch(...)` filters in consumer projects keep working and the REST wire body is unchanged (a custom HttpException subclass would break both). The five layers now agree: - check() / checkRestricted() / prepareInput(): 403 when authenticated, 401 otherwise - CoreTenantMemberModel.securityCheck(): was 401 for an authenticated non-member — the exact bug — now 403 - S_NO_ONE: was 401 (guards) / 403 (tenant guard) / mixed (check) — now 403 everywhere, for everyone (authenticating can never unlock it, so 401 would be a lie) - CoreTenantGuard: threw 403 at UNAUTHENTICATED requesters ("Authentication required") — now 401 Messages are the translatable ErrorCode.ACCESS_DENIED / ErrorCode.UNAUTHORIZED the guards already throw (the old raw strings had no #LTNS_ marker, so the frontend could not translate them, and they leaked internal class/field names into the response — those go to the debug log now). ## Ownership (S_SELF / S_CREATOR) read from the persisted object, not the payload checkRestricted() decided S_SELF/S_CREATOR from `data`. On the input path `data` IS the caller DTO, so an authenticated attacker could unlock an owner-restricted field on someone else's record by asserting ownership in the body — the service writes to the target it was called with, not to the ids in the payload. Three reads were affected (S_SELF, S_CREATOR, and the isCreatorOfParent trust that propagates into nested objects); all now read from config.dbObject on input, matching check(), which always did. Output is unchanged (there `data` IS the persisted object). Where no dbObject exists (create), ownership cannot be established — matching check() and the old behavior for honest callers. The fields were effectively inert before (MapAndValidatePipe strips id/createdBy), so this is a latent hardening, not a live-exploit fix — but it MUST be audited before adopting, because a field that looked owner-restricted may become writable. An instance-wide scan of all consumer projects found none currently exploitable; the migration guide has the audit steps and the S_CREATOR trap (the inviter, not the user, is the "creator" in invite flows). BREAKING: (1) authenticated permission errors → 403, (2) S_NO_ONE → 403 for everyone, (3) messages → ErrorCodes, (4) CoreTenantGuard → 401 when unauthenticated, (5) S_SELF/S_CREATOR on input fields now enforce ownership. See migration-guides/11.27.7-to-11.28.0.md. Also hardened: core-better-auth.controller re-throws `instanceof HttpException` instead of an enumeration that masked everything it forgot as a 500. FRAMEWORK-API.md gained an Errors & Status Codes section (extracted from source). versioning.md now documents the spectaql.yml version lockstep. Tests: factory contract (instanceof + wire format), all five layers, S_NO_ONE across paths, and the ownership fix in both directions (attack rejected, owner allowed) — red-green verified.
…files' JWTs better-auth-integration.story.test.ts dropped the whole BetterAuth signing keyset in a beforeAll. All e2e files share ONE database per run and execute in parallel forks, so the wipe invalidated every JWT that other files had already minted — ai.e2e-spec.ts caches its bearer tokens in beforeAll and reuses them across ~40 tests, which then failed with 401 en bloc. retry could not help: the token was durably dead. Reproduced deterministically by pairing the two files, and confirmed on develop — predates the 401/403 work. The wipe guarded against keys left over from an earlier run under a different secret; since 11.24.4 every run gets a fresh, unique database, so that state can no longer exist. Separable from the 11.28.0 change — kept on this branch only because the pipeline cannot go green without it.
df33984 to
41901d8
Compare
….28.0 review
Two unrelated bits of housekeeping that were sitting in the working tree.
**.gitignore** — `.lt-dev/` is created per worktree by the `lt dev` CLI and has no business being
tracked.
**.claude/agent-memory/** — written by the review agents during the 11.28.0 (401/403) review. These
are the findings worth carrying forward, not the review prose:
- `security-reviewer/project-exception-wire-format.md` — `HttpExceptionLogFilter` serializes
`{ ...exception }` for REST, so a custom `HttpException` subclass silently changes the wire body
(`name`, `options`) AND breaks `instanceof` against the native Nest exceptions. This is the finding
that would otherwise have shipped a silent breaking change in #559.
- `backend-reviewer/project_401-403-denial-surface.md` — the framework has five permission-denial
layers, and they used to contradict each other. A change to one is a change to none.
- `docs-reviewer/release-version-artifacts.md` — `spectaql.yml` carries the version too and is
derived from `package.json`, but only by `pnpm run docs`, never by `build`. It is therefore
reliably forgotten in release commits.
- `test-reviewer/e2e-isolation-model.md` — e2e files share ONE database per run and execute in
parallel forks, so any unscoped write to global state (a `jwks` wipe, a config mutation) strands
the other files. Pair-run to reproduce; an isolated re-run proves nothing.
Kept separate from #559 and #561 on purpose: neither is part of a code change.
….28.0 review (#562) Two unrelated bits of housekeeping that were sitting in the working tree. **.gitignore** — `.lt-dev/` is created per worktree by the `lt dev` CLI and has no business being tracked. **.claude/agent-memory/** — written by the review agents during the 11.28.0 (401/403) review. These are the findings worth carrying forward, not the review prose: - `security-reviewer/project-exception-wire-format.md` — `HttpExceptionLogFilter` serializes `{ ...exception }` for REST, so a custom `HttpException` subclass silently changes the wire body (`name`, `options`) AND breaks `instanceof` against the native Nest exceptions. This is the finding that would otherwise have shipped a silent breaking change in #559. - `backend-reviewer/project_401-403-denial-surface.md` — the framework has five permission-denial layers, and they used to contradict each other. A change to one is a change to none. - `docs-reviewer/release-version-artifacts.md` — `spectaql.yml` carries the version too and is derived from `package.json`, but only by `pnpm run docs`, never by `build`. It is therefore reliably forgotten in release commits. - `test-reviewer/e2e-isolation-model.md` — e2e files share ONE database per run and execute in parallel forks, so any unscoped write to global state (a `jwks` wipe, a config mutation) strands the other files. Pair-run to reproduce; an isolated re-run proves nothing. Kept separate from #559 and #561 on purpose: neither is part of a code change.
What this does
Two related security/consistency fixes to the permission checks, plus one separable test-infra fix.
1. One 401/403 policy across all five permission layers
Permission errors for authenticated users were thrown as 401. Frontends treat 401 as "session expired" and auto-logout (the
@lenne.tech/nuxt-extensionsinterceptor patches$fetch/fetchglobally), so a mere permission error kicked logged-in users out of the whole app.accessDeniedException(user, message?)— a factory, not a class — derives the status from the requester's auth state and returns the nativeForbiddenException/UnauthorizedException, soinstanceofand@Catch(...)filters in consumer projects keep working and the REST wire body is unchanged. (A customHttpExceptionsubclass, which an earlier draft used, breaks both — that was the review's headline finding.)check()/checkRestricted()/prepareInput()CoreTenantMemberModel.securityCheck()S_NO_ONEcheck)CoreTenantGuard, no userMessages are the translatable
ErrorCode.ACCESS_DENIED/ErrorCode.UNAUTHORIZEDthe guards already throw (the old raw strings had no#LTNS_marker, so the frontend couldn't translate them, and they leaked internal class/field names — those go to the debug log now).2. Ownership (
S_SELF/S_CREATOR) read from the persisted object, not the payloadcheckRestricted()decidedS_SELF/S_CREATORfromdata. On the input pathdatais the caller DTO, so an authenticated attacker could unlock an owner-restricted field on someone else's record by asserting ownership in the body — the service writes to the target it was called with, not to the ids in the payload:Three reads were affected —
S_SELF,S_CREATOR, and theisCreatorOfParenttrust that propagates into nested objects — all now read fromconfig.dbObjecton input, matchingcheck(), which always did. Output is unchanged (dataIS the persisted object there);create()has nodbObject, so ownership can't be established — matchingcheck().This is latent hardening, not a live-exploit fix:
MapAndValidatePipestripsid/createdByfrom payloads, so the branch usually couldn't fire and the fields were effectively admin-only. But it must be audited before adopting — a field that looked owner-restricted may become writable once it starts working. An instance-wide scan of all consumer projects found none currently exploitable; the migration guide has the audit steps and theS_CREATORtrap (in invite flows the inviter, not the user, is the "creator").Breaking changes
Five, all in
migration-guides/11.27.7-to-11.28.0.mdwith before/after and search recipes:S_NO_ONE→ 403 for everyoneErrorCodesCoreTenantGuard→ 401 when unauthenticatedS_SELF/S_CREATORon input fields now enforce ownership — audit requiredAlso
core-better-auth.controllerre-throwsinstanceof HttpExceptioninstead of an enumeration that masked everything it forgot as a 500.FRAMEWORK-API.mdgained an Errors & Status Codes section (extracted from source, so a new exception can't stay invisible).versioning.mdnow documents thespectaql.ymlversion lockstep — a recurring release miss.Tests
The unit spec asserts the contract that actually broke —
instanceof+ wire format — plus all five layers,S_NO_ONEacross paths, and the ownership fix in both directions (attack → 403, owner allowed). A test-expectation bug (a 401 asserted where an authenticated attacker correctly gets 403) surfaced during the rebuild and was fixed. Fullpnpm run checkgreen, including #560'scheck:swc-tdz(the newaccessDeniedExceptionimport introduces no cycle).Second commit is separable
fix(tests): stop wiping the shared jwks keysetfixes a pre-existing bug (confirmed ondevelop): one e2e file dropped the shared BetterAuth signing keyset in abeforeAll, stranding JWTs that other parallel files had already minted. Kept here only because the pipeline can't go green without it; cherry-pick it separately if preferred.🤖 Generated with Claude Code