ci/woodpecker/pr/ci Pipeline was successful
Implements the ratified hierarchy command surface per contract 1 (hierarchy-schema.md) and contract 2 (rbac-grant-model.md), brief M4-1B-II: - HierarchyRepository: the closed command family (company/estate/ platform-project create/rename/transfer/delete, grant create/change/ revoke, directory + granted-companies reads). Every mutation runs in one transaction through the M4-1b-i audit machinery (event + outbox, idempotency-key replay, causation-linked composite operations). - HierarchyGrantEvaluationService: live deny-by-default evaluation — effective role is the max over ancestor-chain user grants, fail-closed, team subjects suspended (§1.4), platform admin confers no tenant access (§1.1). - companies.visibility column (private default, directory carve-out) with migration 0020, admin-only audited visibility_change (§5.5), closed-field directory listing (§2.8), no-existence-oracle refusals (§6.7). - hierarchy_grants role CHECK pinned to the ratified vocabulary; namespaced serialized roles (hierarchy:*, §4.5). - §1.1 bypass retirement: role-derived MCP scope elevation and hasScope admin shortcuts removed; specs updated to the granted-scope path. - Witnesses: schema-level (role CHECK, visibility class/default), §6.3 closed route inventory, §6.4 per-mutation-class commit+rollback legs, §6.5 authorization, §6.7 oracle indistinguishability, §6.9 visibility, grant-evaluation semantics (chain inheritance, max-role, live revocation).
243 lines
7.9 KiB
TypeScript
243 lines
7.9 KiB
TypeScript
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<Db, 'select'>;
|
|
|
|
/** 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<AncestorChain | null> {
|
|
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<HierarchyGrantRole | null> {
|
|
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<string[]> {
|
|
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<string>();
|
|
const estateIds = new Set<string>();
|
|
const platformProjectIds = new Set<string>();
|
|
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<HierarchyGrantRole | null> {
|
|
return evaluateEffectiveRole(tx ?? this.db, userId, kind, id);
|
|
}
|
|
|
|
async hasRole(
|
|
userId: string,
|
|
kind: EvaluableNodeKind,
|
|
id: string,
|
|
required: HierarchyGrantRole,
|
|
tx?: Tx,
|
|
): Promise<boolean> {
|
|
return roleAtLeast(await this.effectiveRole(userId, kind, id, tx), required);
|
|
}
|
|
}
|