learning_ai_common_plat/services/platform-service/src/lib/request-context.ts
saravanakumardb1 42c63dcc6e feat(platform): product ownership + owner-scoped "my projects" + tenant guard
Foundation for a generic, multi-tenant platform (any developer, not just the
built-in products).

- Products carry an optional ownerId (set on create + auto-register), so a
  product has a tenant. GET /products/mine returns the caller's owner-scoped
  list; admins/super_admins see all. productsForUser() is pure + unit-tested.
- requireProductAccess(): a flag-gated tenant authorization guard
  (FLEET_TENANT_ENFORCEMENT, default OFF). OFF = byte-for-byte current behavior;
  ON = a non-admin may only act on products they own (others -> 403; owner-less
  legacy products keep a grace allowance until migrated). Fleet routes now
  resolve productId through it in place of getRequestProductId.

ownerId is additive/optional; enforcement is off by default, so this is a
no-op for existing deployments until explicitly enabled.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-01 16:47:05 -07:00

179 lines
6.4 KiB
TypeScript

/**
* Request-level product context helpers.
*
* Extract and validate productId from every request:
* 1. JWT token payload (authenticated requests)
* 2. X-Product-Id header (unauthenticated requests)
* 3. PRODUCT_ID env var fallback (backward compat during migration)
*
* Validates against the products registry — rejects unknown or disabled products.
*/
import type { FastifyRequest } from 'fastify';
import { BadRequestError, ForbiddenError } from './errors.js';
import { isValidProduct, getProduct } from '../modules/products/cache.js';
import type { ProductDoc } from '../modules/products/types.js';
import { autoRegisterProduct } from './auto-register.js';
import { extractAuth } from './auth.js';
/** JWT payload shape attached to req by the onRequest hook (see Commit 3). */
export interface JwtPayload {
sub: string;
email?: string;
role?: string;
productId?: string;
type?: string;
}
// Augment Fastify request to include parsed JWT payload
declare module 'fastify' {
interface FastifyRequest {
jwtPayload?: JwtPayload;
}
}
/**
* Extract raw productId from request without status validation.
* Priority: JWT token > X-Product-Id header > env fallback (dev only).
* Validates the product exists in the registry but does NOT check status.
*/
export async function extractProductIdAsync(req: FastifyRequest): Promise<string> {
// 1. From JWT (set during login/register, attached by onRequest hook)
let id = req.jwtPayload?.productId;
// 2. From header (unauthenticated requests like license activation)
if (!id) {
const header = req.headers['x-product-id'];
if (typeof header === 'string' && header.length > 0) id = header;
}
// 3. Fallback to env var (backward compat during migration, dev only)
if (!id) {
const envFallback = process.env.PRODUCT_ID;
if (envFallback) id = envFallback;
}
if (!id) throw new BadRequestError('productId is required (via JWT or X-Product-Id header)');
// Auto-register unknown products when requester has a valid JWT
if (!isValidProduct(id) && req.jwtPayload?.sub) {
const result = await autoRegisterProduct(id, req.jwtPayload.sub);
if (
!result.registered &&
result.reason !== 'Already exists' &&
result.reason !== 'Concurrent registration'
) {
throw new BadRequestError(
`Unknown product: ${id} (auto-registration failed: ${result.reason})`
);
}
} else if (!isValidProduct(id)) {
throw new BadRequestError(`Unknown product: ${id}`);
}
return id;
}
/** Synchronous wrapper kept for backward compatibility on hot paths. */
function extractProductId(req: FastifyRequest): string {
// 1. From JWT (set during login/register, attached by onRequest hook)
let id = req.jwtPayload?.productId;
// 2. From header (unauthenticated requests like license activation)
if (!id) {
const header = req.headers['x-product-id'];
if (typeof header === 'string' && header.length > 0) id = header;
}
// 3. Fallback to env var (backward compat during migration, dev only)
if (!id) {
const envFallback = process.env.PRODUCT_ID;
if (envFallback) id = envFallback;
}
if (!id) throw new BadRequestError('productId is required (via JWT or X-Product-Id header)');
if (!isValidProduct(id)) throw new BadRequestError(`Unknown product: ${id}`);
return id;
}
/**
* Extract and validate productId from the request.
* Blocks: `draft`, `sunset`, `disabled` (only `pre_launch`, `beta`, `active` pass).
*/
export function getRequestProductId(req: FastifyRequest): string {
const id = extractProductId(req);
const product = getProduct(id)!;
const blockedStatuses = ['draft', 'sunset', 'disabled'] as const;
if ((blockedStatuses as readonly string[]).includes(product.status)) {
throw new BadRequestError(`Product ${id} is not available (status: ${product.status})`);
}
return id;
}
/**
* Extract productId with relaxed status gating — permits `pre_launch`.
* Used by public waitlist routes where the product isn't fully operational yet.
* Blocks: `draft`, `sunset`, `disabled`.
*/
export function getRequestProductIdForPublic(req: FastifyRequest): string {
const id = extractProductId(req);
const product = getProduct(id)!;
if (product.status === 'draft' || product.status === 'sunset' || product.status === 'disabled') {
throw new BadRequestError(`Product ${id} is not available (status: ${product.status})`);
}
return id;
}
/**
* Get the full product config for the current request's productId.
* Useful when modules need product-specific values (trial days, device limits, etc.).
*/
export function getRequestProductConfig(req: FastifyRequest): ProductDoc {
const id = getRequestProductId(req);
return getProduct(id)!;
}
/**
* `FLEET_TENANT_ENFORCEMENT` env gate — default OFF. When OFF, tenant scoping is
* advisory only (the dashboard shows you your projects, but the API does not
* reject cross-tenant access) — byte-for-byte the current behavior. When ON, the
* API enforces that a caller may only act on products they own (admins exempt).
*/
export function isTenantEnforcementEnabled(): boolean {
const v = (process.env.FLEET_TENANT_ENFORCEMENT ?? '').trim().toLowerCase();
return v === '1' || v === 'true' || v === 'on' || v === 'yes';
}
/**
* Resolve the request's productId AND authorize the caller for it (multi-tenant
* guard, §tenancy). Resolution + status gating is unchanged (`getRequestProductId`);
* the access check is additive and flag-gated:
*
* - enforcement OFF ⇒ returns the resolved id (no behavior change).
* - admins / super_admins ⇒ always allowed (operators see everything).
* - otherwise ⇒ allowed only when the product is owned by the caller, OR is
* owner-less/legacy (grace for products created before ownership tracking;
* migrate them to lock down fully). A product owned by SOMEONE ELSE ⇒ 403.
*
* Async because it needs the verified auth identity. Use this on tenant-scoped
* surfaces (e.g. the fleet control plane) in place of `getRequestProductId`.
*/
export async function requireProductAccess(req: FastifyRequest): Promise<string> {
const id = getRequestProductId(req);
if (!isTenantEnforcementEnabled()) return id;
const auth = await extractAuth(req);
if (auth.role === 'admin' || auth.role === 'super_admin') return id;
const product = getProduct(id);
if (product?.ownerId && product.ownerId !== auth.sub) {
throw new ForbiddenError(`Not authorized for product '${id}'`);
}
return id;
}