diff --git a/src/core/permissions/ability.middleware.ts b/src/core/permissions/ability.middleware.ts index afe3ceb0..425e31b0 100644 --- a/src/core/permissions/ability.middleware.ts +++ b/src/core/permissions/ability.middleware.ts @@ -1,4 +1,4 @@ -import { Injectable, type NestMiddleware } from "@nestjs/common"; +import { Inject, Injectable, type NestMiddleware, Optional } from "@nestjs/common"; import type { NextFunction, Request, Response } from "express"; import { resolveHubOperatorTenantId } from "../hub/hub-operator-tenant.js"; @@ -6,6 +6,10 @@ import { isHubPortalProtectedPath, isHubPortalStaticAsset } from "../hub/hub-por import { resolveRequestTenantId } from "../multi-tenancy/resolve-request-tenant.js"; import { isTenantExempt } from "../multi-tenancy/tenant-guard.js"; import { PrismaService } from "../prisma/prisma.service.js"; +import { + AUTHENTICATED_ABILITY_FALLBACK, + type AuthenticatedAbilityFallback, +} from "./authenticated-ability-fallback.js"; import { type Ability, buildAbility } from "./casl-ability.js"; import { PermissionService } from "./permission.service.js"; @@ -75,6 +79,16 @@ export class AbilityMiddleware implements NestMiddleware { constructor( private readonly permissions: PermissionService, private readonly prisma: PrismaService, + /** + * Optional project hook. When present, it decides the ability for an + * authenticated session the framework could not tenant-scope, in + * place of the empty-ability default (the "single-tenant gap" — see + * `authenticated-ability-fallback.ts`). Absent by default → behaviour + * is byte-identical to before. + */ + @Optional() + @Inject(AUTHENTICATED_ABILITY_FALLBACK) + private readonly abilityFallback?: AuthenticatedAbilityFallback, ) {} async use(req: AuthenticatedRequest, _res: Response, next: NextFunction): Promise { @@ -106,7 +120,10 @@ export class AbilityMiddleware implements NestMiddleware { resolveHubOperatorTenantId(req.user!, this.prisma), ); } else { - req.ability = buildAbility([]); + // Tenant-exempt but authenticated (`/me/*`, …): the framework + // default is an empty ability. A single-tenant project can + // upgrade this via the fallback hook (baseline for app sessions). + await this.installEmptyOrFallbackAbility(req); } next(); return; @@ -160,7 +177,10 @@ export class AbilityMiddleware implements NestMiddleware { return; } if (!tenantId) { - req.ability = buildAbility([]); + // No tenant resolvable for an authenticated user. Framework + // default is an empty ability; a project fallback may upgrade it + // (single-tenant staff → role ability, app session → baseline). + await this.installEmptyOrFallbackAbility(req); return; } try { @@ -171,6 +191,34 @@ export class AbilityMiddleware implements NestMiddleware { req.ability = buildAbility([]); } } + + /** + * Install the empty-ability default, or — when a project registered + * `AUTHENTICATED_ABILITY_FALLBACK` and the request is authenticated — + * the ability the fallback resolves. Only ever called at a site that + * would otherwise install an empty ability, so it can never widen an + * ability the framework already resolved. A throwing fallback degrades + * to the empty default (never fails the request). + */ + private async installEmptyOrFallbackAbility(req: AuthenticatedRequest): Promise { + 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([]); + } } /** `/hub/*` JSON/HTML uses `@Can(Hub)` with hub-operator tenant fallback. */ diff --git a/src/core/permissions/authenticated-ability-fallback.ts b/src/core/permissions/authenticated-ability-fallback.ts new file mode 100644 index 00000000..c03d824c --- /dev/null +++ b/src/core/permissions/authenticated-ability-fallback.ts @@ -0,0 +1,70 @@ +import type { Ability } from "./casl-ability.js"; + +/** + * Optional extension point for the `AbilityMiddleware`. + * + * ─── The problem this solves ──────────────────────────────────────── + * `AbilityMiddleware` installs an EMPTY ability whenever an + * authenticated request cannot be resolved to a tenant (no + * `session.activeOrganizationId`, not a hub-operator path). That is the + * correct default for the framework: a `@Can()`-gated route then denies + * via `CanGuard`, and non-`@Can()` routes are unaffected. + * + * But a project that runs SINGLE-TENANT (Better-Auth organization plugin + * off) has NO `activeOrganizationId` on any session — so EVERY + * authenticated request would fall into the empty-ability branch and a + * `@Can()` gate would deny every real session. The friction-log calls + * this the "single-tenant gap": product routes had to stay `@Public()` + * because `@Can()` was structurally impossible. + * + * ─── The hook ─────────────────────────────────────────────────────── + * When a project provides `AUTHENTICATED_ABILITY_FALLBACK`, the + * middleware consults it at exactly the moment it would otherwise + * install an empty ability for an AUTHENTICATED user. The provider + * decides the ability for that session — e.g. resolve the caller's + * first membership to a tenant and return their role ability, or return + * a fixed baseline for membership-less app sessions. + * + * Contract: + * - Returning an `Ability` replaces the empty ability for this + * request. + * - Returning `null` keeps the framework default (empty ability), so + * the provider can opt out per-request (e.g. only act in + * single-tenant mode, leaving multi-tenant behaviour byte-identical). + * - The hook 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 threw a security signal (bad tenant header). It only + * ever UPGRADES an empty ability — it can never widen an ability the + * framework already resolved. + * + * When no project registers the token the middleware behaves exactly as + * before (the injection is `@Optional()`), so this is a zero-cost, + * backwards-compatible extension point. + */ +export interface AuthenticatedAbilityFallbackUser { + /** Authenticated caller id (`req.user.id`). */ + id: string; + /** API-key scopes when the request authenticated via a scoped key. */ + scopes?: readonly string[]; + /** Active organization from the Better-Auth session, if any. */ + activeOrganizationId?: string | null; +} + +export interface AuthenticatedAbilityFallbackInput { + user: AuthenticatedAbilityFallbackUser; + /** Resolved request path (`req.originalUrl ?? req.url`), when known. */ + path?: string; +} + +export interface AuthenticatedAbilityFallback { + /** + * Resolve the ability for an authenticated session the framework + * could not tenant-scope. Return `null` to keep the empty-ability + * default. + */ + resolve(input: AuthenticatedAbilityFallbackInput): Promise; +} + +/** DI token for the optional `AuthenticatedAbilityFallback` provider. */ +export const AUTHENTICATED_ABILITY_FALLBACK = Symbol.for("lt:authenticated-ability-fallback");