Add organization governance controls
This commit is contained in:
1 parent
1d7757c88b
commit
7116ae2a7b
15 files changed
+589
-5
No files matched your search
@@ -0,0 +1,89 @@
|
|||||||
|
export const orgGovernanceSchema = `
|
||||||
|
create table if not exists org_departments (
|
||||||
|
id text primary key,
|
||||||
|
org_id text not null references organizations(id) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
lead_agent_id text references agents(id) on delete set null,
|
||||||
|
headcount_limit integer not null,
|
||||||
|
monthly_budget_limit_cents integer not null,
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null,
|
||||||
|
unique(org_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists hiring_policies (
|
||||||
|
id text primary key,
|
||||||
|
org_id text not null references organizations(id) on delete cascade,
|
||||||
|
department_name text not null,
|
||||||
|
max_headcount integer not null,
|
||||||
|
monthly_budget_limit_cents integer not null,
|
||||||
|
require_approval_above_cents integer not null,
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null,
|
||||||
|
unique(org_id, department_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists approval_routing_rules (
|
||||||
|
id text primary key,
|
||||||
|
org_id text not null references organizations(id) on delete cascade,
|
||||||
|
kind text not null,
|
||||||
|
minimum_risk text not null,
|
||||||
|
route_to_type text not null,
|
||||||
|
route_to_id text not null,
|
||||||
|
created_at text not null,
|
||||||
|
unique(org_id, kind, minimum_risk)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists approval_assignments (
|
||||||
|
id text primary key,
|
||||||
|
org_id text not null references organizations(id) on delete cascade,
|
||||||
|
approval_id text not null references approvals(id) on delete cascade,
|
||||||
|
assignee_type text not null,
|
||||||
|
assignee_id text not null,
|
||||||
|
routing_rule_id text references approval_routing_rules(id) on delete set null,
|
||||||
|
created_at text not null,
|
||||||
|
unique(approval_id, assignee_type, assignee_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_org_departments_org_name on org_departments(org_id, name);
|
||||||
|
create index if not exists idx_hiring_policies_org_department on hiring_policies(org_id, department_name);
|
||||||
|
create index if not exists idx_approval_assignments_org_approval on approval_assignments(org_id, approval_id);
|
||||||
|
|
||||||
|
insert into org_departments(id, org_id, name, lead_agent_id, headcount_limit, monthly_budget_limit_cents, created_at, updated_at)
|
||||||
|
select 'dep_' || lower(hex(randomblob(16))), organizations.id, seed.name, null, 12, 2500000, datetime('now'), datetime('now')
|
||||||
|
from organizations
|
||||||
|
cross join (
|
||||||
|
select 'Executive' as name union all select 'Engineering' union all select 'Product' union all select 'Design'
|
||||||
|
union all select 'Marketing' union all select 'Operations' union all select 'Support'
|
||||||
|
) seed
|
||||||
|
where not exists (
|
||||||
|
select 1 from org_departments
|
||||||
|
where org_departments.org_id = organizations.id and org_departments.name = seed.name
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into hiring_policies(id, org_id, department_name, max_headcount, monthly_budget_limit_cents, require_approval_above_cents, created_at, updated_at)
|
||||||
|
select 'hpl_' || lower(hex(randomblob(16))), org_id, name, headcount_limit, monthly_budget_limit_cents, 500000, datetime('now'), datetime('now')
|
||||||
|
from org_departments
|
||||||
|
where not exists (
|
||||||
|
select 1 from hiring_policies
|
||||||
|
where hiring_policies.org_id = org_departments.org_id and hiring_policies.department_name = org_departments.name
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into approval_routing_rules(id, org_id, kind, minimum_risk, route_to_type, route_to_id, created_at)
|
||||||
|
select 'arr_' || lower(hex(randomblob(16))), organizations.id, '*', 'low', 'employee', employees.id, datetime('now')
|
||||||
|
from organizations
|
||||||
|
join employees on employees.org_id = organizations.id
|
||||||
|
where employees.id = (
|
||||||
|
select first_employee.id from employees first_employee
|
||||||
|
where first_employee.org_id = organizations.id
|
||||||
|
order by first_employee.created_at asc
|
||||||
|
limit 1
|
||||||
|
)
|
||||||
|
and not exists (
|
||||||
|
select 1 from approval_routing_rules
|
||||||
|
where approval_routing_rules.org_id = organizations.id and approval_routing_rules.kind = '*' and approval_routing_rules.minimum_risk = 'low'
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into schema_migrations(version, name, applied_at)
|
||||||
|
values (16, 'org_governance', datetime('now'));
|
||||||
|
`;
|
||||||
@@ -14,6 +14,7 @@ import { complianceControlsSchema } from "./migrations/012-compliance-controls";
|
|||||||
import { searchDocumentsSchema } from "./migrations/013-search-documents";
|
import { searchDocumentsSchema } from "./migrations/013-search-documents";
|
||||||
import { providerPolicyUsageSchema } from "./migrations/014-provider-policy-usage";
|
import { providerPolicyUsageSchema } from "./migrations/014-provider-policy-usage";
|
||||||
import { identityAccessSchema } from "./migrations/015-identity-access";
|
import { identityAccessSchema } from "./migrations/015-identity-access";
|
||||||
|
import { orgGovernanceSchema } from "./migrations/016-org-governance";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -37,6 +38,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
{ version: 13, name: "search_documents", sql: searchDocumentsSchema },
|
{ version: 13, name: "search_documents", sql: searchDocumentsSchema },
|
||||||
{ version: 14, name: "provider_policy_usage", sql: providerPolicyUsageSchema },
|
{ version: 14, name: "provider_policy_usage", sql: providerPolicyUsageSchema },
|
||||||
{ version: 15, name: "identity_access", sql: identityAccessSchema },
|
{ version: 15, name: "identity_access", sql: identityAccessSchema },
|
||||||
|
{ version: 16, name: "org_governance", sql: orgGovernanceSchema },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { APPROVAL_STATE, ROLE_KEY } from "../../shared/domain";
|
|||||||
import { createId } from "../../shared/ids";
|
import { createId } from "../../shared/ids";
|
||||||
import { AuditService } from "./audit-service";
|
import { AuditService } from "./audit-service";
|
||||||
import { IdentityAccessService } from "./identity-access-service";
|
import { IdentityAccessService } from "./identity-access-service";
|
||||||
|
import { OrgGovernanceService } from "./org-governance-service";
|
||||||
|
|
||||||
interface ApprovalAuditContext {
|
interface ApprovalAuditContext {
|
||||||
sourceMessageId?: string;
|
sourceMessageId?: string;
|
||||||
@@ -13,10 +14,12 @@ interface ApprovalAuditContext {
|
|||||||
export class ApprovalService {
|
export class ApprovalService {
|
||||||
private readonly audit: AuditService;
|
private readonly audit: AuditService;
|
||||||
private readonly identityAccess: IdentityAccessService;
|
private readonly identityAccess: IdentityAccessService;
|
||||||
|
private readonly governance: OrgGovernanceService;
|
||||||
|
|
||||||
constructor(private readonly db: DatabaseSync) {
|
constructor(private readonly db: DatabaseSync) {
|
||||||
this.audit = new AuditService(db);
|
this.audit = new AuditService(db);
|
||||||
this.identityAccess = new IdentityAccessService(db);
|
this.identityAccess = new IdentityAccessService(db);
|
||||||
|
this.governance = new OrgGovernanceService(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
create(input: CreateApprovalInput, context: ApprovalAuditContext = {}): string {
|
create(input: CreateApprovalInput, context: ApprovalAuditContext = {}): string {
|
||||||
@@ -57,6 +60,7 @@ export class ApprovalService {
|
|||||||
action: "requested",
|
action: "requested",
|
||||||
payload: { kind, risk: input.risk, ...context },
|
payload: { kind, risk: input.risk, ...context },
|
||||||
});
|
});
|
||||||
|
this.governance.routeApproval({ orgId: input.orgId, approvalId: id, kind, risk: input.risk });
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { AuditService } from "./audit-service";
|
|||||||
|
|
||||||
const LEGACY_TABLES = [
|
const LEGACY_TABLES = [
|
||||||
"organizations",
|
"organizations",
|
||||||
|
"org_departments",
|
||||||
|
"hiring_policies",
|
||||||
"employees",
|
"employees",
|
||||||
"employee_identities",
|
"employee_identities",
|
||||||
"service_identities",
|
"service_identities",
|
||||||
@@ -20,6 +22,8 @@ const LEGACY_TABLES = [
|
|||||||
"projects",
|
"projects",
|
||||||
"task_runs",
|
"task_runs",
|
||||||
"approvals",
|
"approvals",
|
||||||
|
"approval_routing_rules",
|
||||||
|
"approval_assignments",
|
||||||
"chat_spaces",
|
"chat_spaces",
|
||||||
"chat_messages",
|
"chat_messages",
|
||||||
"chat_threads",
|
"chat_threads",
|
||||||
@@ -88,6 +92,9 @@ interface MigrationReportBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RELATION_CHECKS: RelationCheck[] = [
|
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: "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: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||||
{ table: "employee_identities", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_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: "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: "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: "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_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: "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" },
|
{ 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 { AuditService } from "./audit-service";
|
||||||
import { PolicyService } from "./policy-service";
|
import { PolicyService } from "./policy-service";
|
||||||
import { IdentityAccessService } from "./identity-access-service";
|
import { IdentityAccessService } from "./identity-access-service";
|
||||||
|
import { OrgGovernanceService } from "./org-governance-service";
|
||||||
import { assertUsableMentionName } from "./mention-handles";
|
import { assertUsableMentionName } from "./mention-handles";
|
||||||
|
|
||||||
export class OrganizationService {
|
export class OrganizationService {
|
||||||
private readonly audit: AuditService;
|
private readonly audit: AuditService;
|
||||||
private readonly policy: PolicyService;
|
private readonly policy: PolicyService;
|
||||||
private readonly identityAccess: IdentityAccessService;
|
private readonly identityAccess: IdentityAccessService;
|
||||||
|
private readonly governance: OrgGovernanceService;
|
||||||
|
|
||||||
constructor(private readonly db: DatabaseSync) {
|
constructor(private readonly db: DatabaseSync) {
|
||||||
this.audit = new AuditService(db);
|
this.audit = new AuditService(db);
|
||||||
this.policy = new PolicyService(db);
|
this.policy = new PolicyService(db);
|
||||||
this.identityAccess = new IdentityAccessService(db);
|
this.identityAccess = new IdentityAccessService(db);
|
||||||
|
this.governance = new OrgGovernanceService(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
createOrganization(input: CreateOrganizationInput): string {
|
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 (?, ?, ?, ?, ?)")
|
this.db.prepare("insert into employees(id, org_id, display_name, role, created_at) values (?, ?, ?, ?, ?)")
|
||||||
.run(employeeId, orgId, input.employeeName.trim(), "Board Chair", now);
|
.run(employeeId, orgId, input.employeeName.trim(), "Board Chair", now);
|
||||||
this.identityAccess.seedEmployeeAccess(orgId, employeeId, now);
|
this.identityAccess.seedEmployeeAccess(orgId, employeeId, now);
|
||||||
|
this.governance.seedOrganizationGovernance(orgId, employeeId, now);
|
||||||
|
|
||||||
for (const space of [
|
for (const space of [
|
||||||
["company", "general", "Company coordination", "workspace"],
|
["company", "general", "Company coordination", "workspace"],
|
||||||
@@ -134,6 +138,7 @@ export class OrganizationService {
|
|||||||
|
|
||||||
createAgent(input: CreateAgentInput): string {
|
createAgent(input: CreateAgentInput): string {
|
||||||
assertUsableMentionName(input.name);
|
assertUsableMentionName(input.name);
|
||||||
|
const hiringPolicy = this.governance.enforceHiringPolicy(input);
|
||||||
const id = createId("agt");
|
const id = createId("agt");
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
@@ -172,6 +177,29 @@ export class OrganizationService {
|
|||||||
});
|
});
|
||||||
this.addAgentToWorkspaceSpaces(input.orgId, id);
|
this.addAgentToWorkspaceSpaces(input.orgId, id);
|
||||||
this.identityAccess.createAgentServiceIdentity(input.orgId, id, input.name, now);
|
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;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,13 +235,14 @@ export class OrganizationService {
|
|||||||
);
|
);
|
||||||
const policy = this.policy.evaluateTaskCreation(input.orgId, input.risk);
|
const policy = this.policy.evaluateTaskCreation(input.orgId, input.risk);
|
||||||
if (policy.approvalRequired) {
|
if (policy.approvalRequired) {
|
||||||
|
const approvalId = createId("app");
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
insert into approvals(
|
insert into approvals(
|
||||||
id, org_id, kind, title, details, risk, requested_by_type,
|
id, org_id, kind, title, details, risk, requested_by_type,
|
||||||
requested_by_id, state, decided_by_employee_id, created_at, decided_at
|
requested_by_id, state, decided_by_employee_id, created_at, decided_at
|
||||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
createId("app"),
|
approvalId,
|
||||||
input.orgId,
|
input.orgId,
|
||||||
"task_risk",
|
"task_risk",
|
||||||
input.title.trim(),
|
input.title.trim(),
|
||||||
@@ -226,6 +255,7 @@ export class OrganizationService {
|
|||||||
now,
|
now,
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
this.governance.routeApproval({ orgId: input.orgId, approvalId, kind: "task_risk", risk: RISK_LEVEL.HIGH });
|
||||||
}
|
}
|
||||||
this.audit.record({
|
this.audit.record({
|
||||||
orgId: input.orgId,
|
orgId: input.orgId,
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ export class SnapshotService {
|
|||||||
serviceIdentities: list(this.db, "service_identities", "created_at desc"),
|
serviceIdentities: list(this.db, "service_identities", "created_at desc"),
|
||||||
roleGrants: list(this.db, "role_grants", "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),
|
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),
|
auditEvents: list<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc").slice(0, 200),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
|||||||
const employeeIdentities = useMemo(() => snapshot.employeeIdentities.filter((item) => item.orgId === orgId), [snapshot.employeeIdentities, orgId]);
|
const employeeIdentities = useMemo(() => snapshot.employeeIdentities.filter((item) => item.orgId === orgId), [snapshot.employeeIdentities, orgId]);
|
||||||
const serviceIdentities = useMemo(() => snapshot.serviceIdentities.filter((item) => item.orgId === orgId), [snapshot.serviceIdentities, orgId]);
|
const serviceIdentities = useMemo(() => snapshot.serviceIdentities.filter((item) => item.orgId === orgId), [snapshot.serviceIdentities, orgId]);
|
||||||
const roleGrants = useMemo(() => snapshot.roleGrants.filter((item) => item.orgId === orgId), [snapshot.roleGrants, orgId]);
|
const roleGrants = useMemo(() => snapshot.roleGrants.filter((item) => item.orgId === orgId), [snapshot.roleGrants, orgId]);
|
||||||
|
const orgDepartments = useMemo(() => snapshot.orgDepartments.filter((item) => item.orgId === orgId), [snapshot.orgDepartments, orgId]);
|
||||||
|
const hiringPolicies = useMemo(() => snapshot.hiringPolicies.filter((item) => item.orgId === orgId), [snapshot.hiringPolicies, orgId]);
|
||||||
|
const approvalRoutingRules = useMemo(() => snapshot.approvalRoutingRules.filter((item) => item.orgId === orgId), [snapshot.approvalRoutingRules, orgId]);
|
||||||
|
const approvalAssignments = useMemo(() => snapshot.approvalAssignments.filter((item) => item.orgId === orgId), [snapshot.approvalAssignments, orgId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="split-main" style={{ height: "calc(100vh - 44px)", margin: "-28px -32px -40px" }}>
|
<div className="split-main" style={{ height: "calc(100vh - 44px)", margin: "-28px -32px -40px" }}>
|
||||||
@@ -124,7 +128,17 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
|||||||
actions={actions}
|
actions={actions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === "policies" && <PoliciesPanel policies={policies} />}
|
{tab === "policies" && (
|
||||||
|
<PoliciesPanel
|
||||||
|
policies={policies}
|
||||||
|
agents={agents}
|
||||||
|
employees={employees}
|
||||||
|
orgDepartments={orgDepartments}
|
||||||
|
hiringPolicies={hiringPolicies}
|
||||||
|
approvalRoutingRules={approvalRoutingRules}
|
||||||
|
approvalAssignments={approvalAssignments}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{tab === "compliance" && (
|
{tab === "compliance" && (
|
||||||
<CompliancePanel
|
<CompliancePanel
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
import { Pill } from "../../shared/ui";
|
import { Pill } from "../../shared/ui";
|
||||||
import type { PolicyRuleRecord } from "../../../../shared/domain";
|
import type {
|
||||||
|
AgentRecord,
|
||||||
|
ApprovalAssignmentRecord,
|
||||||
|
ApprovalRoutingRuleRecord,
|
||||||
|
EmployeeRecord,
|
||||||
|
HiringPolicyRecord,
|
||||||
|
OrgDepartmentRecord,
|
||||||
|
PolicyRuleRecord,
|
||||||
|
} from "../../../../shared/domain";
|
||||||
|
import { money } from "../types";
|
||||||
|
|
||||||
export function PoliciesPanel({ policies }: { policies: PolicyRuleRecord[] }) {
|
export function PoliciesPanel({
|
||||||
|
policies,
|
||||||
|
agents,
|
||||||
|
employees,
|
||||||
|
orgDepartments,
|
||||||
|
hiringPolicies,
|
||||||
|
approvalRoutingRules,
|
||||||
|
approvalAssignments,
|
||||||
|
}: {
|
||||||
|
policies: PolicyRuleRecord[];
|
||||||
|
agents: AgentRecord[];
|
||||||
|
employees: EmployeeRecord[];
|
||||||
|
orgDepartments: OrgDepartmentRecord[];
|
||||||
|
hiringPolicies: HiringPolicyRecord[];
|
||||||
|
approvalRoutingRules: ApprovalRoutingRuleRecord[];
|
||||||
|
approvalAssignments: ApprovalAssignmentRecord[];
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h1 className="page-h1">Policies</h1>
|
<h1 className="page-h1">Policies</h1>
|
||||||
<p className="page-sub">Policy rules evaluated for actions across the workspace.</p>
|
<p className="page-sub">Policy rules evaluated for actions across the workspace.</p>
|
||||||
|
<div className="section-label" style={{ marginBottom: 10 }}>Rules</div>
|
||||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||||
<div
|
<div
|
||||||
className="table-row head"
|
className="table-row head"
|
||||||
@@ -30,6 +56,118 @@ export function PoliciesPanel({ policies }: { policies: PolicyRuleRecord[] }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="section-label" style={{ margin: "24px 0 10px" }}>Hiring policies</div>
|
||||||
|
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||||
|
<div
|
||||||
|
className="table-row head"
|
||||||
|
style={{ gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr)" }}
|
||||||
|
>
|
||||||
|
<div>Department</div><div>Headcount</div><div>Budget cap</div><div>Review</div>
|
||||||
|
</div>
|
||||||
|
{hiringPolicies.map((policy, index) => (
|
||||||
|
<HiringPolicyRow
|
||||||
|
key={policy.id}
|
||||||
|
policy={policy}
|
||||||
|
department={orgDepartments.find((item) => item.name === policy.departmentName) ?? null}
|
||||||
|
agents={agents}
|
||||||
|
last={index === hiringPolicies.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section-label" style={{ margin: "24px 0 10px" }}>Review routing</div>
|
||||||
|
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||||
|
<div
|
||||||
|
className="table-row head"
|
||||||
|
style={{ gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr)" }}
|
||||||
|
>
|
||||||
|
<div>Kind</div><div>Min risk</div><div>Route</div><div>Assigned</div>
|
||||||
|
</div>
|
||||||
|
{approvalRoutingRules.map((rule, index) => (
|
||||||
|
<RoutingRuleRow
|
||||||
|
key={rule.id}
|
||||||
|
rule={rule}
|
||||||
|
employees={employees}
|
||||||
|
agents={agents}
|
||||||
|
assignmentCount={approvalAssignments.filter((assignment) => assignment.routingRuleId === rule.id).length}
|
||||||
|
last={index === approvalRoutingRules.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HiringPolicyRow({
|
||||||
|
policy,
|
||||||
|
department,
|
||||||
|
agents,
|
||||||
|
last,
|
||||||
|
}: {
|
||||||
|
policy: HiringPolicyRecord;
|
||||||
|
department: OrgDepartmentRecord | null;
|
||||||
|
agents: AgentRecord[];
|
||||||
|
last: boolean;
|
||||||
|
}) {
|
||||||
|
if (!department) throw new Error(`Department governance is missing for ${policy.departmentName}.`);
|
||||||
|
const roster = agents.filter((agent) => agent.department === policy.departmentName && agent.lifecycle === "active");
|
||||||
|
const budgetCents = roster.reduce((sum, agent) => sum + agent.budgetCents, 0);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="table-row"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr)",
|
||||||
|
borderBottom: last ? "none" : "1px solid var(--border-soft)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 540 }}>{policy.departmentName}</div>
|
||||||
|
<div className="num mono">{roster.length} / {department.headcountLimit}</div>
|
||||||
|
<div className="num mono">{money(budgetCents)} / {money(policy.monthlyBudgetLimitCents)}</div>
|
||||||
|
<div><Pill>{money(policy.requireApprovalAboveCents)}</Pill></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoutingRuleRow({
|
||||||
|
rule,
|
||||||
|
employees,
|
||||||
|
agents,
|
||||||
|
assignmentCount,
|
||||||
|
last,
|
||||||
|
}: {
|
||||||
|
rule: ApprovalRoutingRuleRecord;
|
||||||
|
employees: EmployeeRecord[];
|
||||||
|
agents: AgentRecord[];
|
||||||
|
assignmentCount: number;
|
||||||
|
last: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="table-row"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr)",
|
||||||
|
borderBottom: last ? "none" : "1px solid var(--border-soft)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 540 }}>{rule.kind}</div>
|
||||||
|
<div><Pill tone={rule.minimumRisk === "high" ? "red" : rule.minimumRisk === "medium" ? "amber" : undefined}>{rule.minimumRisk}</Pill></div>
|
||||||
|
<div style={{ color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{routeLabel(rule, employees, agents)}
|
||||||
|
</div>
|
||||||
|
<div className="num mono">{assignmentCount}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeLabel(rule: ApprovalRoutingRuleRecord, employees: EmployeeRecord[], agents: AgentRecord[]): string {
|
||||||
|
if (rule.routeToType === "board") return "Board";
|
||||||
|
if (rule.routeToType === "employee") {
|
||||||
|
const employee = employees.find((item) => item.id === rule.routeToId);
|
||||||
|
if (!employee) throw new Error("Approval route employee is missing.");
|
||||||
|
return employee.displayName;
|
||||||
|
}
|
||||||
|
const agent = agents.find((item) => item.id === rule.routeToId);
|
||||||
|
if (!agent) throw new Error("Approval route agent is missing.");
|
||||||
|
return agent.name;
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { BoardInterventionRecord as BoardInterventionRecordModel, ChatThreadBoardState as ChatThreadBoardStateModel } from "./board-domain";
|
import type { BoardInterventionRecord as BoardInterventionRecordModel, ChatThreadBoardState as ChatThreadBoardStateModel } from "./board-domain";
|
||||||
import type { ComplianceExportRecord, LegalHoldRecord, RetentionFreezeRecord } from "./compliance-domain";
|
import type { ComplianceExportRecord, LegalHoldRecord, RetentionFreezeRecord } from "./compliance-domain";
|
||||||
import type { AccessDecisionRecord, EmployeeIdentityRecord, RoleGrantRecord, ServiceIdentityRecord } from "./identity-domain";
|
import type { AccessDecisionRecord, EmployeeIdentityRecord, RoleGrantRecord, ServiceIdentityRecord } from "./identity-domain";
|
||||||
|
import type { ApprovalAssignmentRecord, ApprovalRoutingRuleRecord, HiringPolicyRecord, OrgDepartmentRecord } from "./org-governance-domain";
|
||||||
import type { ProviderDataPolicy, ProviderExecutionMode } from "./provider-policy-domain";
|
import type { ProviderDataPolicy, ProviderExecutionMode } from "./provider-policy-domain";
|
||||||
export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain";
|
export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain";
|
||||||
export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type LegalHoldRecord, type RetentionFreezeRecord, type RetentionFreezeScopeType } from "./compliance-domain";
|
export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type LegalHoldRecord, type RetentionFreezeRecord, type RetentionFreezeScopeType } from "./compliance-domain";
|
||||||
export { ACCESS_DECISION, IDENTITY_SOURCE, ROLE_KEY, type AccessDecision, type AccessDecisionRecord, type EmployeeIdentityRecord, type IdentitySource, type PrincipalType, type RoleGrantRecord, type RoleKey, type ServiceIdentityRecord } from "./identity-domain";
|
export { ACCESS_DECISION, IDENTITY_SOURCE, ROLE_KEY, type AccessDecision, type AccessDecisionRecord, type EmployeeIdentityRecord, type IdentitySource, type PrincipalType, type RoleGrantRecord, type RoleKey, type ServiceIdentityRecord } from "./identity-domain";
|
||||||
|
export { ROUTE_TO_TYPE, type ApprovalAssignmentRecord, type ApprovalRoutingRuleRecord, type HiringPolicyRecord, type OrgDepartmentRecord, type RouteToType } from "./org-governance-domain";
|
||||||
export { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, type ProviderDataPolicy, type ProviderExecutionMode } from "./provider-policy-domain";
|
export { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, type ProviderDataPolicy, type ProviderExecutionMode } from "./provider-policy-domain";
|
||||||
export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain";
|
export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain";
|
||||||
export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REVIEW: "review", DONE: "done" } as const;
|
export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REVIEW: "review", DONE: "done" } as const;
|
||||||
@@ -468,5 +470,6 @@ export interface AppSnapshot {
|
|||||||
migrationReports: MigrationReportRecord[];
|
migrationReports: MigrationReportRecord[];
|
||||||
complianceExports: ComplianceExportRecord[]; legalHolds: LegalHoldRecord[]; retentionFreezes: RetentionFreezeRecord[];
|
complianceExports: ComplianceExportRecord[]; legalHolds: LegalHoldRecord[]; retentionFreezes: RetentionFreezeRecord[];
|
||||||
employeeIdentities: EmployeeIdentityRecord[]; serviceIdentities: ServiceIdentityRecord[]; roleGrants: RoleGrantRecord[]; accessDecisions: AccessDecisionRecord[];
|
employeeIdentities: EmployeeIdentityRecord[]; serviceIdentities: ServiceIdentityRecord[]; roleGrants: RoleGrantRecord[]; accessDecisions: AccessDecisionRecord[];
|
||||||
|
orgDepartments: OrgDepartmentRecord[]; hiringPolicies: HiringPolicyRecord[]; approvalRoutingRules: ApprovalRoutingRuleRecord[]; approvalAssignments: ApprovalAssignmentRecord[];
|
||||||
auditEvents: AuditEventRecord[];
|
auditEvents: AuditEventRecord[];
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
type ValueOf<T> = T[keyof T];
|
||||||
|
|
||||||
|
export const ROUTE_TO_TYPE = { EMPLOYEE: "employee", AGENT: "agent", BOARD: "board" } as const;
|
||||||
|
export type RouteToType = ValueOf<typeof ROUTE_TO_TYPE>;
|
||||||
|
|
||||||
|
export interface OrgDepartmentRecord {
|
||||||
|
id: string; orgId: string; name: string; leadAgentId: string | null;
|
||||||
|
headcountLimit: number; monthlyBudgetLimitCents: number; createdAt: string; updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HiringPolicyRecord {
|
||||||
|
id: string; orgId: string; departmentName: string; maxHeadcount: number;
|
||||||
|
monthlyBudgetLimitCents: number; requireApprovalAboveCents: number; createdAt: string; updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalRoutingRuleRecord {
|
||||||
|
id: string; orgId: string; kind: string; minimumRisk: string;
|
||||||
|
routeToType: RouteToType; routeToId: string; createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalAssignmentRecord {
|
||||||
|
id: string; orgId: string; approvalId: string; assigneeType: RouteToType;
|
||||||
|
assigneeId: string; routingRuleId: string | null; createdAt: string;
|
||||||
|
}
|
||||||
@@ -44,6 +44,9 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
|||||||
await expect(page.getByText("Identity & Access")).toBeVisible();
|
await expect(page.getByText("Identity & Access")).toBeVisible();
|
||||||
await expect(page.getByText("org admin").first()).toBeVisible();
|
await expect(page.getByText("org admin").first()).toBeVisible();
|
||||||
await expect(page.getByText("scoped agent").first()).toBeVisible();
|
await expect(page.getByText("scoped agent").first()).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Policies" }).click();
|
||||||
|
await expect(page.getByText("Hiring policies")).toBeVisible();
|
||||||
|
await expect(page.getByText("Review routing")).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Work", exact: true }).click();
|
await page.getByRole("button", { name: "Work", exact: true }).click();
|
||||||
await page.getByRole("button", { name: "New task" }).click();
|
await page.getByRole("button", { name: "New task" }).click();
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe("domain services", () => {
|
|||||||
const connection = createConnection();
|
const connection = createConnection();
|
||||||
|
|
||||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);
|
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
|
||||||
connection.close();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,6 +45,9 @@ describe("domain services", () => {
|
|||||||
expect(state.providers.length).toBeGreaterThanOrEqual(6);
|
expect(state.providers.length).toBeGreaterThanOrEqual(6);
|
||||||
expect(state.employeeIdentities).toHaveLength(1);
|
expect(state.employeeIdentities).toHaveLength(1);
|
||||||
expect(state.roleGrants.map((grant) => grant.roleKey).sort()).toEqual(["approval_decider", "hiring_manager", "org_admin"]);
|
expect(state.roleGrants.map((grant) => grant.roleKey).sort()).toEqual(["approval_decider", "hiring_manager", "org_admin"]);
|
||||||
|
expect(state.orgDepartments.map((department) => department.name)).toContain("Engineering");
|
||||||
|
expect(state.hiringPolicies.some((policy) => policy.departmentName === "Engineering")).toBe(true);
|
||||||
|
expect(state.approvalRoutingRules[0]).toMatchObject({ kind: "*", minimumRisk: "low", routeToType: "employee" });
|
||||||
expect(state.chatSpaces.map((space) => space.name)).toContain("board");
|
expect(state.chatSpaces.map((space) => space.name)).toContain("board");
|
||||||
expect(state.policyRules[0]?.action).toBe("task_create");
|
expect(state.policyRules[0]?.action).toBe("task_create");
|
||||||
expect(state.auditEvents[0]?.action).toBe("created");
|
expect(state.auditEvents[0]?.action).toBe("created");
|
||||||
@@ -82,6 +85,7 @@ describe("domain services", () => {
|
|||||||
const state = snapshot.load();
|
const state = snapshot.load();
|
||||||
expect(state.tasks).toHaveLength(1);
|
expect(state.tasks).toHaveLength(1);
|
||||||
expect(state.approvals).toHaveLength(1);
|
expect(state.approvals).toHaveLength(1);
|
||||||
|
expect(state.approvalAssignments).toHaveLength(1);
|
||||||
expect(state.approvals[0]?.state).toBe("pending");
|
expect(state.approvals[0]?.state).toBe("pending");
|
||||||
connection.close();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ describe("migration service", () => {
|
|||||||
const snapshot = new SnapshotService(connection.db);
|
const snapshot = new SnapshotService(connection.db);
|
||||||
const legacyPath = createLegacyDatabase(`
|
const legacyPath = createLegacyDatabase(`
|
||||||
create table organizations(id text primary key);
|
create table organizations(id text primary key);
|
||||||
|
create table org_departments(id text primary key, org_id text, lead_agent_id text);
|
||||||
|
create table hiring_policies(id text primary key, org_id text);
|
||||||
create table employees(id text primary key, org_id text);
|
create table employees(id text primary key, org_id text);
|
||||||
create table employee_identities(id text primary key, org_id text, employee_id text);
|
create table employee_identities(id text primary key, org_id text, employee_id text);
|
||||||
create table service_identities(id text primary key, org_id text, agent_id text);
|
create table service_identities(id text primary key, org_id text, agent_id text);
|
||||||
@@ -104,6 +106,8 @@ describe("migration service", () => {
|
|||||||
create table projects(id text primary key, org_id text, goal_id text, lead_agent_id text);
|
create table projects(id text primary key, org_id text, goal_id text, lead_agent_id text);
|
||||||
create table task_runs(id text primary key, org_id text, task_id text, agent_id text, provider_id text);
|
create table task_runs(id text primary key, org_id text, task_id text, agent_id text, provider_id text);
|
||||||
create table approvals(id text primary key, org_id text, decided_by_employee_id text);
|
create table approvals(id text primary key, org_id text, decided_by_employee_id text);
|
||||||
|
create table approval_routing_rules(id text primary key, org_id text);
|
||||||
|
create table approval_assignments(id text primary key, org_id text, approval_id text, routing_rule_id text);
|
||||||
create table chat_spaces(id text primary key, org_id text);
|
create table chat_spaces(id text primary key, org_id text);
|
||||||
create table chat_messages(id text primary key, org_id text, space_id text, thread_id text);
|
create table chat_messages(id text primary key, org_id text, space_id text, thread_id text);
|
||||||
create table chat_threads(id text primary key, org_id text, space_id text, root_message_id text);
|
create table chat_threads(id text primary key, org_id text, space_id text, root_message_id text);
|
||||||
@@ -147,6 +151,8 @@ describe("migration service", () => {
|
|||||||
insert into file_objects(id, org_id, space_id) values ('file-1', 'org-1', 'space-1');
|
insert into file_objects(id, org_id, space_id) values ('file-1', 'org-1', 'space-1');
|
||||||
insert into workspaces(id, org_id, project_id) values ('wsp-1', 'org-1', 'prj-1');
|
insert into workspaces(id, org_id, project_id) values ('wsp-1', 'org-1', 'prj-1');
|
||||||
insert into agents(id, org_id, model_provider_id, manager_agent_id, workspace_id) values ('agent-1', 'org-1', 'prov-1', null, 'wsp-1');
|
insert into agents(id, org_id, model_provider_id, manager_agent_id, workspace_id) values ('agent-1', 'org-1', 'prov-1', null, 'wsp-1');
|
||||||
|
insert into org_departments(id, org_id, lead_agent_id) values ('dep-1', 'org-1', 'agent-1');
|
||||||
|
insert into hiring_policies(id, org_id) values ('hpl-1', 'org-1');
|
||||||
insert into service_identities(id, org_id, agent_id) values ('sid-1', 'org-1', 'agent-1');
|
insert into service_identities(id, org_id, agent_id) values ('sid-1', 'org-1', 'agent-1');
|
||||||
insert into role_grants(id, org_id, granted_by_employee_id) values ('role-1', 'org-1', 'emp-1');
|
insert into role_grants(id, org_id, granted_by_employee_id) values ('role-1', 'org-1', 'emp-1');
|
||||||
insert into access_decisions(id, org_id) values ('access-1', 'org-1');
|
insert into access_decisions(id, org_id) values ('access-1', 'org-1');
|
||||||
@@ -155,6 +161,8 @@ describe("migration service", () => {
|
|||||||
insert into tasks(id, org_id, assignee_agent_id, project_id, goal_id, workspace_id) values ('task-1', 'org-1', 'agent-1', 'prj-1', 'goal-1', 'wsp-1');
|
insert into tasks(id, org_id, assignee_agent_id, project_id, goal_id, workspace_id) values ('task-1', 'org-1', 'agent-1', 'prj-1', 'goal-1', 'wsp-1');
|
||||||
insert into task_runs(id, org_id, task_id, agent_id, provider_id) values ('run-1', 'org-1', 'task-1', 'agent-1', 'prov-1');
|
insert into task_runs(id, org_id, task_id, agent_id, provider_id) values ('run-1', 'org-1', 'task-1', 'agent-1', 'prov-1');
|
||||||
insert into approvals(id, org_id, decided_by_employee_id) values ('approval-1', 'org-1', 'emp-1');
|
insert into approvals(id, org_id, decided_by_employee_id) values ('approval-1', 'org-1', 'emp-1');
|
||||||
|
insert into approval_routing_rules(id, org_id) values ('route-1', 'org-1');
|
||||||
|
insert into approval_assignments(id, org_id, approval_id, routing_rule_id) values ('assign-1', 'org-1', 'approval-1', 'route-1');
|
||||||
insert into chat_threads(id, org_id, space_id, root_message_id) values ('thread-1', 'org-1', 'space-1', null);
|
insert into chat_threads(id, org_id, space_id, root_message_id) values ('thread-1', 'org-1', 'space-1', null);
|
||||||
insert into chat_messages(id, org_id, space_id, thread_id) values ('msg-1', 'org-1', 'space-1', 'thread-1');
|
insert into chat_messages(id, org_id, space_id, thread_id) values ('msg-1', 'org-1', 'space-1', 'thread-1');
|
||||||
insert into chat_memberships(space_id, principal_type, principal_id) values ('space-1', 'employee', 'emp-1');
|
insert into chat_memberships(space_id, principal_type, principal_id) values ('space-1', 'employee', 'emp-1');
|
||||||
@@ -197,6 +205,8 @@ describe("migration service", () => {
|
|||||||
expect(body.failures).toEqual([]);
|
expect(body.failures).toEqual([]);
|
||||||
expect(body.tableCounts.credentials).toBe(1);
|
expect(body.tableCounts.credentials).toBe(1);
|
||||||
expect(body.tableCounts.employee_identities).toBe(1);
|
expect(body.tableCounts.employee_identities).toBe(1);
|
||||||
|
expect(body.tableCounts.org_departments).toBe(1);
|
||||||
|
expect(body.tableCounts.hiring_policies).toBe(1);
|
||||||
expect(body.tableCounts.role_grants).toBe(1);
|
expect(body.tableCounts.role_grants).toBe(1);
|
||||||
expect(body.tableCounts.skill_artifacts).toBe(1);
|
expect(body.tableCounts.skill_artifacts).toBe(1);
|
||||||
expect(body.tableCounts.chat_read_state).toBe(1);
|
expect(body.tableCounts.chat_read_state).toBe(1);
|
||||||
@@ -210,6 +220,7 @@ describe("migration service", () => {
|
|||||||
expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]);
|
expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]);
|
||||||
expect(body.idMaps.service_identities).toEqual(["sid-1"]);
|
expect(body.idMaps.service_identities).toEqual(["sid-1"]);
|
||||||
expect(body.idMaps.access_decisions).toEqual(["access-1"]);
|
expect(body.idMaps.access_decisions).toEqual(["access-1"]);
|
||||||
|
expect(body.idMaps.approval_assignments).toEqual(["assign-1"]);
|
||||||
expect(body.idMaps.board_interventions).toEqual(["bdi-1"]);
|
expect(body.idMaps.board_interventions).toEqual(["bdi-1"]);
|
||||||
expect(body.idMaps.retention_freezes).toEqual(["freeze-1"]);
|
expect(body.idMaps.retention_freezes).toEqual(["freeze-1"]);
|
||||||
expect(body.idMaps.provider_usage_windows).toEqual(["usage-1"]);
|
expect(body.idMaps.provider_usage_windows).toEqual(["usage-1"]);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||||
|
import { ApprovalService } from "../../src/main/services/approval-service";
|
||||||
|
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||||
|
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
||||||
|
import { RISK_LEVEL } from "../../src/shared/domain";
|
||||||
|
|
||||||
|
function createConnection(): DatabaseConnection {
|
||||||
|
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-governance-${randomUUID()}.db`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOrganization(organization: OrganizationService): string {
|
||||||
|
return organization.createOrganization({
|
||||||
|
organizationName: "Acme Operations",
|
||||||
|
employeeName: "Dana Lee",
|
||||||
|
mission: "Coordinate agents with auditable approvals.",
|
||||||
|
goal: "Ship a verified control plane.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("org governance service", () => {
|
||||||
|
test("enforces department hiring budget policy before creating agents", () => {
|
||||||
|
const connection = createConnection();
|
||||||
|
const organization = new OrganizationService(connection.db);
|
||||||
|
let orgId = "";
|
||||||
|
|
||||||
|
connection.transaction(() => {
|
||||||
|
orgId = createOrganization(organization);
|
||||||
|
connection.db.prepare(`
|
||||||
|
update hiring_policies
|
||||||
|
set monthly_budget_limit_cents = ?
|
||||||
|
where org_id = ? and department_name = ?
|
||||||
|
`).run(10000, orgId, "Engineering");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => connection.transaction(() => organization.createAgent({
|
||||||
|
orgId,
|
||||||
|
name: "Ada",
|
||||||
|
role: "Reviewer",
|
||||||
|
department: "Engineering",
|
||||||
|
modelProviderId: null,
|
||||||
|
modelId: null,
|
||||||
|
budgetCents: 10001,
|
||||||
|
managerAgentId: null,
|
||||||
|
}))).toThrow("Department monthly budget limit has been reached.");
|
||||||
|
expect(connection.db.prepare("select count(*) as count from agents where org_id = ?").get(orgId)).toEqual({ count: 0 });
|
||||||
|
connection.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("routes created approvals to the configured review assignee", () => {
|
||||||
|
const connection = createConnection();
|
||||||
|
const organization = new OrganizationService(connection.db);
|
||||||
|
const approvals = new ApprovalService(connection.db);
|
||||||
|
const snapshot = new SnapshotService(connection.db);
|
||||||
|
let orgId = "";
|
||||||
|
let approvalId = "";
|
||||||
|
|
||||||
|
connection.transaction(() => {
|
||||||
|
orgId = createOrganization(organization);
|
||||||
|
approvalId = approvals.create({
|
||||||
|
orgId,
|
||||||
|
kind: "release",
|
||||||
|
title: "Release approval",
|
||||||
|
details: "Approve release train.",
|
||||||
|
risk: RISK_LEVEL.MEDIUM,
|
||||||
|
requestedByType: "system",
|
||||||
|
requestedById: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = snapshot.load();
|
||||||
|
expect(state.approvalAssignments).toHaveLength(1);
|
||||||
|
expect(state.approvalAssignments[0]).toMatchObject({ approvalId, assigneeType: "employee" });
|
||||||
|
expect(state.auditEvents.some((event) => event.entityType === "approval" && event.action === "routed")).toBe(true);
|
||||||
|
connection.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("routes high budget hires to the configured review assignee", () => {
|
||||||
|
const connection = createConnection();
|
||||||
|
const organization = new OrganizationService(connection.db);
|
||||||
|
const snapshot = new SnapshotService(connection.db);
|
||||||
|
let orgId = "";
|
||||||
|
|
||||||
|
connection.transaction(() => {
|
||||||
|
orgId = createOrganization(organization);
|
||||||
|
organization.createAgent({
|
||||||
|
orgId,
|
||||||
|
name: "Ada",
|
||||||
|
role: "Reviewer",
|
||||||
|
department: "Engineering",
|
||||||
|
modelProviderId: null,
|
||||||
|
modelId: null,
|
||||||
|
budgetCents: 600000,
|
||||||
|
managerAgentId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = snapshot.load();
|
||||||
|
const approval = state.approvals.find((item) => item.kind === "agent_hire");
|
||||||
|
expect(approval).toMatchObject({ orgId, title: "Review hire: Ada", risk: RISK_LEVEL.MEDIUM });
|
||||||
|
expect(state.approvalAssignments).toHaveLength(1);
|
||||||
|
expect(state.approvalAssignments[0]).toMatchObject({ approvalId: approval?.id, assigneeType: "employee" });
|
||||||
|
connection.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user