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
14 changes: 14 additions & 0 deletions docs/openapi.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,20 @@
]
}
},
"/hub/operator-tenant.json": {
"get": {
"operationId": "Hub_operatorTenantJson",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Hub"
]
}
},
"/hub/dashboard.json": {
"get": {
"operationId": "Hub_dashboardJson",
Expand Down
10 changes: 10 additions & 0 deletions src/core/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { DeviceModule } from "../devices/device.module.js";
import { HubSpaModule } from "../dx/hub-spa.module.js";
import { UserAdminModule } from "../dx/user-admin.module.js";
import { HubModule } from "../hub/hub.module.js";
import { HubOperatorTenantInterceptor } from "../hub/hub-operator-tenant.interceptor.js";
import { HubPortalMiddleware } from "../hub/hub-portal.middleware.js";
import { EmailModule } from "../email/email.module.js";
import { EmailOutboxModule } from "../email/email-outbox.module.js";
Expand Down Expand Up @@ -249,6 +250,15 @@ const features = loadFeatures(process.env as Record<string, string | undefined>)
...(features.multiTenancy.enabled
? [{ provide: APP_INTERCEPTOR, useClass: TenantInterceptor }]
: []),
// Single-tenant deployments never mount `TenantInterceptor` above, so
// core Hub/admin routes (`requireTenantContext()`) would have no tenant
// in the ALS → 400. This Hub-scoped interceptor resolves the operator's
// OWN membership tenant for `/hub/*` + `/admin/*` only; all other paths
// (the product `/api/*` surface, which pins its own tenant) pass through
// untouched. Mutually exclusive with `TenantInterceptor` by the flag.
...(!features.multiTenancy.enabled
? [{ provide: APP_INTERCEPTOR, useClass: HubOperatorTenantInterceptor }]
: []),
],
})
export class AppModule implements NestModule {
Expand Down
37 changes: 33 additions & 4 deletions src/core/dx/clients/lib/hub-session-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,31 @@ export function pickDefaultOrganizationId(
}

/**
* After Hub sign-in, activate a default Better-Auth organization via set-active.
* Tenant scope for hub/admin JSON comes from the session cookie only.
* After Hub sign-in, resolve the tenant the operator's admin pages scope to.
*
* MULTI-TENANT: `/api/auth/organization/list` returns the operator's orgs; we
* pick a default, `set-active` it (tenant scope then comes from the session
* cookie), and return its id. This path is unchanged.
*
* SINGLE-TENANT: the org plugin is off, so `organization/list` 404s (or
* returns an empty list). There is no `activeOrganizationId` to seed the
* tenant-gated admin pages, which would otherwise keep their query disabled
* and hang in "Loading …". We fall back to `/hub/operator-tenant.json`, which
* resolves the operator's OWN membership tenant server-side, and return that
* id so the pages render exactly as in multi-tenant mode.
*/
export async function bootstrapHubOperatorSession(): Promise<string | undefined> {
const listRes = await fetch("/api/auth/organization/list", {
credentials: "include",
headers: { accept: "application/json" },
});
if (!listRes.ok) return undefined;
// 404 → single-tenant (org plugin off): resolve the tenant server-side.
if (!listRes.ok) return resolveSingleTenantOperatorTenantId();

const orgs = (await listRes.json()) as HubOrganizationSummary[];
const organizationId = pickDefaultOrganizationId(orgs);
if (!organizationId) return undefined;
// Empty list → still single-tenant-shaped: same server-side fallback.
if (!organizationId) return resolveSingleTenantOperatorTenantId();

await fetch("/api/auth/organization/set-active", {
method: "POST",
Expand All @@ -45,6 +57,23 @@ export async function bootstrapHubOperatorSession(): Promise<string | undefined>
return organizationId;
}

/**
* Single-tenant tenant resolution: ask the server which tenant the operator's
* membership maps to. No `set-active` here — the org plugin does not exist in
* single-tenant mode; the `HubOperatorTenantInterceptor` stamps the tenant per
* request from the same membership resolver. Returns `undefined` when the
* probe fails or the operator has no membership (server sends `tenantId: null`).
*/
async function resolveSingleTenantOperatorTenantId(): Promise<string | undefined> {
const res = await fetch("/hub/operator-tenant.json", {
credentials: "include",
headers: { accept: "application/json" },
});
if (!res.ok) return undefined;
const body = (await res.json()) as { tenantId: string | null };
return body.tenantId ?? undefined;
}

/** Switch the Better-Auth session to a specific organization (hub/admin JSON). */
export async function activateHubOrganization(organizationId: string): Promise<boolean> {
const res = await fetch("/api/auth/organization/set-active", {
Expand Down
36 changes: 36 additions & 0 deletions src/core/dx/hub.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ import { ApiTags } from "@nestjs/swagger";

import type { Ability } from "../permissions/casl-ability.js";
import { buildHubPortalAccessSnapshot } from "../hub/hub-portal-access.js";
import { resolveHubOperatorTenantId, type HubOperatorUser } from "../hub/hub-operator-tenant.js";
import { buildHubNavFeatureSnapshot, filterPalettePagesForNavSnapshot } from "./hub-nav-planner.js";
import { Public } from "../permissions/public.decorator.js";
import { assertFeatureEnabledFromEnv } from "../features/assert-feature-enabled.js";
Expand Down Expand Up @@ -229,6 +230,41 @@ export class HubController {
return buildHubPortalAccessSnapshot(req.ability, buildHubNavFeatureSnapshot(features));
}

/**
* `/hub/operator-tenant.json` — the tenant the signed-in operator's Hub
* pages should scope to. Single-tenant deployments run the org plugin off,
* so `/api/auth/organization/list` 404s and the dev-portal SPA has no
* `activeOrganizationId` to seed its tenant-gated admin pages (roles,
* policies, tenants). This probe lets the client learn its server-resolved
* tenant: the operator's OWN membership org via `resolveHubOperatorTenantId`
* (same resolver the single-tenant `HubOperatorTenantInterceptor` uses).
*
* `@Public` + `assertDev()` mirror `portalAccessJson`: it must be reachable
* WITHOUT a tenant context (it is what RESOLVES the tenant). The
* `HubOperatorTenantInterceptor` passes tenant-less hub requests through, so
* no interceptor 400 blocks this. Returns `{ tenantId: null }` for an
* unauthenticated caller or an operator with no membership — no blind
* `SINGLE_TENANT_ID` fallback (isolation: only the caller's own membership).
*
* Upstream candidate (hub.controller.ts — see docs/upstream-drafts/README.md
* "Hub tenant-scoped admin routes (roles)"): pairs with the interceptor +
* client bootstrap fix so the core Hub admin console works in single-tenant
* deployments end-to-end.
*/
@Get("operator-tenant.json")
@Public(
"client resolves its single-tenant hub scope — reachable before any tenant context exists",
)
async operatorTenantJson(
@Req() req: { user?: HubOperatorUser },
): Promise<{ tenantId: string | null }> {
this.assertDev();
if (!req.user) {
return { tenantId: null };
}
return { tenantId: await resolveHubOperatorTenantId(req.user, this.prisma) };
}

@Get("dashboard.json")
async dashboardJson(): Promise<unknown> {
this.assertDev();
Expand Down
143 changes: 143 additions & 0 deletions src/core/hub/hub-operator-tenant.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {
Injectable,
Optional,
type CallHandler,
type ExecutionContext,
type NestInterceptor,
} from "@nestjs/common";
import type { Request } from "express";
import { Observable, defer, from, switchMap } from "rxjs";

import { runWithTenant } from "../multi-tenancy/tenant-context.js";
import { isTenantExempt } from "../multi-tenancy/tenant-guard.js";
import { PrismaService } from "../prisma/prisma.service.js";
import { resolveHubOperatorTenantId } from "./hub-operator-tenant.js";
import { isHubPortalProtectedPath } from "./hub-portal-paths.js";

/**
* Hub-scoped tenant interceptor for SINGLE-TENANT deployments.
*
* Upstream candidate (hub-operator-tenant.interceptor.ts — see
* docs/upstream-drafts/README.md "Hub tenant-scoped admin routes (roles)"):
* the core `TenantInterceptor` is gated on `features.multiTenancy.enabled`
* (app.module.ts:255), so in a single-tenant deployment
* (`FEATURE_MULTI_TENANCY_ENABLED=false`) it is never mounted. The core
* Hub/admin routes still call `requireTenantContext()`, so with no
* interceptor in the chain they have no tenant in the ALS and throw 400
* "tenant context is required" — the Roles page hangs in "Loading roles…".
*
* This interceptor fills exactly that gap and NOTHING else:
*
* - Only the Hub operator console (`isHubPortalProtectedPath`) needs a
* tenant in single-tenant mode. Those requests resolve the operator's
* OWN membership tenant via `resolveHubOperatorTenantId` and run the
* handler inside `runWithTenant()` so the Prisma RLS extension stamps
* `SET app.tenant_id` and `requireTenantContext()` reads a value.
* - EVERY other path is pure pass-through. The product `/api/*` surface
* pins its own tenant (session `activeOrganizationId` / SINGLE_TENANT_ID
* resolvers) and MUST keep running exactly as today — this interceptor
* never makes a non-Hub path tenant-required.
*
* When the tenant is NOT resolvable (no `req.user`, no Prisma, or the
* operator has no membership) the interceptor PASSES THROUGH instead of
* throwing. `isHubPortalProtectedPath` also matches tenant-OPTIONAL hub
* probes — notably the `@Public` `/hub/portal-access.json` access probe,
* which decides hub access and MUST answer without a tenant (a
* membership-less operator gets a "no access" snapshot, not a 400).
* Throwing here would break those probes. Tenant-REQUIRED handlers
* (`GET /admin/roles`, …) call `requireTenantContext()` themselves, so
* with no tenant in the ALS they still throw their own 400 — the
* "membership-less → 400 on /admin/roles" behaviour is preserved, just
* enforced by the handler rather than the interceptor.
*
* Isolation invariant: the fallback resolves ONLY the caller's own
* membership — there is no blind `SINGLE_TENANT_ID` fallback, so no
* foreign-tenant leak (an authenticated user with no membership never
* inherits another tenant's context). This interceptor and the core
* `TenantInterceptor` are mutually exclusive: they are wired on opposite
* sides of the `multiTenancy` flag, so never both active.
*
* `runWithTenant` is imported from `tenant-context.ts` (never from
* `tenant.interceptor.ts`) to avoid the `PrismaService`-before-init cycle
* documented there.
*/

interface HubAuthenticatedRequest extends Request {
user?: {
id: string;
activeOrganizationId?: string | null;
};
}

@Injectable()
export class HubOperatorTenantInterceptor implements NestInterceptor {
/**
* `@Optional()` mirrors `TenantInterceptor`: synthetic test modules may
* wire the interceptor without importing `PrismaModule`. Production wiring
* imports `PrismaModule` (it is `@Global`), so the real path always has a
* Prisma instance. Without Prisma the membership cannot be resolved, so
* the request passes through WITHOUT a tenant — tenant-required handlers
* then throw their own `requireTenantContext()` 400.
*/
constructor(@Optional() private readonly prisma?: PrismaService) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
if (context.getType() !== "http") {
return next.handle();
}
const req = context.switchToHttp().getRequest<HubAuthenticatedRequest>();
const path = (req.originalUrl ?? req.url ?? "/") as string;

// Public/system paths (/, /health/*, /api/auth/*) — never tenant-scoped.
if (isTenantExempt(path)) {
return next.handle();
}

const purePath = stripQuery(path);
// Non-Hub paths (the whole product `/api/*` surface) pin their own tenant
// and must pass through untouched in single-tenant mode.
if (!isHubPortalProtectedPath(purePath)) {
return next.handle();
}

return defer(async () => {
const tenantId =
req.user && this.prisma ? await resolveHubOperatorTenantId(req.user, this.prisma) : null;
if (!tenantId) {
// Tenant not resolvable (no session / no Prisma / no membership).
// Pass through WITHOUT a tenant instead of throwing — no blind
// SINGLE_TENANT_ID fallback (that would leak a foreign tenant).
// Tenant-OPTIONAL hub probes (the @Public /hub/portal-access.json
// access probe) must answer without a tenant; tenant-REQUIRED
// handlers (/admin/roles, …) throw their own requireTenantContext()
// 400, so the membership-less → 400 contract is preserved there.
return streamToPromise(next.handle());
}
return runWithTenant(tenantId, () => streamToPromise(next.handle()));
}).pipe(switchMap((value) => from(unwrap(value))));
}
}

function streamToPromise(observable: Observable<unknown>): Promise<unknown> {
return new Promise((resolveResult, rejectResult) => {
let last: unknown;
observable.subscribe({
next: (v) => {
last = v;
},
error: rejectResult,
complete: () => resolveResult(last),
});
});
}

function unwrap(value: unknown): Promise<unknown> {
return Promise.resolve(value);
}

function stripQuery(path: string): string {
const queryAt = path.indexOf("?");
const hashAt = path.indexOf("#");
const cut = Math.min(...[queryAt, hashAt].filter((i) => i >= 0), path.length);
return path.slice(0, cut);
}
Loading
Loading