Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 51 additions & 3 deletions src/core/permissions/ability.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
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";
import { isHubPortalProtectedPath, isHubPortalStaticAsset } from "../hub/hub-portal-paths.js";
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";

Expand Down Expand Up @@ -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<void> {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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<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([]);
}
}

/** `/hub/*` JSON/HTML uses `@Can(Hub)` with hub-operator tenant fallback. */
Expand Down
70 changes: 70 additions & 0 deletions src/core/permissions/authenticated-ability-fallback.ts
Original file line number Diff line number Diff line change
@@ -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<Ability | null>;
}

/** DI token for the optional `AuthenticatedAbilityFallback` provider. */
export const AUTHENTICATED_ABILITY_FALLBACK = Symbol.for("lt:authenticated-ability-fallback");
Loading