Add identity access controls

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 17:58:42 -04:00
1 parent 276929b855
commit 1d7757c88b
21 files changed
+607 -8

No files matched your search

@@ -1,8 +1,9 @@
import type { DatabaseSync } from "node:sqlite";
import type { CreateApprovalInput, DecideApprovalInput } from "../../shared/commands";
import { APPROVAL_STATE } from "../../shared/domain";
import { APPROVAL_STATE, ROLE_KEY } from "../../shared/domain";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
import { IdentityAccessService } from "./identity-access-service";
interface ApprovalAuditContext {
sourceMessageId?: string;
@@ -11,9 +12,11 @@ interface ApprovalAuditContext {
export class ApprovalService {
private readonly audit: AuditService;
private readonly identityAccess: IdentityAccessService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
this.identityAccess = new IdentityAccessService(db);
}
create(input: CreateApprovalInput, context: ApprovalAuditContext = {}): string {
@@ -58,7 +61,15 @@ export class ApprovalService {
}
decide(input: DecideApprovalInput): void {
this.assertEmployee(input.orgId, input.decidedByEmployeeId, "Deciding employee was not found.");
this.identityAccess.assertEmployeeRole(
input.orgId,
input.decidedByEmployeeId,
ROLE_KEY.APPROVAL_DECIDER,
"approval_decide",
"approval",
input.approvalId,
"Deciding employee was not found.",
);
const now = new Date().toISOString();
const result = this.db.prepare(`
update approvals
@@ -0,0 +1,181 @@
import type { DatabaseSync } from "node:sqlite";
import type { GrantRoleInput, SyncEmployeeIdentityInput } from "../../shared/commands";
import { ACCESS_DECISION, IDENTITY_SOURCE, ROLE_KEY, type PrincipalType, type RoleKey } from "../../shared/domain";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
const ROLE_KEYS = new Set(Object.values(ROLE_KEY));
const IDENTITY_SOURCES = new Set(Object.values(IDENTITY_SOURCE));
const SCOPE_TYPES = new Set(["organization", "workspace", "project", "department"]);
export class IdentityAccessService {
private readonly audit: AuditService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
}
seedEmployeeAccess(orgId: string, employeeId: string, now: string): void {
this.db.prepare(`
insert into employee_identities(id, org_id, employee_id, source, external_subject, email, active, created_at, last_seen_at)
values (?, ?, ?, ?, ?, null, 1, ?, ?)
on conflict(org_id, source, external_subject) do nothing
`).run(createId("eid"), orgId, employeeId, IDENTITY_SOURCE.BOOTSTRAP, employeeId, now, now);
for (const roleKey of [ROLE_KEY.ORG_ADMIN, ROLE_KEY.APPROVAL_DECIDER, ROLE_KEY.HIRING_MANAGER]) {
this.insertRoleGrant({
orgId,
principalType: "employee",
principalId: employeeId,
roleKey,
scopeType: "organization",
scopeId: orgId,
grantedByEmployeeId: null,
now,
});
}
}
createAgentServiceIdentity(orgId: string, agentId: string, name: string, now: string): void {
const serviceIdentityId = createId("sid");
this.db.prepare(`
insert into service_identities(id, org_id, agent_id, name, scope_json, created_at)
values (?, ?, ?, ?, ?, ?)
on conflict(org_id, agent_id) do nothing
`).run(serviceIdentityId, orgId, agentId, `${name.trim()} service identity`, JSON.stringify({ agentId }), now);
const existing = this.db.prepare("select id from service_identities where org_id = ? and agent_id = ?")
.get(orgId, agentId) as { id: string } | undefined;
if (!existing) throw new Error("Agent service identity was not created.");
this.insertRoleGrant({
orgId,
principalType: "service_identity",
principalId: existing.id,
roleKey: ROLE_KEY.SCOPED_AGENT,
scopeType: "organization",
scopeId: orgId,
grantedByEmployeeId: null,
now,
});
}
syncEmployeeIdentity(input: SyncEmployeeIdentityInput): void {
this.assertEmployee(input.orgId, input.employeeId, "Employee was not found.");
if (!IDENTITY_SOURCES.has(input.source)) throw new Error("Identity source is invalid.");
const externalSubject = input.externalSubject.trim();
if (!externalSubject) throw new Error("External subject is required.");
const email = input.email?.trim() || null;
const now = new Date().toISOString();
this.db.prepare(`
insert into employee_identities(id, org_id, employee_id, source, external_subject, email, active, created_at, last_seen_at)
values (?, ?, ?, ?, ?, ?, 1, ?, ?)
on conflict(org_id, source, external_subject) do update set
employee_id = excluded.employee_id,
email = excluded.email,
active = 1,
last_seen_at = excluded.last_seen_at
`).run(createId("eid"), input.orgId, input.employeeId, input.source, externalSubject, email, now, now);
this.audit.record({
orgId: input.orgId,
entityType: "employee_identity",
entityId: input.employeeId,
actorType: "system",
actorId: null,
action: "synced",
payload: { source: input.source, externalSubject, email },
});
}
grantRole(input: GrantRoleInput): void {
this.assertEmployee(input.orgId, input.grantedByEmployeeId, "Granting employee was not found.");
this.assertEmployeeRole(input.orgId, input.grantedByEmployeeId, ROLE_KEY.ORG_ADMIN, "role_grant", input.scopeType, input.scopeId);
if (!ROLE_KEYS.has(input.roleKey)) throw new Error("Role key is invalid.");
if (!SCOPE_TYPES.has(input.scopeType)) throw new Error("Role scope type is invalid.");
this.assertPrincipal(input.orgId, input.principalType, input.principalId);
const now = new Date().toISOString();
const changes = this.insertRoleGrant({ ...input, now });
if (changes > 0) {
this.audit.record({
orgId: input.orgId,
entityType: "role_grant",
entityId: input.principalId,
actorType: "employee",
actorId: input.grantedByEmployeeId,
action: "granted",
payload: { principalType: input.principalType, roleKey: input.roleKey, scopeType: input.scopeType, scopeId: input.scopeId },
});
}
}
assertEmployeeRole(
orgId: string,
employeeId: string,
roleKey: RoleKey,
action: string,
resourceType: string,
resourceId: string,
employeeMissingMessage = "Employee was not found.",
): void {
this.assertEmployee(orgId, employeeId, employeeMissingMessage);
const allowed = this.hasRole(orgId, "employee", employeeId, roleKey) || this.hasRole(orgId, "employee", employeeId, ROLE_KEY.ORG_ADMIN);
this.recordDecision(orgId, "employee", employeeId, action, resourceType, resourceId, allowed, allowed ? roleKey : "missing_role");
if (!allowed) throw new Error(`Employee requires ${roleKey} role.`);
}
private hasRole(orgId: string, principalType: PrincipalType, principalId: string, roleKey: RoleKey): boolean {
const row = this.db.prepare(`
select id from role_grants
where org_id = ? and principal_type = ? and principal_id = ? and role_key = ?
limit 1
`).get(orgId, principalType, principalId, roleKey);
return Boolean(row);
}
private insertRoleGrant(input: {
orgId: string; principalType: PrincipalType; principalId: string; roleKey: RoleKey;
scopeType: string; scopeId: string; grantedByEmployeeId: string | null; now: string;
}): number {
const result = this.db.prepare(`
insert into role_grants(id, org_id, principal_type, principal_id, role_key, scope_type, scope_id, granted_by_employee_id, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
on conflict(org_id, principal_type, principal_id, role_key, scope_type, scope_id) do nothing
`).run(createId("rgr"), input.orgId, input.principalType, input.principalId, input.roleKey, input.scopeType, input.scopeId, input.grantedByEmployeeId, input.now);
return Number(result.changes);
}
private assertPrincipal(orgId: string, principalType: PrincipalType, principalId: string): void {
if (principalType === "employee") this.assertEmployee(orgId, principalId, "Employee was not found.");
else if (principalType === "agent") this.assertAgent(orgId, principalId);
else if (principalType === "service_identity") this.assertServiceIdentity(orgId, principalId);
else throw new Error("Principal type is invalid.");
}
private assertEmployee(orgId: string, employeeId: string, message: string): void {
const row = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId);
if (!row) throw new Error(message);
}
private assertAgent(orgId: string, agentId: string): void {
const row = this.db.prepare("select id from agents where id = ? and org_id = ?").get(agentId, orgId);
if (!row) throw new Error("Agent was not found.");
}
private assertServiceIdentity(orgId: string, serviceIdentityId: string): void {
const row = this.db.prepare("select id from service_identities where id = ? and org_id = ?").get(serviceIdentityId, orgId);
if (!row) throw new Error("Service identity was not found.");
}
private recordDecision(
orgId: string,
principalType: PrincipalType,
principalId: string,
action: string,
resourceType: string,
resourceId: string,
allowed: boolean,
reason: string,
): void {
this.db.prepare(`
insert into access_decisions(id, org_id, principal_type, principal_id, action, resource_type, resource_id, decision, reason, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(createId("acd"), orgId, principalType, principalId, action, resourceType, resourceId, allowed ? ACCESS_DECISION.ALLOW : ACCESS_DECISION.DENY, reason, new Date().toISOString());
}
}
@@ -9,6 +9,10 @@ import { AuditService } from "./audit-service";
const LEGACY_TABLES = [
"organizations",
"employees",
"employee_identities",
"service_identities",
"role_grants",
"access_decisions",
"agents",
"tasks",
"workspaces",
@@ -85,6 +89,13 @@ interface MigrationReportBody {
const RELATION_CHECKS: RelationCheck[] = [
{ 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" },
{ table: "service_identities", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "service_identities", column: "agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_agent_id" },
{ table: "role_grants", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "role_grants", column: "granted_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_grantor_employee_id" },
{ table: "access_decisions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "agents", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "agents", column: "model_provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_model_provider_id" },
{ table: "agents", column: "manager_agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_manager_agent_id" },
@@ -5,15 +5,18 @@ import { createId } from "../../shared/ids";
import type { CreateAgentInput, CreateOrganizationInput, CreateTaskInput } from "../../shared/commands";
import { AuditService } from "./audit-service";
import { PolicyService } from "./policy-service";
import { IdentityAccessService } from "./identity-access-service";
import { assertUsableMentionName } from "./mention-handles";
export class OrganizationService {
private readonly audit: AuditService;
private readonly policy: PolicyService;
private readonly identityAccess: IdentityAccessService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
this.policy = new PolicyService(db);
this.identityAccess = new IdentityAccessService(db);
}
createOrganization(input: CreateOrganizationInput): string {
@@ -40,6 +43,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);
for (const space of [
["company", "general", "Company coordination", "workspace"],
@@ -167,6 +171,7 @@ export class OrganizationService {
payload: { role: input.role, department: input.department },
});
this.addAgentToWorkspaceSpaces(input.orgId, id);
this.identityAccess.createAgentServiceIdentity(input.orgId, id, input.name, now);
return id;
}
@@ -51,6 +51,8 @@ export class SnapshotService {
.map((hold) => ({ ...hold, active: toBool(hold.active) }));
const retentionFreezes = list<AppSnapshot["retentionFreezes"][number]>(this.db, "retention_freezes", "created_at desc")
.map((freeze) => ({ ...freeze, active: toBool(freeze.active) }));
const employeeIdentities = list<AppSnapshot["employeeIdentities"][number]>(this.db, "employee_identities", "created_at desc")
.map((identity) => ({ ...identity, active: toBool(identity.active) }));
return {
activeOrgId,
@@ -90,6 +92,10 @@ export class SnapshotService {
complianceExports: list(this.db, "compliance_exports", "created_at desc"),
legalHolds,
retentionFreezes,
employeeIdentities,
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),
auditEvents: list<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc").slice(0, 200),
};
}