Skip to content

11.28.0: unify the 401/403 policy and fix S_SELF/S_CREATOR ownership across the permission layers#559

Merged
kaihaase merged 2 commits into
developfrom
fix/403-for-permission-errors
Jul 15, 2026
Merged

11.28.0: unify the 401/403 policy and fix S_SELF/S_CREATOR ownership across the permission layers#559
kaihaase merged 2 commits into
developfrom
fix/403-for-permission-errors

Conversation

@DKoenig9

@DKoenig9 DKoenig9 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Rebased onto 11.27.7 (#560). The commit history was rebuilt on the current develop; the branch now targets 11.27.7 → 11.28.0. It also absorbs the former fix/s-self-input-path — the ownership fix ships here, in one release, so consumers audit their @Restricted fields once.

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-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 — 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, which an earlier draft used, breaks both — that was the review's headline finding.)

Layer Was Now
check() / checkRestricted() / prepareInput() 401 for authenticated 403 authenticated, 401 anonymous
CoreTenantMemberModel.securityCheck() 401 for an authenticated non-member (the bug) 403
S_NO_ONE 401 (guards) / 403 (tenant) / mixed (check) 403 for everyone — authenticating can never unlock it
CoreTenantGuard, no user 403 (inverted) 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 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 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:

PATCH /users/<victim>  { "id": "<attacker>", "createdBy": "<attacker>", "<restricted>": ... }

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 (data IS the persisted object there); create() has no dbObject, so ownership can't be established — matching check().

This is latent hardening, not a live-exploit fix: MapAndValidatePipe strips id/createdBy from 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 the S_CREATOR trap (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.md with before/after and search recipes:

  1. Authenticated permission errors → 403
  2. S_NO_ONE403 for everyone
  3. Permission-error messages → ErrorCodes
  4. CoreTenantGuard401 when unauthenticated
  5. S_SELF/S_CREATOR on input fields now enforce ownership — audit required

Also

  • 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, so a new exception can't stay invisible).
  • versioning.md now documents the spectaql.yml version lockstep — a recurring release miss.

Tests

The unit spec asserts the contract that actually broke — instanceof + wire format — plus all five layers, S_NO_ONE across 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. Full pnpm run check green, including #560's check:swc-tdz (the new accessDeniedException import introduces no cycle).

Second commit is separable

fix(tests): stop wiping the shared jwks keyset fixes a pre-existing bug (confirmed on develop): one e2e file dropped the shared BetterAuth signing keyset in a beforeAll, 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(), and prepareInput() to throw ForbiddenException when user/currentUser.id is present, keeping UnauthorizedException for 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.

Comment thread migration-guides/11.27.6-to-11.28.0.md Outdated
| **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. |

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@DKoenig9

Copy link
Copy Markdown
Contributor Author

Review follow-up addressed (from the internal Linear review, DEV-2489):

  • 1358987 — the auth-state ternary (user?.id ? ForbiddenException : UnauthorizedException) was duplicated 6× across 3 files; it is now centralized in the new AccessDeniedException (src/core/common/exceptions/, matching the existing exceptions convention). It extends HttpException, picks 403/401 in the constructor, and builds the body via HttpException.createBody — a new unit spec asserts exact body parity with the native Forbidden/Unauthorized exceptions for both auth states, so responses cannot drift from the RolesGuard. Placed under common/ (not auth/exceptions/) so common does not depend on a module; exported via src/index.ts.
  • 5c3eba3 — migration-guide clarification from the Copilot review comment (exception type changes, not just the status code).

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.

@DKoenig9

Copy link
Copy Markdown
Contributor Author

Multi-agent review of the full PR (incl. 909f153 + cc3fea5) — findings fixed in df33984

Per the nest-server change process, the complete diff (develop..HEAD, all 5 commits) went through another /lt-dev:review-style pass — three parallel reviewers (backend conventions, security semantics, test quality) plus a full pnpm run check.

Security verdict: SHIP. The 401/403 decision is derived from a non-spoofable signal (user.id from guard-validated request state) at every call site, fails closed to 401, and the change strictly reduces information disclosure (class/field names + rejected role lists moved to debug logs). The better-auth controller catch-broadening closes a real masking bug (rights-403s no longer collapse to 500).

Findings that were fixed in df33984:

  1. checkRestricted() did not implement the documented S_NO_ONE → 403 always invariant (backend + security, found independently). The docs shipped in this PR (REQUEST-LIFECYCLE.md, role-system.md, migration guide BC2) declare 403-for-everyone for S_NO_ONE, but both checkRestricted throw sites routed through accessDeniedException → anonymous requesters got 401. Fixed: both sites now special-case S_NO_ONE (same rationale comment as the guards), with the flatten logic extracted so the detection honors processType-scoped restrictions.
  2. Two of the five layers had zero test execution (test review): BetterAuthRolesGuard (only registered in IAM-only setups — no story file exercised it) and prepareInput's roles processing. New tests/unit/access-denied-call-sites.spec.ts (15 tests) pins all previously unexercised layers in both directions (401 anonymous / 403 authenticated / S_NO_ONE), including that rejected role names no longer leak to the client.
  3. Migration guide gaps: BC3 now lists the remaining changed messages (Missing roles of current user, Access to tenant membership denied, Authentication required, tenant-guard Access denied) with matching Step-3 greps (exact toEqual('Access denied') assertions break; toContain survives); the BC2 table names checkRestricted() explicitly.
  4. Minors: service.helper rejected-roles log now goes through Logger.debug (respects log level) instead of a bare console.debug; the anonymous-401 e2e asserts the ErrorCode its comment promised; unit spec imports vitest explicitly.

Validation: full pnpm run check green on the final state — 2095 tests / 74 files, lint + format clean, build + server-start ok, pnpm audit 0 findings.

Documented follow-ups (deliberately NOT in this PR):

  • The AI module services are an un-unified "sixth layer": core-ai-prompt.service.ts:42/144 throw 403 with a raw string for missing authentication (should be 401 per this PR's policy), and core-ai-prompt.service.ts:161 / core-ai-slot.service.ts:354/479 return raw-string 403s (one interpolating internal error messages). Normally shadowed by guards, reachable via internal/MCP call paths → separate PR migrating them to accessDeniedException + ErrorCode.
  • RolesGuard.checkGraphQL's S_NO_ONE branch has no direct test (only the REST canActivate copy is exercised) — would need a @Roles(S_NO_ONE) test resolver.

From my side the PR is ready to merge into develop.

kaihaase added 2 commits July 15, 2026 10:26
… 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.
@kaihaase
kaihaase force-pushed the fix/403-for-permission-errors branch from df33984 to 41901d8 Compare July 15, 2026 08:29
@kaihaase kaihaase changed the title 11.28.0: throw 403 instead of 401 for permission errors of authenticated users 11.28.0: unify the 401/403 policy and fix S_SELF/S_CREATOR ownership across the permission layers Jul 15, 2026
kaihaase added a commit that referenced this pull request Jul 15, 2026
….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.
kaihaase added a commit that referenced this pull request Jul 15, 2026
….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.
@kaihaase
kaihaase merged commit e7216fe into develop Jul 15, 2026
1 check passed
@kaihaase
kaihaase deleted the fix/403-for-permission-errors branch July 15, 2026 08:52
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.

3 participants