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.
182 lines
8.1 KiB
TypeScript
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());
|
|
}
|
|
}
|