import { Inject, Injectable } from '@nestjs/common'; import { companies, eq, estates, hierarchyGrants, inArray, or, platformProjects, workspaces, and, type Db, HIERARCHY_GRANT_ROLES, } from '@mosaicstack/db'; import { DB } from '../database/database.module.js'; /** * Hierarchy grant evaluation (contract 2 §3). * * Deny-by-default (§3.1): a user's effective role on a node is null unless a * grant row explicitly confers one. Grants apply down the chain only (§3.2): * the effective role on a node is the maximum role over grants targeting the * node itself or any of its ancestors, maximum per the total order * viewer ⊂ member ⊂ owner (§2). Evaluation is live and per-decision — no * caching — so revocation (row deletion, §6) denies the next decision * inherently. A missing node evaluates to null, indistinguishable from * no-grant, which keeps unauthorized probes oracle-safe (contract 1 §6.7). * * Team grant subjects are SUSPENDED (§1.4): the command surface refuses to * create them and this evaluator considers user-subject grants only, so a * team row could not confer access even if one existed. * * Read-only module: it selects from the class tables but never writes them, * so it does not appear on the writer-coverage allowlist. */ export type HierarchyGrantRole = (typeof HIERARCHY_GRANT_ROLES)[number]; /** Node kinds a grant may target (§3.2; workspace is evaluable, not grantable). */ export type GrantTargetKind = 'company' | 'estate' | 'platform_project'; /** Node kinds an authorization decision may be evaluated at (§3.2: down to workspace). */ export type EvaluableNodeKind = GrantTargetKind | 'workspace'; type Tx = Pick; /** Ancestor chain of a node, self included at its own level; ids only. */ export interface AncestorChain { readonly companyId: string; readonly estateId?: string; readonly platformProjectId?: string; readonly workspaceId?: string; } export function roleStrength(role: HierarchyGrantRole): number { return HIERARCHY_GRANT_ROLES.indexOf(role); } export function roleAtLeast( role: HierarchyGrantRole | null, required: HierarchyGrantRole, ): boolean { return role !== null && roleStrength(role) >= roleStrength(required); } /** * Serialized role strings are namespaced (§4.5): audit events and API * responses carry `hierarchy:owner`, never a bare `owner`. */ export function namespacedHierarchyRole(role: HierarchyGrantRole): string { return `hierarchy:${role}`; } /** * Resolve a node's ancestor chain (self included). Returns null when the * node does not exist — callers treat that exactly like no-grant (§3.1, * oracle-safe). */ export async function resolveAncestorChain( tx: Tx, kind: EvaluableNodeKind, id: string, ): Promise { if (kind === 'company') { const rows = await tx .select({ id: companies.id }) .from(companies) .where(eq(companies.id, id)) .limit(1); const row = rows[0]; return row ? { companyId: row.id } : null; } if (kind === 'estate') { const rows = await tx .select({ id: estates.id, companyId: estates.companyId }) .from(estates) .where(eq(estates.id, id)) .limit(1); const row = rows[0]; return row ? { companyId: row.companyId, estateId: row.id } : null; } if (kind === 'platform_project') { const rows = await tx .select({ id: platformProjects.id, estateId: platformProjects.estateId, companyId: estates.companyId, }) .from(platformProjects) .innerJoin(estates, eq(estates.id, platformProjects.estateId)) .where(eq(platformProjects.id, id)) .limit(1); const row = rows[0]; return row ? { companyId: row.companyId, estateId: row.estateId, platformProjectId: row.id } : null; } const rows = await tx .select({ id: workspaces.id, platformProjectId: workspaces.platformProjectId, estateId: platformProjects.estateId, companyId: estates.companyId, }) .from(workspaces) .innerJoin(platformProjects, eq(platformProjects.id, workspaces.platformProjectId)) .innerJoin(estates, eq(estates.id, platformProjects.estateId)) .where(eq(workspaces.id, id)) .limit(1); const row = rows[0]; return row ? { companyId: row.companyId, estateId: row.estateId, platformProjectId: row.platformProjectId, workspaceId: row.id, } : null; } function maxRole(roles: readonly string[]): HierarchyGrantRole | null { let best: HierarchyGrantRole | null = null; for (const candidate of roles) { // Fail-closed: a value outside the vocabulary confers nothing. if (!(HIERARCHY_GRANT_ROLES as readonly string[]).includes(candidate)) continue; const role = candidate as HierarchyGrantRole; if (best === null || roleStrength(role) > roleStrength(best)) best = role; } return best; } /** * Effective role of a user on a node: maximum over the user's grants whose * target is the node or any ancestor (§3.2); null = deny (§3.1). Missing * node → null. */ export async function evaluateEffectiveRole( tx: Tx, userId: string, kind: EvaluableNodeKind, id: string, ): Promise { const chain = await resolveAncestorChain(tx, kind, id); if (!chain) return null; const targetConditions = [eq(hierarchyGrants.companyId, chain.companyId)]; if (chain.estateId) targetConditions.push(eq(hierarchyGrants.estateId, chain.estateId)); if (chain.platformProjectId) { targetConditions.push(eq(hierarchyGrants.platformProjectId, chain.platformProjectId)); } const rows = await tx .select({ role: hierarchyGrants.role }) .from(hierarchyGrants) .where(and(eq(hierarchyGrants.userId, userId), or(...targetConditions))); return maxRole(rows.map((r) => r.role)); } /** * All companies on which the user holds any effective role, i.e. companies * with a grant on the company itself or on any descendant (contract 1 §2.8: * a grant anywhere in the subtree discloses the company's chain upward). */ export async function grantedCompanyIds(tx: Tx, userId: string): Promise { const grants = await tx .select({ companyId: hierarchyGrants.companyId, estateId: hierarchyGrants.estateId, platformProjectId: hierarchyGrants.platformProjectId, }) .from(hierarchyGrants) .where(eq(hierarchyGrants.userId, userId)); const companyIds = new Set(); const estateIds = new Set(); const platformProjectIds = new Set(); for (const grant of grants) { if (grant.companyId) companyIds.add(grant.companyId); else if (grant.estateId) estateIds.add(grant.estateId); else if (grant.platformProjectId) platformProjectIds.add(grant.platformProjectId); } if (platformProjectIds.size > 0) { const rows = await tx .select({ estateId: platformProjects.estateId }) .from(platformProjects) .where(inArray(platformProjects.id, [...platformProjectIds])); for (const row of rows) estateIds.add(row.estateId); } if (estateIds.size > 0) { const rows = await tx .select({ companyId: estates.companyId }) .from(estates) .where(inArray(estates.id, [...estateIds])); for (const row of rows) companyIds.add(row.companyId); } return [...companyIds]; } @Injectable() export class HierarchyGrantEvaluationService { constructor(@Inject(DB) private readonly db: Db) {} /** Live per-decision evaluation; pass a tx to evaluate inside a command's transaction. */ effectiveRole( userId: string, kind: EvaluableNodeKind, id: string, tx?: Tx, ): Promise { return evaluateEffectiveRole(tx ?? this.db, userId, kind, id); } async hasRole( userId: string, kind: EvaluableNodeKind, id: string, required: HierarchyGrantRole, tx?: Tx, ): Promise { return roleAtLeast(await this.effectiveRole(userId, kind, id, tx), required); } }