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>
179 lines
6.4 KiB
TypeScript
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;
|
|
}
|