Add policy governed task approvals
This commit is contained in:
1 parent
0e920b409a
commit
de63df7daf
4 files changed
+71
-2
No files matched your search
@@ -357,6 +357,21 @@ export function installSchema(db: DatabaseSync): void {
|
|||||||
|
|
||||||
create index if not exists idx_provider_requests_org_created on provider_requests(org_id, created_at);
|
create index if not exists idx_provider_requests_org_created on provider_requests(org_id, created_at);
|
||||||
create index if not exists idx_chat_threads_space_updated on chat_threads(space_id, updated_at);
|
create index if not exists idx_chat_threads_space_updated on chat_threads(space_id, updated_at);
|
||||||
|
insert into policy_rules(id, org_id, subject_type, subject_id, action, effect, risk_ceiling, created_at)
|
||||||
|
select
|
||||||
|
'pol_' || lower(hex(randomblob(16))),
|
||||||
|
id,
|
||||||
|
'organization',
|
||||||
|
id,
|
||||||
|
'task_create',
|
||||||
|
'require_approval',
|
||||||
|
'medium',
|
||||||
|
datetime('now')
|
||||||
|
from organizations
|
||||||
|
where not exists (
|
||||||
|
select 1 from policy_rules
|
||||||
|
where policy_rules.org_id = organizations.id and policy_rules.action = 'task_create'
|
||||||
|
);
|
||||||
insert into schema_migrations(version, name, applied_at)
|
insert into schema_migrations(version, name, applied_at)
|
||||||
values (2, 'collaboration_provider_policy_schema', datetime('now'));
|
values (2, 'collaboration_provider_policy_schema', datetime('now'));
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
|||||||
import { createId } from "../../shared/ids";
|
import { createId } from "../../shared/ids";
|
||||||
import type { CreateAgentInput, CreateOrganizationInput, CreateTaskInput } from "../../shared/commands";
|
import type { CreateAgentInput, CreateOrganizationInput, CreateTaskInput } from "../../shared/commands";
|
||||||
import { AuditService } from "./audit-service";
|
import { AuditService } from "./audit-service";
|
||||||
|
import { PolicyService } from "./policy-service";
|
||||||
|
|
||||||
export class OrganizationService {
|
export class OrganizationService {
|
||||||
private readonly audit: AuditService;
|
private readonly audit: AuditService;
|
||||||
|
private readonly policy: PolicyService;
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
createOrganization(input: CreateOrganizationInput): string {
|
createOrganization(input: CreateOrganizationInput): string {
|
||||||
@@ -56,6 +59,7 @@ export class OrganizationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.policy.seedOrganizationPolicies(orgId, now);
|
||||||
this.audit.record({
|
this.audit.record({
|
||||||
orgId,
|
orgId,
|
||||||
entityType: "organization",
|
entityType: "organization",
|
||||||
@@ -118,7 +122,8 @@ export class OrganizationService {
|
|||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
if (input.risk === RISK_LEVEL.HIGH) {
|
const policy = this.policy.evaluateTaskCreation(input.orgId, input.risk);
|
||||||
|
if (policy.approvalRequired) {
|
||||||
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,
|
||||||
@@ -146,7 +151,7 @@ export class OrganizationService {
|
|||||||
actorType: "system",
|
actorType: "system",
|
||||||
actorId: null,
|
actorId: null,
|
||||||
action: "created",
|
action: "created",
|
||||||
payload: { status: TASK_STATUS.BACKLOG },
|
payload: { status: TASK_STATUS.BACKLOG, policyRuleId: policy.matchedRuleId },
|
||||||
});
|
});
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import type { RiskLevel } from "../../shared/domain";
|
||||||
|
import { RISK_LEVEL } from "../../shared/domain";
|
||||||
|
import { createId } from "../../shared/ids";
|
||||||
|
|
||||||
|
const RISK_RANK: Record<RiskLevel, number> = {
|
||||||
|
[RISK_LEVEL.LOW]: 0,
|
||||||
|
[RISK_LEVEL.MEDIUM]: 1,
|
||||||
|
[RISK_LEVEL.HIGH]: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PolicyDecision {
|
||||||
|
approvalRequired: boolean;
|
||||||
|
matchedRuleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PolicyService {
|
||||||
|
constructor(private readonly db: DatabaseSync) {}
|
||||||
|
|
||||||
|
seedOrganizationPolicies(orgId: string, now: string): void {
|
||||||
|
this.db.prepare(`
|
||||||
|
insert into policy_rules(id, org_id, subject_type, subject_id, action, effect, risk_ceiling, created_at)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(createId("pol"), orgId, "organization", orgId, "task_create", "require_approval", RISK_LEVEL.MEDIUM, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluateTaskCreation(orgId: string, risk: RiskLevel): PolicyDecision {
|
||||||
|
const rule = this.db.prepare(`
|
||||||
|
select id, effect, risk_ceiling
|
||||||
|
from policy_rules
|
||||||
|
where org_id = ? and action = ?
|
||||||
|
order by created_at asc
|
||||||
|
limit 1
|
||||||
|
`).get(orgId, "task_create") as PolicyRuleRow | undefined;
|
||||||
|
if (!rule) throw new Error("Policy rule is required for task creation.");
|
||||||
|
if (rule.effect !== "require_approval") throw new Error(`Unsupported policy effect: ${rule.effect}`);
|
||||||
|
return {
|
||||||
|
approvalRequired: RISK_RANK[risk] > RISK_RANK[rule.risk_ceiling],
|
||||||
|
matchedRuleId: rule.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PolicyRuleRow {
|
||||||
|
id: string;
|
||||||
|
effect: string;
|
||||||
|
risk_ceiling: RiskLevel;
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ describe("domain services", () => {
|
|||||||
expect(state.employees[0]?.displayName).toBe("Dana Lee");
|
expect(state.employees[0]?.displayName).toBe("Dana Lee");
|
||||||
expect(state.providers.length).toBeGreaterThanOrEqual(6);
|
expect(state.providers.length).toBeGreaterThanOrEqual(6);
|
||||||
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.auditEvents[0]?.action).toBe("created");
|
expect(state.auditEvents[0]?.action).toBe("created");
|
||||||
connection.close();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user