feat(permissions): optional AUTHENTICATED_ABILITY_FALLBACK hook for AbilityMiddleware#174
Merged
Merged
Conversation
…bilityMiddleware AbilityMiddleware installs an EMPTY ability whenever an authenticated request cannot be resolved to a tenant (no `session.activeOrganizationId`, not a hub-operator path). For a multi-tenant deployment that is the correct default: a `@Can()`-gated route then denies via `CanGuard`, and non-`@Can()` routes are unaffected. But a project running SINGLE-TENANT (Better-Auth organization plugin off) has no `activeOrganizationId` on any session, so EVERY authenticated request falls into the empty-ability branch and a `@Can()` gate denies every real session. `@Can()` becomes structurally unusable and product routes are forced back onto `@Public()` + ad-hoc service-level gates (fail-open) — the exact opposite of what the permission layer is for. This adds an OPTIONAL, additive extension point so a project can close that "single-tenant gap" without forking core: - new `authenticated-ability-fallback.ts`: an `@Optional()` DI token `AUTHENTICATED_ABILITY_FALLBACK` plus its contract types. - `ability.middleware.ts`: at the two sites that would install an empty ability for an AUTHENTICATED user (tenant-exempt path, and no-tenant- resolved), consult the fallback via a new `installEmptyOrFallbackAbility()` helper. Safety properties (by construction): - The hook can only UPGRADE an empty ability, never widen one the framework already resolved — it is only called at sites that were about to install `buildAbility([])`. - It is NEVER consulted for anonymous requests (no `req.user`), for requests that already carry an ability (`X-Test-Ability` / a resolved tenant), or when the tenant resolver raised a security signal (bad tenant header). - A throwing fallback degrades to the empty-ability default; it can never fail the request. - Returning `null` keeps the framework default, so a provider can opt out per-request (e.g. act only in single-tenant mode, leaving multi-tenant behaviour byte-identical). When no project registers the token the injection is `undefined` and the middleware behaviour is byte-identical to before — a zero-cost, backward- compatible extension point. All existing core tests (incl. the cross-tenant write-breach regression) are unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
Problem
AbilityMiddlewareinstalls an empty CASL ability (buildAbility([])) whenever anauthenticated request cannot be resolved to a tenant — i.e. no
session.activeOrganizationId, and the path is not a hub-operator path. For amulti-tenant deployment this is the correct default:
@Can()-gated route then denies viaCanGuard(empty ability grants nothing), and@Can()routes (e.g. the/me/*family that scopes byreq.user.id) are unaffected.But a project that runs single-tenant (Better-Auth organization plugin off) has no
activeOrganizationIdon any session. Every authenticated request therefore falls intothe empty-ability branch, and any
@Can()gate denies every real session.@Can()becomes structurally unusable, and product routes are pushed back onto
@Public()+ad-hoc service-level checks — a fail-open posture, the opposite of what the permission
layer exists to provide. In our downstream project this was logged as the "single-tenant
gap": we could not adopt
@Can()on any product route until we could inject an abilityfor membership-less sessions.
There is currently no supported extension point to influence the ability at the exact
moment the middleware decides to install an empty one, short of forking
src/core/.Design
Add an optional injection token that the middleware consults only at the two sites
where it was already about to install an empty ability for an authenticated user.
New file —
src/core/permissions/authenticated-ability-fallback.tsAUTHENTICATED_ABILITY_FALLBACK— aSymbol.for("lt:authenticated-ability-fallback")DI token.AuthenticatedAbilityFallback— the provider contract:resolve(input) => Promise<Ability | null>.AuthenticatedAbilityFallbackInput/...User— the input shape (user.id, optionalscopes, optionalactiveOrganizationId, optional resolvedpath).Patched —
src/core/permissions/ability.middleware.ts@Optional() @Inject(AUTHENTICATED_ABILITY_FALLBACK) private readonly abilityFallback?: AuthenticatedAbilityFallback.req.ability = buildAbility([])assignments for an authenticated user(the tenant-exempt branch and the no-tenant-resolved branch) are replaced by a single
new private helper
installEmptyOrFallbackAbility(req).Safety properties (by construction)
buildAbility([]). It can replace an empty ability but can never widen an abilitythe framework already resolved for a real tenant.
req.user),not for requests that already carry an ability (
X-Test-Ability/ a resolved tenant),and not when the tenant resolver raised a security signal (bad/forbidden tenant header —
that path installs empty ability directly and returns before reaching the helper).
default; the hook can never fail a request.
nullkeeps the framework default, so a provider canact only in single-tenant mode and leave multi-tenant behaviour byte-identical.
Proof: byte-identical when no provider is registered
@Optional(). With no provider bound,this.abilityFallbackisundefined.installEmptyOrFallbackAbilityopens withif (user && this.abilityFallback). WithabilityFallback === undefinedthat guard is false, so control falls straight throughto
req.ability = buildAbility([]).So for any consumer that does not register the token, the resulting
req.abilityisidentical at both sites (an empty ability). The only mechanical difference is that the two
sites now
awaita promise that resolves immediately — no observable behavioural change.The existing core test-suite (including the cross-tenant write-breach regression) exercises
exactly the no-provider path and stays green.
Usage (example provider registration)
A downstream project registers a global provider bound to the token. Example — resolve the
caller's first membership to a tenant and return their role ability, else a fixed baseline,
and opt out entirely in multi-tenant mode:
No core wiring change is needed on the consumer side beyond importing that module — the
middleware picks the provider up through the optional token.
Tests (proposed additions upstream)
Suggested new story test
tests/stories/authenticated-ability-fallback.story.test.ts.It drives the deterministic tenant-exempt branch (e.g.
/me) so the helper is reachedwithout a live tenant resolver, and asserts the contract. (The "not consulted when a real
tenant resolves" property is already covered by the existing multi-tenant regression
suite, which registers no provider and stays green.)
Adjust the fake shapes / subject strings to the repo's existing test conventions (see
tests/stories/pino-logger-service.story.test.tsfor the light-fake style andsrc/core/permissions/test-ability.tsfor ability construction helpers).Checklist for the reviewer
lt:authenticated-ability-fallback) fits the core namespace convention.src/core/permissions/).src/modules/-specific symbol leaked into the patch (it does not — thepatch is limited to the two
src/core/permissions/files; the project provider andits
@Globalmodule registration stay downstream).🤖 Generated with Claude Code