From 1d7757c88b8e23ed9c31fdff6e2e5a5674854eb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 23 May 2026 17:58:42 -0400 Subject: [PATCH] Add identity access controls --- AgentCompany-New/src/main/app/ipc-handlers.ts | 4 + .../migrations/015-identity-access.ts | 90 +++++++++ AgentCompany-New/src/main/database/schema.ts | 2 + .../src/main/services/approval-service.ts | 15 +- .../main/services/identity-access-service.ts | 181 ++++++++++++++++++ .../src/main/services/migration-service.ts | 11 ++ .../src/main/services/organization-service.ts | 5 + .../src/main/services/snapshot-service.ts | 6 + AgentCompany-New/src/preload/index.ts | 2 + .../src/renderer/src/pages/SettingsPage.tsx | 13 +- .../src/pages/settings/MembersPanel.tsx | 95 ++++++++- AgentCompany-New/src/shared/commands.ts | 21 ++ AgentCompany-New/src/shared/desktop-api.ts | 4 + AgentCompany-New/src/shared/domain.ts | 3 + .../src/shared/identity-domain.ts | 34 ++++ AgentCompany-New/src/shared/ipc.ts | 2 + AgentCompany-New/tests/e2e/desktop.spec.ts | 9 +- .../tests/e2e/screenshots.spec.ts | 2 +- .../tests/unit/domain-services.test.ts | 4 +- .../unit/identity-access-service.test.ts | 100 ++++++++++ .../tests/unit/migration-service.test.ts | 12 ++ 21 files changed, 607 insertions(+), 8 deletions(-) create mode 100644 AgentCompany-New/src/main/database/migrations/015-identity-access.ts create mode 100644 AgentCompany-New/src/main/services/identity-access-service.ts create mode 100644 AgentCompany-New/src/shared/identity-domain.ts create mode 100644 AgentCompany-New/tests/unit/identity-access-service.test.ts diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index d3573000..b2a57dad 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -15,6 +15,7 @@ import { MigrationService } from "../services/migration-service"; import { OnboardingService } from "../services/onboarding-service"; import { ComplianceService } from "../services/compliance-service"; import { SearchService } from "../services/search-service"; +import { IdentityAccessService } from "../services/identity-access-service"; export interface IpcDeps { connection: DatabaseConnection; @@ -34,6 +35,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { const migrations = new MigrationService(deps.connection.db); const compliance = new ComplianceService(deps.connection.db); const search = new SearchService(deps.connection.db); + const identityAccess = new IdentityAccessService(deps.connection.db); const onboarding = new OnboardingService({ db: deps.connection.db, saveProviderCredential: (input) => providers.saveCredential(input), @@ -78,6 +80,8 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.updateTaskStatus, (_event, input) => mutate(() => organization.updateTaskStatus(input.orgId, input.taskId, input.status))); ipcMain.handle(IPC_CHANNELS.createApproval, (_event, input) => mutate(() => approvals.create(input))); ipcMain.handle(IPC_CHANNELS.decideApproval, (_event, input) => mutate(() => approvals.decide(input))); + ipcMain.handle(IPC_CHANNELS.syncEmployeeIdentity, (_event, input) => mutate(() => identityAccess.syncEmployeeIdentity(input))); + ipcMain.handle(IPC_CHANNELS.grantRole, (_event, input) => mutate(() => identityAccess.grantRole(input))); ipcMain.handle(IPC_CHANNELS.createChatSpace, (_event, input) => mutate(() => chat.createSpace(input))); ipcMain.handle(IPC_CHANNELS.sendMessage, (_event, input) => mutate(() => chat.sendMessage(input))); ipcMain.handle(IPC_CHANNELS.markChatSpaceRead, (_event, input) => mutate(() => chat.markSpaceRead(input))); diff --git a/AgentCompany-New/src/main/database/migrations/015-identity-access.ts b/AgentCompany-New/src/main/database/migrations/015-identity-access.ts new file mode 100644 index 00000000..d1d1c63d --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/015-identity-access.ts @@ -0,0 +1,90 @@ +export const identityAccessSchema = ` + create table if not exists employee_identities ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + employee_id text not null references employees(id) on delete cascade, + source text not null, + external_subject text not null, + email text, + active integer not null, + created_at text not null, + last_seen_at text, + unique(org_id, source, external_subject) + ); + + create table if not exists service_identities ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + agent_id text not null references agents(id) on delete cascade, + name text not null, + scope_json text not null, + created_at text not null, + unique(org_id, agent_id) + ); + + create table if not exists role_grants ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + principal_type text not null, + principal_id text not null, + role_key text not null, + scope_type text not null, + scope_id text not null, + granted_by_employee_id text references employees(id) on delete set null, + created_at text not null, + unique(org_id, principal_type, principal_id, role_key, scope_type, scope_id) + ); + + create table if not exists access_decisions ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + principal_type text not null, + principal_id text not null, + action text not null, + resource_type text not null, + resource_id text not null, + decision text not null, + reason text not null, + created_at text not null + ); + + create index if not exists idx_role_grants_principal on role_grants(org_id, principal_type, principal_id); + create index if not exists idx_access_decisions_org_created on access_decisions(org_id, created_at); + + insert into employee_identities(id, org_id, employee_id, source, external_subject, email, active, created_at, last_seen_at) + select 'eid_' || lower(hex(randomblob(16))), org_id, id, 'bootstrap', id, null, 1, datetime('now'), datetime('now') + from employees + where not exists ( + select 1 from employee_identities + where employee_identities.org_id = employees.org_id and employee_identities.employee_id = employees.id + ); + + insert into role_grants(id, org_id, principal_type, principal_id, role_key, scope_type, scope_id, granted_by_employee_id, created_at) + select 'rgr_' || lower(hex(randomblob(16))), org_id, 'employee', id, seed_role_key, 'organization', org_id, null, datetime('now') + from employees + cross join ( + select 'org_admin' as seed_role_key + union all select 'approval_decider' + union all select 'hiring_manager' + ) + where not exists ( + select 1 from role_grants + where role_grants.org_id = employees.org_id + and role_grants.principal_type = 'employee' + and role_grants.principal_id = employees.id + and role_grants.role_key = seed_role_key + and role_grants.scope_type = 'organization' + and role_grants.scope_id = employees.org_id + ); + + insert into service_identities(id, org_id, agent_id, name, scope_json, created_at) + select 'sid_' || lower(hex(randomblob(16))), org_id, id, name || ' service identity', json_object('agentId', id), datetime('now') + from agents + where not exists ( + select 1 from service_identities + where service_identities.org_id = agents.org_id and service_identities.agent_id = agents.id + ); + + insert into schema_migrations(version, name, applied_at) + values (15, 'identity_access', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index 182abe1c..fa061e50 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -13,6 +13,7 @@ import { boardInterventionSchema } from "./migrations/011-board-interventions"; import { complianceControlsSchema } from "./migrations/012-compliance-controls"; import { searchDocumentsSchema } from "./migrations/013-search-documents"; import { providerPolicyUsageSchema } from "./migrations/014-provider-policy-usage"; +import { identityAccessSchema } from "./migrations/015-identity-access"; interface Migration { version: number; @@ -35,6 +36,7 @@ const MIGRATIONS: Migration[] = [ { version: 12, name: "compliance_controls", sql: complianceControlsSchema }, { version: 13, name: "search_documents", sql: searchDocumentsSchema }, { version: 14, name: "provider_policy_usage", sql: providerPolicyUsageSchema }, + { version: 15, name: "identity_access", sql: identityAccessSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/approval-service.ts b/AgentCompany-New/src/main/services/approval-service.ts index f211a942..d0d0fbae 100644 --- a/AgentCompany-New/src/main/services/approval-service.ts +++ b/AgentCompany-New/src/main/services/approval-service.ts @@ -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 diff --git a/AgentCompany-New/src/main/services/identity-access-service.ts b/AgentCompany-New/src/main/services/identity-access-service.ts new file mode 100644 index 00000000..04595346 --- /dev/null +++ b/AgentCompany-New/src/main/services/identity-access-service.ts @@ -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()); + } +} diff --git a/AgentCompany-New/src/main/services/migration-service.ts b/AgentCompany-New/src/main/services/migration-service.ts index b5e78d12..f5ce57a6 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -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" }, diff --git a/AgentCompany-New/src/main/services/organization-service.ts b/AgentCompany-New/src/main/services/organization-service.ts index 358aa327..6ec7e508 100644 --- a/AgentCompany-New/src/main/services/organization-service.ts +++ b/AgentCompany-New/src/main/services/organization-service.ts @@ -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; } diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index 912b9c7d..a4bb6370 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -51,6 +51,8 @@ export class SnapshotService { .map((hold) => ({ ...hold, active: toBool(hold.active) })); const retentionFreezes = list(this.db, "retention_freezes", "created_at desc") .map((freeze) => ({ ...freeze, active: toBool(freeze.active) })); + const employeeIdentities = list(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(this.db, "access_decisions", "created_at desc").slice(0, 200), auditEvents: list(this.db, "audit_events", "created_at desc").slice(0, 200), }; } diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index f5915069..d2ff349b 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -17,6 +17,8 @@ const api: DesktopApi = { updateTaskStatus: (input) => invoke(IPC_CHANNELS.updateTaskStatus, input), createApproval: (input) => invoke(IPC_CHANNELS.createApproval, input), decideApproval: (input) => invoke(IPC_CHANNELS.decideApproval, input), + syncEmployeeIdentity: (input) => invoke(IPC_CHANNELS.syncEmployeeIdentity, input), + grantRole: (input) => invoke(IPC_CHANNELS.grantRole, input), createChatSpace: (input) => invoke(IPC_CHANNELS.createChatSpace, input), sendMessage: (input) => invoke(IPC_CHANNELS.sendMessage, input), markChatSpaceRead: (input) => invoke(IPC_CHANNELS.markChatSpaceRead, input), diff --git a/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx b/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx index 308784aa..d427b80c 100644 --- a/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx @@ -76,6 +76,9 @@ export function SettingsPage({ snapshot, actions }: PageProps) { const complianceExports = useMemo(() => snapshot.complianceExports.filter((item) => item.orgId === orgId), [snapshot.complianceExports, orgId]); const legalHolds = useMemo(() => snapshot.legalHolds.filter((item) => item.orgId === orgId), [snapshot.legalHolds, orgId]); const retentionFreezes = useMemo(() => snapshot.retentionFreezes.filter((item) => item.orgId === orgId), [snapshot.retentionFreezes, 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 roleGrants = useMemo(() => snapshot.roleGrants.filter((item) => item.orgId === orgId), [snapshot.roleGrants, orgId]); return (
@@ -90,7 +93,15 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
{tab === "workspace" && } - {tab === "members" && } + {tab === "members" && ( + + )} {tab === "billing" && } {tab === "models" && } {tab === "providers" && ( diff --git a/AgentCompany-New/src/renderer/src/pages/settings/MembersPanel.tsx b/AgentCompany-New/src/renderer/src/pages/settings/MembersPanel.tsx index 54f6d073..aefcdcdd 100644 --- a/AgentCompany-New/src/renderer/src/pages/settings/MembersPanel.tsx +++ b/AgentCompany-New/src/renderer/src/pages/settings/MembersPanel.tsx @@ -1,13 +1,19 @@ import { Avatar, Pill } from "../../shared/ui"; -import type { AgentRecord, EmployeeRecord } from "../../../../shared/domain"; +import type { AgentRecord, EmployeeIdentityRecord, EmployeeRecord, RoleGrantRecord, ServiceIdentityRecord } from "../../../../shared/domain"; import { money } from "../types"; export function MembersPanel({ employees, agents, + employeeIdentities, + serviceIdentities, + roleGrants, }: { employees: EmployeeRecord[]; agents: AgentRecord[]; + employeeIdentities: EmployeeIdentityRecord[]; + serviceIdentities: ServiceIdentityRecord[]; + roleGrants: RoleGrantRecord[]; }) { const total = employees.length + agents.length; return ( @@ -63,6 +69,93 @@ export function MembersPanel({
))}
+
Identity & Access
+
+
+
Principal
Identity source
Roles
+
+ {employees.map((employee, index) => ( + + ))} + {agents.map((agent, index) => { + const serviceIdentity = serviceIdentities.find((identity) => identity.agentId === agent.id); + return ( + + ); + })} +
); } + +function AccessRow({ + name, + detail, + source, + roles, + bot, + last, +}: { + name: string; + detail: string; + source: string; + roles: string[]; + bot: boolean; + last: boolean; +}) { + return ( +
+
+ +
+
{name}
+
{detail}
+
+
+
{source}
+
+ {roles.map((role) => {role})} +
+
+ ); +} + +function identitySource(employeeId: string, identities: EmployeeIdentityRecord[]): string { + const identity = identities.find((item) => item.employeeId === employeeId && item.active); + if (!identity) return "Unlinked"; + if (identity.source === "sso") return "SSO"; + if (identity.source === "scim") return "SCIM"; + return "Bootstrap"; +} + +function principalRoles(principalType: RoleGrantRecord["principalType"], principalId: string, grants: RoleGrantRecord[]): string[] { + return grants + .filter((grant) => grant.principalType === principalType && grant.principalId === principalId) + .map((grant) => grant.roleKey.replaceAll("_", " ")) + .sort(); +} diff --git a/AgentCompany-New/src/shared/commands.ts b/AgentCompany-New/src/shared/commands.ts index 8bb7b9b8..94ff9e54 100644 --- a/AgentCompany-New/src/shared/commands.ts +++ b/AgentCompany-New/src/shared/commands.ts @@ -3,10 +3,13 @@ import type { ComplianceExportType, ComplianceSubjectType, LegalHoldEntityType, + IdentitySource, + PrincipalType, ProviderDataPolicy, ProviderExecutionMode, RetentionFreezeScopeType, RiskLevel, + RoleKey, SearchEntityType, SocialPlatform, TaskPriority, @@ -86,6 +89,24 @@ export interface DecideApprovalInput { decidedByEmployeeId: string; } +export interface SyncEmployeeIdentityInput { + orgId: string; + employeeId: string; + source: IdentitySource; + externalSubject: string; + email: string | null; +} + +export interface GrantRoleInput { + orgId: string; + principalType: PrincipalType; + principalId: string; + roleKey: RoleKey; + scopeType: "organization" | "workspace" | "project" | "department"; + scopeId: string; + grantedByEmployeeId: string; +} + export interface SendMessageInput { orgId: string; spaceId: string; diff --git a/AgentCompany-New/src/shared/desktop-api.ts b/AgentCompany-New/src/shared/desktop-api.ts index a92e795d..51bf9502 100644 --- a/AgentCompany-New/src/shared/desktop-api.ts +++ b/AgentCompany-New/src/shared/desktop-api.ts @@ -22,6 +22,7 @@ import type { DeleteOrganizationInput, ImportFileInput, ImportSkillDirectoryInput, + GrantRoleInput, LaunchProviderSignInInput, LaunchSocialLoginInput, ResolveAgentSkillsInput, @@ -33,6 +34,7 @@ import type { SetLegalHoldStateInput, SetRetentionFreezeStateInput, SetScheduledPostStateInput, + SyncEmployeeIdentityInput, UpdateProviderPolicyInput, UpdateTaskStatusInput, } from "./commands"; @@ -52,6 +54,8 @@ export interface DesktopApi { updateTaskStatus(input: UpdateTaskStatusInput): Promise>; createApproval(input: CreateApprovalInput): Promise>; decideApproval(input: DecideApprovalInput): Promise>; + syncEmployeeIdentity(input: SyncEmployeeIdentityInput): Promise>; + grantRole(input: GrantRoleInput): Promise>; createChatSpace(input: CreateChatSpaceInput): Promise>; sendMessage(input: SendMessageInput): Promise>; markChatSpaceRead(input: MarkChatSpaceReadInput): Promise>; diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index 385ccb15..65de59ef 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -1,8 +1,10 @@ import type { BoardInterventionRecord as BoardInterventionRecordModel, ChatThreadBoardState as ChatThreadBoardStateModel } from "./board-domain"; import type { ComplianceExportRecord, LegalHoldRecord, RetentionFreezeRecord } from "./compliance-domain"; +import type { AccessDecisionRecord, EmployeeIdentityRecord, RoleGrantRecord, ServiceIdentityRecord } from "./identity-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 { 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 { 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 const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REVIEW: "review", DONE: "done" } as const; @@ -465,5 +467,6 @@ export interface AppSnapshot { policyRules: PolicyRuleRecord[]; migrationReports: MigrationReportRecord[]; complianceExports: ComplianceExportRecord[]; legalHolds: LegalHoldRecord[]; retentionFreezes: RetentionFreezeRecord[]; + employeeIdentities: EmployeeIdentityRecord[]; serviceIdentities: ServiceIdentityRecord[]; roleGrants: RoleGrantRecord[]; accessDecisions: AccessDecisionRecord[]; auditEvents: AuditEventRecord[]; } diff --git a/AgentCompany-New/src/shared/identity-domain.ts b/AgentCompany-New/src/shared/identity-domain.ts new file mode 100644 index 00000000..0da7d00f --- /dev/null +++ b/AgentCompany-New/src/shared/identity-domain.ts @@ -0,0 +1,34 @@ +type ValueOf = T[keyof T]; + +export const IDENTITY_SOURCE = { BOOTSTRAP: "bootstrap", SSO: "sso", SCIM: "scim" } as const; +export const ROLE_KEY = { + ORG_ADMIN: "org_admin", + APPROVAL_DECIDER: "approval_decider", + HIRING_MANAGER: "hiring_manager", + SCOPED_AGENT: "scoped_agent", +} as const; +export const ACCESS_DECISION = { ALLOW: "allow", DENY: "deny" } as const; + +export type IdentitySource = ValueOf; +export type RoleKey = ValueOf; +export type AccessDecision = ValueOf; +export type PrincipalType = "employee" | "agent" | "service_identity"; + +export interface EmployeeIdentityRecord { + id: string; orgId: string; employeeId: string; source: IdentitySource; externalSubject: string; + email: string | null; active: boolean; createdAt: string; lastSeenAt: string | null; +} + +export interface ServiceIdentityRecord { + id: string; orgId: string; agentId: string; name: string; scopeJson: string; createdAt: string; +} + +export interface RoleGrantRecord { + id: string; orgId: string; principalType: PrincipalType; principalId: string; roleKey: RoleKey; + scopeType: string; scopeId: string; grantedByEmployeeId: string | null; createdAt: string; +} + +export interface AccessDecisionRecord { + id: string; orgId: string; principalType: PrincipalType; principalId: string; action: string; + resourceType: string; resourceId: string; decision: AccessDecision; reason: string; createdAt: string; +} diff --git a/AgentCompany-New/src/shared/ipc.ts b/AgentCompany-New/src/shared/ipc.ts index 781997a5..6803c1df 100644 --- a/AgentCompany-New/src/shared/ipc.ts +++ b/AgentCompany-New/src/shared/ipc.ts @@ -9,6 +9,8 @@ export const IPC_CHANNELS = { updateTaskStatus: "agentcompany:update-task-status", createApproval: "agentcompany:create-approval", decideApproval: "agentcompany:decide-approval", + syncEmployeeIdentity: "agentcompany:sync-employee-identity", + grantRole: "agentcompany:grant-role", createChatSpace: "agentcompany:create-chat-space", sendMessage: "agentcompany:send-message", markChatSpaceRead: "agentcompany:mark-chat-space-read", diff --git a/AgentCompany-New/tests/e2e/desktop.spec.ts b/AgentCompany-New/tests/e2e/desktop.spec.ts index b0dc295c..4d150d74 100644 --- a/AgentCompany-New/tests/e2e/desktop.spec.ts +++ b/AgentCompany-New/tests/e2e/desktop.spec.ts @@ -39,8 +39,13 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.getByLabel("Monthly budget").fill("250"); await page.locator(".modal").getByRole("button", { name: "Hire" }).click(); await expect(page.getByText("Alex Chen").first()).toBeVisible(); + await page.getByRole("button", { name: "Settings" }).click(); + await page.getByRole("button", { name: "Members" }).click(); + await expect(page.getByText("Identity & Access")).toBeVisible(); + await expect(page.getByText("org admin").first()).toBeVisible(); + await expect(page.getByText("scoped agent").first()).toBeVisible(); - await page.getByRole("button", { name: "Work" }).click(); + await page.getByRole("button", { name: "Work", exact: true }).click(); await page.getByRole("button", { name: "New task" }).click(); await page.getByLabel("Title").fill("Production deploy"); await page.getByLabel("Description").fill("Deploy a high-risk change."); @@ -117,7 +122,7 @@ async function verifyPagesDoNotOverflow(page: Page): Promise { for (const size of [{ width: 1280, height: 860 }, { width: 980, height: 700 }]) { await page.setViewportSize(size); for (const nav of ["Home", "Team", "Work", "Messages", "Files", "Approvals", "Social", "Settings"]) { - await page.getByRole("button", { name: nav }).click(); + await page.getByRole("button", { name: nav, exact: true }).click(); await expectPageToFit(page); } for (const tab of ["Workspace", "Members", "Billing", "Models", "Providers", "Skills", "Policies", "Compliance", "Migration", "Profile", "Notifications", "Security"]) { diff --git a/AgentCompany-New/tests/e2e/screenshots.spec.ts b/AgentCompany-New/tests/e2e/screenshots.spec.ts index 8b23d3b1..7fe3ba7e 100644 --- a/AgentCompany-New/tests/e2e/screenshots.spec.ts +++ b/AgentCompany-New/tests/e2e/screenshots.spec.ts @@ -64,7 +64,7 @@ test("captures every page for visual review", async () => { }); async function go(page: Page, nav: string, dir: string, name: string): Promise { - await page.getByRole("button", { name: nav }).click(); + await page.getByRole("button", { name: nav, exact: true }).click(); await snap(page, dir, name); } diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index d2828f86..8cf93169 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -23,7 +23,7 @@ describe("domain services", () => { const connection = createConnection(); 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]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]); connection.close(); }); @@ -43,6 +43,8 @@ describe("domain services", () => { expect(state.organizations).toHaveLength(1); expect(state.employees[0]?.displayName).toBe("Dana Lee"); expect(state.providers.length).toBeGreaterThanOrEqual(6); + expect(state.employeeIdentities).toHaveLength(1); + expect(state.roleGrants.map((grant) => grant.roleKey).sort()).toEqual(["approval_decider", "hiring_manager", "org_admin"]); expect(state.chatSpaces.map((space) => space.name)).toContain("board"); expect(state.policyRules[0]?.action).toBe("task_create"); expect(state.auditEvents[0]?.action).toBe("created"); diff --git a/AgentCompany-New/tests/unit/identity-access-service.test.ts b/AgentCompany-New/tests/unit/identity-access-service.test.ts new file mode 100644 index 00000000..5258aed5 --- /dev/null +++ b/AgentCompany-New/tests/unit/identity-access-service.test.ts @@ -0,0 +1,100 @@ +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 { IdentityAccessService } from "../../src/main/services/identity-access-service"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { SnapshotService } from "../../src/main/services/snapshot-service"; +import { RISK_LEVEL, ROLE_KEY } from "../../src/shared/domain"; + +function createConnection(): DatabaseConnection { + return new DatabaseConnection(join(tmpdir(), `agentcompany-new-identity-${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.", + }); +} + +function firstEmployeeId(connection: DatabaseConnection, orgId: string): string { + return (connection.db.prepare("select id from employees where org_id = ? order by created_at asc limit 1") + .get(orgId) as { id: string }).id; +} + +describe("identity access service", () => { + test("syncs SSO employee identities and grants scoped roles", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const identityAccess = new IdentityAccessService(connection.db); + const snapshot = new SnapshotService(connection.db); + let orgId = ""; + let employeeId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + employeeId = firstEmployeeId(connection, orgId); + identityAccess.syncEmployeeIdentity({ + orgId, + employeeId, + source: "sso", + externalSubject: "okta-00u1", + email: "dana@example.com", + }); + identityAccess.grantRole({ + orgId, + principalType: "employee", + principalId: employeeId, + roleKey: ROLE_KEY.APPROVAL_DECIDER, + scopeType: "organization", + scopeId: orgId, + grantedByEmployeeId: employeeId, + }); + }); + + const state = snapshot.load(); + expect(state.employeeIdentities.some((identity) => identity.source === "sso" && identity.email === "dana@example.com")).toBe(true); + expect(state.roleGrants.some((grant) => grant.principalId === employeeId && grant.roleKey === ROLE_KEY.APPROVAL_DECIDER)).toBe(true); + expect(state.auditEvents.some((event) => event.entityType === "employee_identity" && event.action === "synced")).toBe(true); + connection.close(); + }); + + test("records denied access decisions when approval decider role is missing", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const approvals = new ApprovalService(connection.db); + let orgId = ""; + let employeeId = ""; + let approvalId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + employeeId = firstEmployeeId(connection, orgId); + connection.db.prepare("delete from role_grants where org_id = ? and principal_id = ?").run(orgId, employeeId); + approvalId = approvals.create({ + orgId, + kind: "release", + title: "Release approval", + details: "Approve release train.", + risk: RISK_LEVEL.MEDIUM, + requestedByType: "system", + requestedById: null, + }); + }); + + expect(() => approvals.decide({ + orgId, + approvalId, + state: "approved", + decidedByEmployeeId: employeeId, + })).toThrow("Employee requires approval_decider role."); + expect(connection.db.prepare("select decision, reason from access_decisions where org_id = ?").all(orgId)) + .toEqual([{ decision: "deny", reason: "missing_role" }]); + connection.close(); + }); +}); diff --git a/AgentCompany-New/tests/unit/migration-service.test.ts b/AgentCompany-New/tests/unit/migration-service.test.ts index 848d450d..5bd0d67f 100644 --- a/AgentCompany-New/tests/unit/migration-service.test.ts +++ b/AgentCompany-New/tests/unit/migration-service.test.ts @@ -93,6 +93,10 @@ describe("migration service", () => { const legacyPath = createLegacyDatabase(` create table organizations(id text primary key); 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 service_identities(id text primary key, org_id text, agent_id text); + create table role_grants(id text primary key, org_id text, granted_by_employee_id text); + create table access_decisions(id text primary key, org_id text); create table agents(id text primary key, org_id text, model_provider_id text, manager_agent_id text, workspace_id text); create table tasks(id text primary key, org_id text, assignee_agent_id text, project_id text, goal_id text, workspace_id text); create table workspaces(id text primary key, org_id text, project_id text); @@ -136,12 +140,16 @@ describe("migration service", () => { create table audit_events(id text primary key, org_id text); insert into organizations(id) values ('org-1'); insert into employees(id, org_id) values ('emp-1', 'org-1'); + insert into employee_identities(id, org_id, employee_id) values ('eid-1', 'org-1', 'emp-1'); insert into credentials(id, org_id) values ('cred-1', 'org-1'); insert into providers(id, org_id, credential_id) values ('prov-1', 'org-1', 'cred-1'); insert into chat_spaces(id, org_id) values ('space-1', 'org-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 agents(id, org_id, model_provider_id, manager_agent_id, workspace_id) values ('agent-1', 'org-1', 'prov-1', null, 'wsp-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 access_decisions(id, org_id) values ('access-1', 'org-1'); insert into goals(id, org_id, owner_agent_id) values ('goal-1', 'org-1', 'agent-1'); insert into projects(id, org_id, goal_id, lead_agent_id) values ('prj-1', 'org-1', 'goal-1', 'agent-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'); @@ -188,6 +196,8 @@ describe("migration service", () => { const body = JSON.parse(snapshot.load().migrationReports[0]!.reportJson) as MigrationReportBody; expect(body.failures).toEqual([]); expect(body.tableCounts.credentials).toBe(1); + expect(body.tableCounts.employee_identities).toBe(1); + expect(body.tableCounts.role_grants).toBe(1); expect(body.tableCounts.skill_artifacts).toBe(1); expect(body.tableCounts.chat_read_state).toBe(1); expect(body.tableCounts.board_interventions).toBe(1); @@ -198,6 +208,8 @@ describe("migration service", () => { expect(body.tableCounts.search_documents).toBe(1); expect(body.tableChecksums.audit_events).toHaveLength(64); expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]); + expect(body.idMaps.service_identities).toEqual(["sid-1"]); + expect(body.idMaps.access_decisions).toEqual(["access-1"]); expect(body.idMaps.board_interventions).toEqual(["bdi-1"]); expect(body.idMaps.retention_freezes).toEqual(["freeze-1"]); expect(body.idMaps.provider_usage_windows).toEqual(["usage-1"]);