Skip to content

Release 11.28.0#564

Merged
kaihaase merged 4 commits into
mainfrom
develop
Jul 15, 2026
Merged

Release 11.28.0#564
kaihaase merged 4 commits into
mainfrom
develop

Conversation

@kaihaase

Copy link
Copy Markdown
Member

Unified 401/403 permission semantics and hardened S_SELF/S_CREATOR ownership checks. Permission errors now follow RFC 9110 consistently across all five permission layers, so authenticated users no longer get logged out by a mere permission error.

Breaking changes (see migration guide):

  • Permission errors for authenticated users now return 403 instead of 401.
  • @roles(S_NO_ONE) / @restricted(S_NO_ONE) now returns 403 for every requester (was 401).
  • Permission-error messages are the translatable ErrorCode.ACCESS_DENIED/ErrorCode.UNAUTHORIZED instead of raw strings (they no longer leak internal class/field names).
  • CoreTenantGuard returns 401 (was 403) when the request has no authenticated user.
  • Security: @restricted(S_SELF) / @restricted(S_CREATOR) on input fields now actually enforces ownership. Previously the check read the request payload instead of the persisted record. Audit these before upgrading; a field that looked owner-restricted may become writable.

New: accessDeniedException(user, message?) — an exported factory that derives 401/403 from the requester's auth state and returns the native Nest exceptions, so instanceof and @catch(...) filters keep working.

Effort: pnpm update, then update any test/handler that asserts 401 on an authenticated request or matches permission-error message text, and run the ownership audit (guide Step 5).

kaihaase and others added 4 commits July 15, 2026 10:50
….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.
… was hiding (#561)

`tsc --noEmit -p tsconfig.json` reported 8 errors on develop. Nothing in the check chain runs it
(`nest build` uses tsconfig.build.json, which already excludes the two problem files), so the errors
only surfaced as red squiggles in the editor -- and were ignored long enough that a genuine bug hid
among them.

The real bug: vite.config.ts sets `tsCompiler: 'esbuild'`, but vite-plugin-node@8 narrowed
SupportedTSCompiler to 'vite' | 'swc'. The config would not work if anyone ran it. Switched to
'swc', which the project already uses elsewhere (`nest start -b swc`).

The rest were configuration mismatches, each fixed at the root rather than silenced:

- scripts/generate-framework-api.ts shimmed __dirname from `import.meta.url`, which cannot typecheck
  under `module: commonjs`. The package IS CommonJS (no "type": "module"), so tsx runs the script as
  CJS and __dirname is native -- the shim was never needed. Removed. (A separate ESM tsconfig for
  scripts/ is not an option: scripts/init-server.ts imports src/, which would drag the whole
  `import x = require(...)` tree into ESM mode.)
- vite.config.ts and the migrate template are excluded from the root tsconfig, mirroring
  tsconfig.build.json. vite@8 is ESM-only and cannot be resolved under `moduleResolution: node`,
  which `module: commonjs` forces; the template deliberately imports '@lenne.tech/nest-server' because
  it is copied into consumer projects.
- performance-caches.spec.ts marked a method `override` against a base class whose compile-time type
  is `any` (it comes from a dynamic import), which TS rejects outright (TS4113).
- unified-field-whitelist.spec.ts redeclared `email` without an initializer (TS2612). Added
  `= undefined`, matching every Input class in the codebase.

Verification: `tsc --noEmit` now reports 0 errors; the generator still runs; full check green.
…across the permission layers (#559)

* 11.28.0: unify the 401/403 policy and fix ownership evaluation across 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.

* fix(tests): stop wiping the shared jwks keyset, which stranded other 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.

---------

Co-authored-by: Kai Haase <kai.haase@lenne.tech>
…t step

Two gaps in the 11.27.7→11.28.0 guide, found while reviewing it against the merged 11.28.0 state:

- "work through the four breaking changes" → five (the S_SELF/S_CREATOR ownership change is BC5).
- The numbered Detailed Migration Steps covered BC1–4 but not BC5 — so a consumer working the steps
  top-to-bottom would skip the one change with security impact (input-field ownership now enforced).
  Added it as Step 5 (audit S_SELF/S_CREATOR on input types), Verify becomes Step 6.

Docs only; no code change.
@kaihaase
kaihaase merged commit acc56b7 into main Jul 15, 2026
1 check passed
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