Files
AgentCompany/src/main/services/identity-access-service.ts
T
ZacharyZhang-NY 07d0b4587d Promote AgentCompany-New to the repository root and retire the old app
The legacy application - its src, tests, landing site, promo assets, docs,
release tooling, CI workflows, and root configs - leaves the tree; it stays
reachable in history. Everything under AgentCompany-New moves up unchanged:
same package name, so the production userData/database path is untouched,
and e2e/build scripts already resolved paths relative to the project. The
developer cred.txt stays gitignored at its new location. Full verify:ci
passes from the new root.
2026-07-07 00:04:13 -04:00

182 lines
8.1 KiB
TypeScript

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());
}
}