Add organization governance controls
This commit is contained in:
1 parent
1d7757c88b
commit
7116ae2a7b
15 files changed
+589
-5
No files matched your search
@@ -4,6 +4,7 @@ import { APPROVAL_STATE, ROLE_KEY } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { IdentityAccessService } from "./identity-access-service";
|
||||
import { OrgGovernanceService } from "./org-governance-service";
|
||||
|
||||
interface ApprovalAuditContext {
|
||||
sourceMessageId?: string;
|
||||
@@ -13,10 +14,12 @@ interface ApprovalAuditContext {
|
||||
export class ApprovalService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly identityAccess: IdentityAccessService;
|
||||
private readonly governance: OrgGovernanceService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
this.identityAccess = new IdentityAccessService(db);
|
||||
this.governance = new OrgGovernanceService(db);
|
||||
}
|
||||
|
||||
create(input: CreateApprovalInput, context: ApprovalAuditContext = {}): string {
|
||||
@@ -57,6 +60,7 @@ export class ApprovalService {
|
||||
action: "requested",
|
||||
payload: { kind, risk: input.risk, ...context },
|
||||
});
|
||||
this.governance.routeApproval({ orgId: input.orgId, approvalId: id, kind, risk: input.risk });
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import { AuditService } from "./audit-service";
|
||||
|
||||
const LEGACY_TABLES = [
|
||||
"organizations",
|
||||
"org_departments",
|
||||
"hiring_policies",
|
||||
"employees",
|
||||
"employee_identities",
|
||||
"service_identities",
|
||||
@@ -20,6 +22,8 @@ const LEGACY_TABLES = [
|
||||
"projects",
|
||||
"task_runs",
|
||||
"approvals",
|
||||
"approval_routing_rules",
|
||||
"approval_assignments",
|
||||
"chat_spaces",
|
||||
"chat_messages",
|
||||
"chat_threads",
|
||||
@@ -88,6 +92,9 @@ interface MigrationReportBody {
|
||||
}
|
||||
|
||||
const RELATION_CHECKS: RelationCheck[] = [
|
||||
{ table: "org_departments", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "org_departments", column: "lead_agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_lead_agent_id" },
|
||||
{ table: "hiring_policies", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "employees", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "employee_identities", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "employee_identities", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_id" },
|
||||
@@ -118,6 +125,10 @@ const RELATION_CHECKS: RelationCheck[] = [
|
||||
{ table: "task_runs", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" },
|
||||
{ table: "approvals", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "approvals", column: "decided_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_decider_id" },
|
||||
{ table: "approval_routing_rules", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "approval_assignments", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "approval_assignments", column: "approval_id", referencedTable: "approvals", referencedColumn: "id", reason: "dangling_approval_id" },
|
||||
{ table: "approval_assignments", column: "routing_rule_id", referencedTable: "approval_routing_rules", referencedColumn: "id", reason: "dangling_routing_rule_id" },
|
||||
{ table: "chat_spaces", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "chat_messages", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "chat_messages", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" },
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateAgentInput } from "../../shared/commands";
|
||||
import { RISK_LEVEL, ROUTE_TO_TYPE, type RiskLevel } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
const DEFAULT_DEPARTMENTS = ["Executive", "Engineering", "Product", "Design", "Marketing", "Operations", "Support"] as const;
|
||||
const RISK_RANK: Record<RiskLevel, number> = { low: 0, medium: 1, high: 2 };
|
||||
|
||||
export class OrgGovernanceService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
seedOrganizationGovernance(orgId: string, boardEmployeeId: string, now: string): void {
|
||||
for (const department of DEFAULT_DEPARTMENTS) this.seedDepartmentPolicy(orgId, department, now);
|
||||
this.db.prepare(`
|
||||
insert into approval_routing_rules(id, org_id, kind, minimum_risk, route_to_type, route_to_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, kind, minimum_risk) do nothing
|
||||
`).run(createId("arr"), orgId, "*", RISK_LEVEL.LOW, ROUTE_TO_TYPE.EMPLOYEE, boardEmployeeId, now);
|
||||
}
|
||||
|
||||
enforceHiringPolicy(input: CreateAgentInput): HiringPolicyDecision {
|
||||
const departmentName = input.department.trim();
|
||||
if (!departmentName) throw new Error("Department is required.");
|
||||
const policy = this.db.prepare(`
|
||||
select max_headcount, monthly_budget_limit_cents, require_approval_above_cents
|
||||
from hiring_policies
|
||||
where org_id = ? and department_name = ?
|
||||
`).get(input.orgId, departmentName) as HiringPolicyRow | undefined;
|
||||
if (!policy) throw new Error("Hiring policy is required for department.");
|
||||
const usage = this.db.prepare(`
|
||||
select count(*) as headcount, coalesce(sum(budget_cents), 0) as budget_cents
|
||||
from agents
|
||||
where org_id = ? and department = ? and lifecycle = ?
|
||||
`).get(input.orgId, departmentName, "active") as { headcount: number; budget_cents: number };
|
||||
if (usage.headcount >= policy.max_headcount) throw new Error("Department headcount limit has been reached.");
|
||||
if (usage.budget_cents + input.budgetCents > policy.monthly_budget_limit_cents) {
|
||||
throw new Error("Department monthly budget limit has been reached.");
|
||||
}
|
||||
return { reviewRequired: input.budgetCents > policy.require_approval_above_cents };
|
||||
}
|
||||
|
||||
routeApproval(input: { orgId: string; approvalId: string; kind: string; risk: RiskLevel }): void {
|
||||
const rule = this.resolveRoutingRule(input.orgId, input.kind, input.risk);
|
||||
this.assertRouteTarget(input.orgId, rule.route_to_type, rule.route_to_id);
|
||||
this.db.prepare(`
|
||||
insert into approval_assignments(id, org_id, approval_id, assignee_type, assignee_id, routing_rule_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(approval_id, assignee_type, assignee_id) do nothing
|
||||
`).run(createId("aas"), input.orgId, input.approvalId, rule.route_to_type, rule.route_to_id, rule.id, new Date().toISOString());
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "approval",
|
||||
entityId: input.approvalId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "routed",
|
||||
payload: { routingRuleId: rule.id, routeToType: rule.route_to_type, routeToId: rule.route_to_id },
|
||||
});
|
||||
}
|
||||
|
||||
private seedDepartmentPolicy(orgId: string, departmentName: string, now: string): void {
|
||||
this.db.prepare(`
|
||||
insert into org_departments(id, org_id, name, lead_agent_id, headcount_limit, monthly_budget_limit_cents, created_at, updated_at)
|
||||
values (?, ?, ?, null, 12, 2500000, ?, ?)
|
||||
on conflict(org_id, name) do nothing
|
||||
`).run(createId("dep"), orgId, departmentName, now, now);
|
||||
this.db.prepare(`
|
||||
insert into hiring_policies(id, org_id, department_name, max_headcount, monthly_budget_limit_cents, require_approval_above_cents, created_at, updated_at)
|
||||
values (?, ?, ?, 12, 2500000, 500000, ?, ?)
|
||||
on conflict(org_id, department_name) do nothing
|
||||
`).run(createId("hpl"), orgId, departmentName, now, now);
|
||||
}
|
||||
|
||||
private resolveRoutingRule(orgId: string, kind: string, risk: RiskLevel): ApprovalRoutingRuleRow {
|
||||
const rows = this.db.prepare(`
|
||||
select id, kind, minimum_risk, route_to_type, route_to_id
|
||||
from approval_routing_rules
|
||||
where org_id = ? and (kind = ? or kind = ?)
|
||||
`).all(orgId, kind, "*") as unknown as ApprovalRoutingRuleRow[];
|
||||
const matching = rows
|
||||
.filter((row) => riskApplies(row.minimum_risk, risk))
|
||||
.sort((a, b) => ruleRank(kind, b) - ruleRank(kind, a));
|
||||
const selected = matching[0];
|
||||
if (!selected) throw new Error("Approval routing rule is required.");
|
||||
return selected;
|
||||
}
|
||||
|
||||
private assertRouteTarget(orgId: string, routeToType: string, routeToId: string): void {
|
||||
if (routeToType === ROUTE_TO_TYPE.BOARD) {
|
||||
if (routeToId !== orgId) throw new Error("Board approval route is invalid.");
|
||||
return;
|
||||
}
|
||||
if (routeToType === ROUTE_TO_TYPE.EMPLOYEE) {
|
||||
const employee = this.db.prepare("select id from employees where id = ? and org_id = ?").get(routeToId, orgId);
|
||||
if (!employee) throw new Error("Approval route employee was not found.");
|
||||
return;
|
||||
}
|
||||
if (routeToType === ROUTE_TO_TYPE.AGENT) {
|
||||
const agent = this.db.prepare("select id from agents where id = ? and org_id = ?").get(routeToId, orgId);
|
||||
if (!agent) throw new Error("Approval route agent was not found.");
|
||||
return;
|
||||
}
|
||||
throw new Error("Approval route target type is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
interface HiringPolicyRow {
|
||||
max_headcount: number;
|
||||
monthly_budget_limit_cents: number;
|
||||
require_approval_above_cents: number;
|
||||
}
|
||||
|
||||
interface HiringPolicyDecision {
|
||||
reviewRequired: boolean;
|
||||
}
|
||||
|
||||
interface ApprovalRoutingRuleRow {
|
||||
id: string;
|
||||
kind: string;
|
||||
minimum_risk: string;
|
||||
route_to_type: string;
|
||||
route_to_id: string;
|
||||
}
|
||||
|
||||
function riskApplies(minimumRisk: string, risk: RiskLevel): boolean {
|
||||
const required = RISK_RANK[minimumRisk as RiskLevel];
|
||||
if (required === undefined) throw new Error("Approval routing risk is invalid.");
|
||||
return RISK_RANK[risk] >= required;
|
||||
}
|
||||
|
||||
function ruleRank(kind: string, rule: ApprovalRoutingRuleRow): number {
|
||||
const exactKind = rule.kind === kind ? 100 : 0;
|
||||
return exactKind + RISK_RANK[rule.minimum_risk as RiskLevel];
|
||||
}
|
||||
@@ -6,17 +6,20 @@ import type { CreateAgentInput, CreateOrganizationInput, CreateTaskInput } from
|
||||
import { AuditService } from "./audit-service";
|
||||
import { PolicyService } from "./policy-service";
|
||||
import { IdentityAccessService } from "./identity-access-service";
|
||||
import { OrgGovernanceService } from "./org-governance-service";
|
||||
import { assertUsableMentionName } from "./mention-handles";
|
||||
|
||||
export class OrganizationService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly policy: PolicyService;
|
||||
private readonly identityAccess: IdentityAccessService;
|
||||
private readonly governance: OrgGovernanceService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
this.policy = new PolicyService(db);
|
||||
this.identityAccess = new IdentityAccessService(db);
|
||||
this.governance = new OrgGovernanceService(db);
|
||||
}
|
||||
|
||||
createOrganization(input: CreateOrganizationInput): string {
|
||||
@@ -44,6 +47,7 @@ export class OrganizationService {
|
||||
this.db.prepare("insert into employees(id, org_id, display_name, role, created_at) values (?, ?, ?, ?, ?)")
|
||||
.run(employeeId, orgId, input.employeeName.trim(), "Board Chair", now);
|
||||
this.identityAccess.seedEmployeeAccess(orgId, employeeId, now);
|
||||
this.governance.seedOrganizationGovernance(orgId, employeeId, now);
|
||||
|
||||
for (const space of [
|
||||
["company", "general", "Company coordination", "workspace"],
|
||||
@@ -134,6 +138,7 @@ export class OrganizationService {
|
||||
|
||||
createAgent(input: CreateAgentInput): string {
|
||||
assertUsableMentionName(input.name);
|
||||
const hiringPolicy = this.governance.enforceHiringPolicy(input);
|
||||
const id = createId("agt");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
@@ -172,6 +177,29 @@ export class OrganizationService {
|
||||
});
|
||||
this.addAgentToWorkspaceSpaces(input.orgId, id);
|
||||
this.identityAccess.createAgentServiceIdentity(input.orgId, id, input.name, now);
|
||||
if (hiringPolicy.reviewRequired) {
|
||||
const approvalId = createId("app");
|
||||
this.db.prepare(`
|
||||
insert into approvals(
|
||||
id, org_id, kind, title, details, risk, requested_by_type,
|
||||
requested_by_id, state, decided_by_employee_id, created_at, decided_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
approvalId,
|
||||
input.orgId,
|
||||
"agent_hire",
|
||||
`Review hire: ${input.name.trim()}`,
|
||||
`Department: ${input.department.trim()}\nRole: ${input.role.trim()}\nBudget: ${input.budgetCents}`,
|
||||
RISK_LEVEL.MEDIUM,
|
||||
"system",
|
||||
null,
|
||||
APPROVAL_STATE.PENDING,
|
||||
null,
|
||||
now,
|
||||
null,
|
||||
);
|
||||
this.governance.routeApproval({ orgId: input.orgId, approvalId, kind: "agent_hire", risk: RISK_LEVEL.MEDIUM });
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -207,13 +235,14 @@ export class OrganizationService {
|
||||
);
|
||||
const policy = this.policy.evaluateTaskCreation(input.orgId, input.risk);
|
||||
if (policy.approvalRequired) {
|
||||
const approvalId = createId("app");
|
||||
this.db.prepare(`
|
||||
insert into approvals(
|
||||
id, org_id, kind, title, details, risk, requested_by_type,
|
||||
requested_by_id, state, decided_by_employee_id, created_at, decided_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("app"),
|
||||
approvalId,
|
||||
input.orgId,
|
||||
"task_risk",
|
||||
input.title.trim(),
|
||||
@@ -226,6 +255,7 @@ export class OrganizationService {
|
||||
now,
|
||||
null,
|
||||
);
|
||||
this.governance.routeApproval({ orgId: input.orgId, approvalId, kind: "task_risk", risk: RISK_LEVEL.HIGH });
|
||||
}
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
|
||||
@@ -96,6 +96,10 @@ export class SnapshotService {
|
||||
serviceIdentities: list(this.db, "service_identities", "created_at desc"),
|
||||
roleGrants: list(this.db, "role_grants", "created_at desc"),
|
||||
accessDecisions: list<AppSnapshot["accessDecisions"][number]>(this.db, "access_decisions", "created_at desc").slice(0, 200),
|
||||
orgDepartments: list(this.db, "org_departments", "name asc"),
|
||||
hiringPolicies: list(this.db, "hiring_policies", "department_name asc"),
|
||||
approvalRoutingRules: list(this.db, "approval_routing_rules", "created_at asc"),
|
||||
approvalAssignments: list(this.db, "approval_assignments", "created_at desc"),
|
||||
auditEvents: list<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc").slice(0, 200),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user