Skip to content

feat(permissions): optional AUTHENTICATED_ABILITY_FALLBACK hook for AbilityMiddleware#174

Merged
pascal-klesse merged 1 commit into
mainfrom
feat/authenticated-ability-fallback
Jul 13, 2026
Merged

feat(permissions): optional AUTHENTICATED_ABILITY_FALLBACK hook for AbilityMiddleware#174
pascal-klesse merged 1 commit into
mainfrom
feat/authenticated-ability-fallback

Conversation

@pascal-klesse

Copy link
Copy Markdown
Member

Validated on main @ e600198 (current HEAD): patch applies via git am, bun run test:types exits 0 after prisma generate, the permissions story suite is green, and the only failing story (email-brand-bridge) fails identically on unpatched main (pre-existing, unrelated).
Motivation and a battle-tested downstream provider exist in the BYND project, where this hook unlocked the full @Public()->@Can() migration in single-tenant mode.

Problem

AbilityMiddleware installs an empty CASL ability (buildAbility([])) whenever an
authenticated request cannot be resolved to a tenant — i.e. no
session.activeOrganizationId, and the path is not a hub-operator path. For a
multi-tenant deployment this is the correct default:

  • a @Can()-gated route then denies via CanGuard (empty ability grants nothing), and
  • non-@Can() routes (e.g. the /me/* family that scopes by req.user.id) are unaffected.

But a project that runs single-tenant (Better-Auth organization plugin off) has no
activeOrganizationId on any session
. Every authenticated request therefore falls into
the 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 ability
for 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.ts

  • AUTHENTICATED_ABILITY_FALLBACK — a Symbol.for("lt:authenticated-ability-fallback") DI token.
  • AuthenticatedAbilityFallback — the provider contract: resolve(input) => Promise<Ability | null>.
  • AuthenticatedAbilityFallbackInput / ...User — the input shape (user.id, optional
    scopes, optional activeOrganizationId, optional resolved path).

Patched — src/core/permissions/ability.middleware.ts

  • Constructor gains @Optional() @Inject(AUTHENTICATED_ABILITY_FALLBACK) private readonly abilityFallback?: AuthenticatedAbilityFallback.
  • The two inline 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).
private async installEmptyOrFallbackAbility(req: AuthenticatedRequest): Promise<void> {
  const user = req.user;
  if (user && this.abilityFallback) {
    const path = (req.originalUrl ?? req.url) as string | undefined;
    try {
      const fallbackAbility = await this.abilityFallback.resolve({
        user,
        ...(path !== undefined ? { path } : {}),
      });
      if (fallbackAbility) {
        req.ability = fallbackAbility;
        return;
      }
    } catch {
      // fall through to the empty-ability default
    }
  }
  req.ability = buildAbility([]);
}

Safety properties (by construction)

  • Upgrade-only. The hook is called only at sites that were about to install
    buildAbility([]). It can replace an empty ability but can never widen an ability
    the framework already resolved for a real tenant.
  • Never consulted where it must not be. Not for anonymous requests (no 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).
  • Fail-safe. A throwing provider is swallowed and degrades to the empty-ability
    default; the hook can never fail a request.
  • Opt-out per request. Returning null keeps the framework default, so a provider can
    act only in single-tenant mode and leave multi-tenant behaviour byte-identical.

Proof: byte-identical when no provider is registered

  1. The injection is @Optional(). With no provider bound, this.abilityFallback is
    undefined.
  2. installEmptyOrFallbackAbility opens with if (user && this.abilityFallback). With
    abilityFallback === undefined that guard is false, so control falls straight through
    to req.ability = buildAbility([]).
  3. That is exactly what the two call sites did inline before this change.

So for any consumer that does not register the token, the resulting req.ability is
identical at both sites (an empty ability). The only mechanical difference is that the two
sites now await a 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:

// project-side: src/modules/<x>/authenticated-ability-fallback.provider.ts
import { Injectable } from "@nestjs/common";
import {
  type AuthenticatedAbilityFallback,
  type AuthenticatedAbilityFallbackInput,
} from "../../core/permissions/authenticated-ability-fallback.js";
import { type Ability, buildAbility } from "../../core/permissions/casl-ability.js";
import { PermissionService } from "../../core/permissions/permission.service.js";
import { MembershipService } from "./membership.service.js";

@Injectable()
export class ProjectAbilityFallback implements AuthenticatedAbilityFallback {
  constructor(
    private readonly permissions: PermissionService,
    private readonly memberships: MembershipService,
  ) {}

  async resolve(input: AuthenticatedAbilityFallbackInput): Promise<Ability | null> {
    // Only act in single-tenant mode; leave multi-tenant behaviour untouched.
    if (process.env.MULTI_TENANCY === "true") return null;

    const tenantId = await this.memberships.firstTenantIdFor(input.user.id);
    if (tenantId) {
      // Staff with a membership → their real role ability.
      return this.permissions.abilityFor(input.user.id, tenantId, {
        scopes: input.user.scopes,
      });
    }

    // Membership-less app session → a fixed read-only baseline on public content.
    return buildAbility([{ action: "read", subject: "PublishedContent" }]);
  }
}
// project-side module — @Global so the core AbilityMiddleware can inject it
import { Global, Module } from "@nestjs/common";
import { AUTHENTICATED_ABILITY_FALLBACK } from "../../core/permissions/authenticated-ability-fallback.js";
import { ProjectAbilityFallback } from "./authenticated-ability-fallback.provider.js";

@Global()
@Module({
  providers: [
    ProjectAbilityFallback,
    { provide: AUTHENTICATED_ABILITY_FALLBACK, useExisting: ProjectAbilityFallback },
  ],
  exports: [AUTHENTICATED_ABILITY_FALLBACK],
})
export class ProjectAccessModule {}

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 reached
without 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.)

import { describe, expect, it } from "vitest";

import {
  AUTHENTICATED_ABILITY_FALLBACK,
  type AuthenticatedAbilityFallback,
} from "../../src/core/permissions/authenticated-ability-fallback.js";
import { AbilityMiddleware } from "../../src/core/permissions/ability.middleware.js";
import { buildAbility } from "../../src/core/permissions/casl-ability.js";
import type { PermissionService } from "../../src/core/permissions/permission.service.js";
import type { PrismaService } from "../../src/core/prisma/prisma.service.js";

// abilityFor is only reached when a tenant resolves; the fallback sites never call it.
const permissions = { abilityFor: async () => buildAbility([]) } as unknown as PermissionService;
const prisma = {} as PrismaService;

function reqFor(user?: { id: string }) {
  // `/me` is tenant-exempt → the middleware routes to installEmptyOrFallbackAbility.
  return { user, originalUrl: "/me", url: "/me" } as any;
}
const next = () => {};

describe("Story · AUTHENTICATED_ABILITY_FALLBACK", () => {
  it("no provider registered → empty ability (byte-identical default)", async () => {
    const mw = new AbilityMiddleware(permissions, prisma /* no fallback */);
    const req = reqFor({ id: "u1" });
    await mw.use(req, {} as any, next);
    expect(req.ability).toBeDefined();
    expect(req.ability!.can("read", "AnySubject")).toBe(false);
  });

  it("provider returns an ability → it replaces the empty default", async () => {
    const granted = buildAbility([{ action: "read", subject: "PublishedContent" }]);
    const fallback: AuthenticatedAbilityFallback = { resolve: async () => granted };
    const mw = new AbilityMiddleware(permissions, prisma, fallback);
    const req = reqFor({ id: "u1" });
    await mw.use(req, {} as any, next);
    expect(req.ability!.can("read", "PublishedContent")).toBe(true);
  });

  it("provider returns null → empty ability (per-request opt-out)", async () => {
    const fallback: AuthenticatedAbilityFallback = { resolve: async () => null };
    const mw = new AbilityMiddleware(permissions, prisma, fallback);
    const req = reqFor({ id: "u1" });
    await mw.use(req, {} as any, next);
    expect(req.ability!.can("read", "PublishedContent")).toBe(false);
  });

  it("anonymous request → provider is never consulted", async () => {
    let called = false;
    const fallback: AuthenticatedAbilityFallback = {
      resolve: async () => {
        called = true;
        return buildAbility([{ action: "manage", subject: "all" }]);
      },
    };
    const mw = new AbilityMiddleware(permissions, prisma, fallback);
    const req = reqFor(undefined); // no req.user
    await mw.use(req, {} as any, next);
    expect(called).toBe(false);
    expect(req.ability!.can("read", "AnySubject")).toBe(false);
  });

  it("throwing provider → degrades to empty ability, request still passes", async () => {
    const fallback: AuthenticatedAbilityFallback = {
      resolve: async () => {
        throw new Error("provider boom");
      },
    };
    const mw = new AbilityMiddleware(permissions, prisma, fallback);
    const req = reqFor({ id: "u1" });
    let passed = false;
    await mw.use(req, {} as any, () => {
      passed = true;
    });
    expect(passed).toBe(true);
    expect(req.ability!.can("read", "AnySubject")).toBe(false);
  });
});

Adjust the fake shapes / subject strings to the repo's existing test conventions (see
tests/stories/pino-logger-service.story.test.ts for the light-fake style and
src/core/permissions/test-ability.ts for ability construction helpers).

Checklist for the reviewer

  • Token naming (lt:authenticated-ability-fallback) fits the core namespace convention.
  • Contract types live in the right place (src/core/permissions/).
  • The proposed story test is added (or reshaped to match the existing harness).
  • Confirm no src/modules/-specific symbol leaked into the patch (it does not — the
    patch is limited to the two src/core/permissions/ files; the project provider and
    its @Global module registration stay downstream).

🤖 Generated with Claude Code

…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>
@pascal-klesse
pascal-klesse merged commit 2586304 into main Jul 13, 2026
13 checks passed
@pascal-klesse
pascal-klesse deleted the feat/authenticated-ability-fallback branch July 13, 2026 09:29
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.

1 participant