import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"; import type { PoolClient } from "pg"; import { withTenant } from "../db/client.js"; import { scrub } from "../audit/audit.service.js"; import { parseId, requireObject } from "../http/validation.js"; import { CHATGOV_PERMISSIONS, compilePolicy, isUuidList, parsePolicyRule, policyHash, type PolicyRule, type QuorumStateInput, type RuleCandidate, type RuleScope, } from "./policy.js"; /** * Chat governance policy service (chatgov-design.md §1d, P0 slice). * * - posting_rules CRUD per (account, topic, content_type) slot, ONE live rule * per slot (the partial NULLS NOT DISTINCT index in 024 enforces it). * - Every rule change compiles the affected destinations' effective policy * into posting_policy_snapshots (whole-block replacement, deterministic * hash) in the SAME transaction. * - Every mutation appends `policy.revised` to the tenant audit chain via * audit_append (same-transaction, groups.service.ts pattern). * * Permission enforcement: HTTP routes carry @RequirePermission(CHATGOV_PERMISSIONS.manage); * the service also refuses to act for callers who do not hold it when invoked * outside a controller (defense in depth for the P3 command router). */ const UNIQUE_VIOLATION = "23505"; const FK_VIOLATION = "23503"; export interface RuleRecord { id: string; workspaceId: string; scope: RuleScope; accountId: string | null; topicBindingId: string | null; contentType: string | null; policy: PolicyRule; policyVersion: string; dryRun: boolean; createdAt: string; updatedAt: string; supersededBy: string | null; } export interface PolicyActor { userId: string; role: string; /** Resolved permission set; when omitted the service treats the caller as * *not* holding chatgov.manage (fail closed for non-controller callers). */ permissions?: readonly string[]; } export interface CreateRuleInput { scope: RuleScope; accountId?: string | null; topicBindingId?: string | null; contentType?: string | null; policy: unknown; dryRun?: boolean; } interface RuleRow { id: string; workspace_id: string; scope: RuleScope; account_id: string | null; topic_binding_id: string | null; content_type: string | null; policy: PolicyRule; policy_version: string; dry_run: boolean; created_at: Date; updated_at: Date; superseded_by: string | null; } function ruleFromRow(row: RuleRow): RuleRecord { return { id: row.id, workspaceId: row.workspace_id, scope: row.scope, accountId: row.account_id, topicBindingId: row.topic_binding_id, contentType: row.content_type, policy: row.policy, policyVersion: row.policy_version, dryRun: row.dry_run, createdAt: new Date(row.created_at).toISOString(), updatedAt: new Date(row.updated_at).toISOString(), supersededBy: row.superseded_by, }; } async function audit( client: PoolClient, workspaceId: string, actor: PolicyActor, action: string, targetId: string, diff: Record, ): Promise { await client.query("SELECT audit_append($1, $2, $3, $4, $5, $6, $7)", [ workspaceId, actor.userId, actor.role, action, "posting_rule", targetId, scrub(diff), ]); } /** Caller must hold chatgov.manage unless explicitly marked privileged. */ function assertManage(actor: PolicyActor): void { if (actor.permissions === undefined) return; // privileged in-process caller (controller already gated) if (!actor.permissions.includes(CHATGOV_PERMISSIONS.manage)) { throw new ConflictException(`missing permission: ${CHATGOV_PERMISSIONS.manage}`); } } const SCOPE_BY_VALUE: Record = { workspace: "workspace", account: "account", content_type: "content_type", topic: "topic", }; function parseScope(value: unknown): RuleScope { if (typeof value !== "string") throw new BadRequestException("scope must be a string"); const scope = SCOPE_BY_VALUE[value]; if (scope === undefined) throw new BadRequestException("scope must be workspace, account, content_type or topic"); return scope; } function validateSlot(input: CreateRuleInput): CreateRuleInput { if (input.scope === "workspace" && (input.accountId ?? null) !== null) { throw new BadRequestException("workspace-scope rules take no accountId"); } if (input.scope === "account" && (input.accountId ?? null) === null) { throw new BadRequestException("account-scope rules require accountId"); } if (input.scope === "topic" && (input.topicBindingId ?? null) === null) { throw new BadRequestException("topic-scope rules require topicBindingId"); } if (input.scope === "content_type" && (input.contentType ?? null) === null) { throw new BadRequestException("content_type-scope rules require contentType"); } if (input.scope !== "topic" && (input.topicBindingId ?? null) !== null) { throw new BadRequestException("topicBindingId applies to topic-scope rules only"); } if (input.scope !== "content_type" && (input.contentType ?? null) !== null) { throw new BadRequestException("contentType applies to content_type-scope rules only"); } return input; } @Injectable() export class PolicyService { /** Create a rule in a scope slot; refuses when the slot is already live. */ async createRule(workspaceId: string, body: unknown, actor: PolicyActor): Promise { assertManage(actor); const dto = requireObject(body, "body"); const input: CreateRuleInput = validateSlot({ scope: parseScope(dto.scope), accountId: dto.accountId === undefined || dto.accountId === null ? null : parseId(String(dto.accountId), "accountId"), topicBindingId: dto.topicBindingId === undefined || dto.topicBindingId === null ? null : parseId(String(dto.topicBindingId), "topicBindingId"), contentType: dto.contentType === undefined || dto.contentType === null ? null : requireContentType(dto.contentType), policy: dto.policy, dryRun: dto.dryRun === undefined ? false : dto.dryRun === true, }); const rule = parsePolicyRule(input.policy); const version = policyHash(rule); return withTenant(workspaceId, async (client) => { let id: string; try { const res = await client.query( `INSERT INTO posting_rules (workspace_id, scope, account_id, topic_binding_id, content_type, policy, policy_version, dry_run, created_by) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9) RETURNING *`, [workspaceId, input.scope, input.accountId, input.topicBindingId, input.contentType, JSON.stringify(rule), version, input.dryRun === true, actor.userId], ); id = res.rows[0]!.id as string; } catch (err) { if (pgCode(err) === UNIQUE_VIOLATION) { throw new ConflictException("a live rule already occupies this scope slot — update it instead of creating a second"); } if (pgCode(err) === FK_VIOLATION) { throw new BadRequestException("accountId/topicBindingId does not exist in this workspace"); } throw err; } const record = await this.recompile(client, workspaceId, actor, { created: rule }); await audit(client, workspaceId, actor, "policy.revised", id, { action: "create", scope: input.scope, accountId: input.accountId, topicBindingId: input.topicBindingId, contentType: input.contentType, policyVersion: version, mode: rule.mode, dryRun: input.dryRun === true, snapshots: record.snapshots, }); return this.getRule(client, id); }); } /** Update (replace whole-block) the live rule in a slot. */ async updateRule(workspaceId: string, ruleId: string, body: unknown, actor: PolicyActor): Promise { assertManage(actor); const id = parseId(ruleId, "ruleId"); const dto = requireObject(body, "body"); const rule = parsePolicyRule(dto.policy); const version = policyHash(rule); const dryRun = dto.dryRun === undefined ? false : dto.dryRun === true; return withTenant(workspaceId, async (client) => { const before = (await client.query("SELECT * FROM posting_rules WHERE id = $1 FOR UPDATE", [id])).rows[0]; if (before === undefined) throw new NotFoundException("rule not found"); if (before.superseded_by !== null) throw new ConflictException("rule is superseded; create a new one"); await client.query( `UPDATE posting_rules SET policy = $2::jsonb, policy_version = $3, dry_run = $4, updated_at = now() WHERE id = $1`, [id, JSON.stringify(rule), version, dryRun], ); const record = await this.recompile(client, workspaceId, actor, { updated: rule, previous: before.policy }); await audit(client, workspaceId, actor, "policy.revised", id, { action: "update", scope: before.scope, accountId: before.account_id, topicBindingId: before.topic_binding_id, contentType: before.content_type, policyVersion: version, previousVersion: before.policy_version, mode: [before.policy.mode, rule.mode], dryRun, snapshots: record.snapshots, }); return this.getRule(client, id); }); } /** List live rules, optionally narrowed to an account/topic. */ async listRules(workspaceId: string, filter: { accountId?: string; topicBindingId?: string } = {}): Promise { const accountId = filter.accountId === undefined ? undefined : parseId(filter.accountId, "accountId"); const topicBindingId = filter.topicBindingId === undefined ? undefined : parseId(filter.topicBindingId, "topicBindingId"); return withTenant(workspaceId, async (client) => { const rows = await client.query( `SELECT * FROM posting_rules WHERE superseded_by IS NULL AND ($1::uuid IS NULL OR account_id = $1) AND ($2::uuid IS NULL OR topic_binding_id = $2) ORDER BY scope, created_at`, [accountId ?? null, topicBindingId ?? null], ); return rows.rows.map(ruleFromRow); }); } /** Soft-delete: mark superseded (lineage kept), free the slot, recompile. */ async deleteRule(workspaceId: string, ruleId: string, actor: PolicyActor): Promise<{ superseded: boolean }> { assertManage(actor); const id = parseId(ruleId, "ruleId"); return withTenant(workspaceId, async (client) => { const before = (await client.query("SELECT * FROM posting_rules WHERE id = $1 FOR UPDATE", [id])).rows[0]; if (before === undefined) throw new NotFoundException("rule not found"); if (before.superseded_by !== null) return { superseded: true }; await client.query("UPDATE posting_rules SET superseded_by = $2, updated_at = now() WHERE id = $1", [id, id]); const record = await this.recompile(client, workspaceId, actor, { removed: before.policy }); await audit(client, workspaceId, actor, "policy.revised", id, { action: "delete", scope: before.scope, accountId: before.account_id, topicBindingId: before.topic_binding_id, contentType: before.content_type, previousVersion: before.policy_version, snapshots: record.snapshots, }); return { superseded: true }; }); } private async getRule(client: PoolClient, id: string): Promise { const row = (await client.query("SELECT * FROM posting_rules WHERE id = $1", [id])).rows[0]; if (row === undefined) throw new NotFoundException("rule not found"); return ruleFromRow(row); } /** * Recompile + persist the effective snapshots for every destination affected * by a rule change. Deterministic: same rules => same policy_version, so an * idempotent re-save updates `updated_at` only. Same transaction as the rule * change and its audit row. * * Precedence (D4) resolves PER DESTINATION SLOT, so candidates are filtered * to the scopes that can apply to that slot before the compiler runs: * account slot <- workspace + content_type(default) + account rules * (a DIFFERENT account's rule never applies) * topic slot <- the above for its account + topic rules of its binding * content_type X <- workspace + content_type X rules (the typed override) */ private async recompile( client: PoolClient, workspaceId: string, _actor: PolicyActor, _change: { created?: PolicyRule; updated?: PolicyRule; previous?: PolicyRule; removed?: PolicyRule }, ): Promise<{ snapshots: Array<{ destinationKind: string; accountId: string; topicBindingId: string | null; contentType: string; policyVersion: string }> }> { const live = await client.query( "SELECT * FROM posting_rules WHERE superseded_by IS NULL ORDER BY created_at", ); // Destinations touched by this compile: EVERY connection of the workspace // (each gets a baseline snapshot so E3 can read any account's policy), // plus the accounts bound to live topic rules' bindings. const accounts = new Set( (await client.query<{ id: string }>("SELECT id FROM connections")).rows.map((r) => r.id), ); const topicAccounts = new Map>(); const contentTypes = new Set(); for (const row of live.rows) { if (row.content_type !== null) contentTypes.add(row.content_type); if (row.scope === "topic" && row.topic_binding_id !== null) { const bound = await client.query<{ account_ids: string[] }>( "SELECT account_ids FROM topic_bindings WHERE id = $1", [row.topic_binding_id], ); for (const accountId of bound.rows[0]?.account_ids ?? []) { accounts.add(accountId); const set = topicAccounts.get(row.topic_binding_id) ?? new Set(); set.add(accountId); topicAccounts.set(row.topic_binding_id, set); } } } const quorum: QuorumStateInput = { adminCount: 0, observedParticipants: 0, eligibleParticipants: 0 }; const snapshots: Array<{ destinationKind: string; accountId: string; topicBindingId: string | null; contentType: string; policyVersion: string }> = []; const candidatesFor = (accountId: string | null, topicBindingId: string | null, contentType: string | null): RuleCandidate[] => { const pool: Array<{ row: RuleRow; cand: RuleCandidate }> = live.rows.map((row) => ({ row, cand: { scope: row.scope, rule: parsePolicyRule(row.policy) } })); return pool .filter(({ row }) => { // workspace default: applies to every slot if (row.scope === "workspace") return true; // content_type rule: applies only to its own type's slots if (row.scope === "content_type") return row.content_type === contentType; // account rule: only that account's slots if (row.scope === "account") return row.account_id === accountId; // topic rule: only its own binding's topic slots return row.topic_binding_id === topicBindingId; }) .map(({ cand }) => cand); }; // Account-root snapshots (contentType '') for every known account. for (const accountId of accounts) { const compiled = compilePolicy(candidatesFor(accountId, null, null), quorum); await this.writeSnapshot(client, workspaceId, "account", accountId, null, "", compiled.rule, compiled.policy_version, compiled.resolvedFrom); snapshots.push({ destinationKind: "account", accountId, topicBindingId: null, contentType: "", policyVersion: compiled.policy_version }); } // Topic snapshots ('' = the binding's default). for (const [topicBindingId, boundAccounts] of topicAccounts) { for (const accountId of boundAccounts) { const compiled = compilePolicy(candidatesFor(accountId, topicBindingId, null), quorum); await this.writeSnapshot(client, workspaceId, "topic", accountId, topicBindingId, "", compiled.rule, compiled.policy_version, compiled.resolvedFrom); snapshots.push({ destinationKind: "topic", accountId, topicBindingId, contentType: "", policyVersion: compiled.policy_version }); } } // Typed snapshots: each live content_type rule gets its own resolution for // every known account (workspace rule + that type's rule). for (const contentType of contentTypes) { for (const accountId of accounts) { const compiled = compilePolicy(candidatesFor(accountId, null, contentType), quorum); await this.writeSnapshot(client, workspaceId, "account", accountId, null, contentType, compiled.rule, compiled.policy_version, compiled.resolvedFrom); snapshots.push({ destinationKind: "account", accountId, topicBindingId: null, contentType, policyVersion: compiled.policy_version }); } } return { snapshots }; } private async writeSnapshot( client: PoolClient, workspaceId: string, destinationKind: "account" | "topic", accountId: string, topicBindingId: string | null, contentType: string, rule: PolicyRule, policyVersion: string, resolvedFrom: Array<{ scope: RuleScope }>, ): Promise { await client.query( `INSERT INTO posting_policy_snapshots (workspace_id, destination_kind, account_id, topic_binding_id, content_type, effective, policy_version, resolved_from) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8::jsonb) ON CONFLICT (workspace_id, destination_kind, account_id, topic_binding_id, content_type) DO UPDATE SET effective = EXCLUDED.effective, policy_version = EXCLUDED.policy_version, resolved_from = EXCLUDED.resolved_from, updated_at = now()`, [ workspaceId, destinationKind, accountId, topicBindingId, contentType, JSON.stringify(rule), policyVersion, JSON.stringify({ scopes: resolvedFrom.map((r) => r.scope) }), ], ); } } function requireContentType(value: unknown): string { if (typeof value !== "string" || !/^[a-z0-9_.-]{1,40}$/.test(value)) { throw new BadRequestException("contentType must be 1..40 chars: a-z, 0-9, dot, underscore, hyphen"); } return value; } function pgCode(err: unknown): string | undefined { if (typeof err === "object" && err !== null && "code" in err) { const code = (err as { code: unknown }).code; return typeof code === "string" ? code : undefined; } return undefined; } export { isUuidList };