From 648544114a771690f46a2eb5849120f0245bff44 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 23:03:09 -0400 Subject: [PATCH] Record provider auth sessions --- .../migrations/034-provider-auth-sessions.ts | 27 ++++ AgentCompany-New/src/main/database/schema.ts | 2 + .../src/main/services/compliance-service.ts | 2 + .../src/main/services/migration-service.ts | 8 ++ .../src/main/services/provider-service.ts | 121 +++++++++++++++++- .../main/services/provider-sign-in-window.ts | 4 +- .../src/main/services/snapshot-service.ts | 2 + .../src/renderer/src/pages/SettingsPage.tsx | 2 + .../src/pages/settings/ProvidersPanel.tsx | 12 +- AgentCompany-New/src/shared/commands.ts | 1 + AgentCompany-New/src/shared/domain.ts | 5 +- .../src/shared/provider-auth-domain.ts | 17 +++ .../tests/unit/compliance-service.test.ts | 23 ++++ .../tests/unit/domain-services.test.ts | 2 +- .../tests/unit/migration-service.test.ts | 8 ++ .../tests/unit/provider-service.test.ts | 58 ++++++++- 16 files changed, 280 insertions(+), 14 deletions(-) create mode 100644 AgentCompany-New/src/main/database/migrations/034-provider-auth-sessions.ts diff --git a/AgentCompany-New/src/main/database/migrations/034-provider-auth-sessions.ts b/AgentCompany-New/src/main/database/migrations/034-provider-auth-sessions.ts new file mode 100644 index 00000000..99766cb6 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/034-provider-auth-sessions.ts @@ -0,0 +1,27 @@ +export const providerAuthSessionsSchema = ` + create table if not exists provider_auth_sessions ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + provider_id text not null references providers(id) on delete cascade, + provider_key text not null, + employee_id text not null references employees(id) on delete cascade, + state text not null, + nonce text not null, + code_verifier_sha256 text not null, + redirect_uri text not null, + sign_in_url text not null, + partition text not null, + created_at text not null, + expires_at text not null, + completed_at text, + unique(provider_id, state) + ); + + create index if not exists idx_provider_auth_sessions_org_provider + on provider_auth_sessions(org_id, provider_id, created_at); + create index if not exists idx_provider_auth_sessions_employee + on provider_auth_sessions(org_id, employee_id, created_at); + + insert into schema_migrations(version, name, applied_at) + values (34, 'provider_auth_sessions', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index 3c6dbcbf..b0b629ed 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -32,6 +32,7 @@ import { approvalSourceRefsSchema } from "./migrations/030-approval-source-refs" import { providerRequestWorkspaceSchema } from "./migrations/031-provider-request-workspace"; import { browserActionMetadataSchema } from "./migrations/032-browser-action-metadata"; import { auditEventImmutabilitySchema } from "./migrations/033-audit-event-immutability"; +import { providerAuthSessionsSchema } from "./migrations/034-provider-auth-sessions"; interface Migration { version: number; @@ -73,6 +74,7 @@ const MIGRATIONS: Migration[] = [ { version: 31, name: "provider_request_workspace", sql: providerRequestWorkspaceSchema }, { version: 32, name: "browser_action_metadata", sql: browserActionMetadataSchema }, { version: 33, name: "audit_event_immutability", sql: auditEventImmutabilitySchema }, + { version: 34, name: "provider_auth_sessions", sql: providerAuthSessionsSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/compliance-service.ts b/AgentCompany-New/src/main/services/compliance-service.ts index 337b2bf1..4e640fc5 100644 --- a/AgentCompany-New/src/main/services/compliance-service.ts +++ b/AgentCompany-New/src/main/services/compliance-service.ts @@ -157,6 +157,7 @@ export class ComplianceService { roleGrants: this.db.prepare("select * from role_grants where org_id = ? and principal_type = 'employee' and principal_id = ? order by created_at").all(orgId, subjectId), socialAccountsAuthorized: this.db.prepare("select * from social_accounts where org_id = ? and authorized_employee_id = ? order by created_at").all(orgId, subjectId), socialAccountGrants: this.db.prepare("select * from social_account_grants where org_id = ? and employee_id = ? order by created_at").all(orgId, subjectId), + providerAuthSessions: this.db.prepare("select * from provider_auth_sessions where org_id = ? and employee_id = ? order by created_at").all(orgId, subjectId), scheduledPostsCreated: this.db.prepare("select * from scheduled_posts where org_id = ? and created_by_employee_id = ? order by created_at").all(orgId, subjectId), complianceExportsRequested: this.db.prepare("select * from compliance_exports where org_id = ? and requested_by_employee_id = ? order by created_at").all(orgId, subjectId), auditEventsAsActor: this.db.prepare("select * from audit_events where org_id = ? and actor_type = 'employee' and actor_id = ? order by created_at").all(orgId, subjectId), @@ -184,6 +185,7 @@ export class ComplianceService { this.db.prepare("delete from employee_identities where org_id = ? and employee_id = ?").run(orgId, subjectId); this.db.prepare("delete from chat_read_state where org_id = ? and employee_id = ?").run(orgId, subjectId); this.db.prepare("delete from social_account_grants where org_id = ? and employee_id = ?").run(orgId, subjectId); + this.db.prepare("delete from provider_auth_sessions where org_id = ? and employee_id = ?").run(orgId, subjectId); this.db.prepare("update social_accounts set authorized_employee_id = null where org_id = ? and authorized_employee_id = ?") .run(orgId, subjectId); this.db.prepare("update social_approval_routes set route_to_id = null where org_id = ? and route_to_type = 'authorized_employee' and route_to_id = ?") diff --git a/AgentCompany-New/src/main/services/migration-service.ts b/AgentCompany-New/src/main/services/migration-service.ts index 6df2ce14..5a3404f5 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -52,6 +52,8 @@ const LEGACY_TABLES = [ "provider_requests", "provider_usage_windows", "provider_rate_limit_observations", + "provider_auth_profiles", + "provider_auth_sessions", "credentials", "skill_definitions", "skill_revisions", @@ -199,6 +201,12 @@ const RELATION_CHECKS: RelationCheck[] = [ { table: "provider_usage_windows", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" }, { table: "provider_rate_limit_observations", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "provider_rate_limit_observations", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" }, + { table: "provider_auth_profiles", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "provider_auth_profiles", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" }, + { table: "provider_auth_profiles", column: "credential_id", referencedTable: "credentials", referencedColumn: "id", reason: "dangling_credential_id" }, + { table: "provider_auth_sessions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "provider_auth_sessions", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" }, + { table: "provider_auth_sessions", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_id" }, { table: "credentials", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "skill_definitions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "skill_revisions", column: "skill_id", referencedTable: "skill_definitions", referencedColumn: "id", reason: "dangling_skill_id" }, diff --git a/AgentCompany-New/src/main/services/provider-service.ts b/AgentCompany-New/src/main/services/provider-service.ts index e784738c..21cddc41 100644 --- a/AgentCompany-New/src/main/services/provider-service.ts +++ b/AgentCompany-New/src/main/services/provider-service.ts @@ -1,4 +1,5 @@ import type { DatabaseSync } from "node:sqlite"; +import { createHash, randomBytes } from "node:crypto"; import type { CancelProviderResponseInput, CreateProviderResponseInput, LaunchProviderSignInInput, RevokeProviderCredentialInput, SaveProviderCredentialInput, StreamProviderResponseInput, UpdateProviderPolicyInput } from "../../shared/commands"; import { FEATURE_FLAG_KEY, PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, PROVIDER_REQUEST_STATE, type ProviderAuthType, type ProviderExecutionMode } from "../../shared/domain"; import type { ProviderStreamRendererEvent } from "../../shared/provider-request-domain"; @@ -23,6 +24,7 @@ const HEALTH_STATE_HEALTHY = "healthy"; const HEALTH_STATE_FAILED = "failed"; const HEALTH_STATE_UNCHECKED = "unchecked"; const WORKSPACE_SCOPES = new Set(["organization", "workspace"]); +const AUTH_SESSION_TTL_MS = 15 * 60 * 1000; export type ProviderResponseCreateInput = CreateProviderResponseInput & { onRequestCreated?: (requestId: string) => void; @@ -61,6 +63,13 @@ export class ProviderService { const adapter = this.requireAdapter(provider.provider_key, provider.auth_type); adapter.parseAuthProfile(secret); const catalogEntry = this.requireCatalogEntry(provider.provider_key); + const source = this.resolveCredentialSource({ + orgId: input.orgId, + providerId: input.providerId, + providerKey: provider.provider_key, + authType: provider.auth_type, + employeeId: input.employeeId ?? null, + }); const authProfile = this.authBroker.createProviderProfile({ orgId: input.orgId, providerId: input.providerId, @@ -68,8 +77,8 @@ export class ProviderService { authType: provider.auth_type, secret, profileKind: provider.auth_type, - sourceType: "manual_secret", - sourceRef: null, + sourceType: source.sourceType, + sourceRef: source.sourceRef, capabilityTags: catalogEntry.capabilityTags, }); this.db.prepare(` @@ -84,7 +93,7 @@ export class ProviderService { actorType: "system", actorId: null, action: "credential_saved", - payload: { providerKey: provider.provider_key, authType: provider.auth_type, authProfileId: authProfile.authProfileId }, + payload: { providerKey: provider.provider_key, authType: provider.auth_type, authProfileId: authProfile.authProfileId, sourceType: source.sourceType, sourceRef: source.sourceRef }, }); } @@ -152,7 +161,14 @@ export class ProviderService { if (!provider) throw new Error("Provider was not found."); const catalogEntry = this.requireCatalogEntry(provider.provider_key); if (!catalogEntry.signInUrl) throw new Error(`${provider.label} does not support browser sign-in.`); - this.launchSignIn({ providerId: provider.id, label: provider.label, signInUrl: catalogEntry.signInUrl }); + const session = this.createAuthSession({ + orgId: input.orgId, + providerId: provider.id, + providerKey: provider.provider_key, + employeeId: input.employeeId, + signInUrl: catalogEntry.signInUrl, + }); + this.launchSignIn({ providerId: provider.id, label: provider.label, signInUrl: catalogEntry.signInUrl, partition: session.partition }); this.audit.record({ orgId: input.orgId, @@ -161,7 +177,16 @@ export class ProviderService { actorType: "employee", actorId: input.employeeId, action: "sign_in_window_opened", - payload: { providerKey: provider.provider_key, authType: provider.auth_type }, + payload: { + providerKey: provider.provider_key, + authType: provider.auth_type, + authSessionId: session.id, + state: session.state, + nonce: session.nonce, + codeVerifierSha256: session.codeVerifierSha256, + redirectUri: session.redirectUri, + partition: session.partition, + }, }); } @@ -473,6 +498,75 @@ export class ProviderService { if (!employee) throw new Error("Provider sign-in employee was not found."); } + private resolveCredentialSource(input: { + orgId: string; + providerId: string; + providerKey: string; + authType: ProviderAuthType; + employeeId: string | null; + }): { sourceType: string; sourceRef: string | null } { + if (input.authType !== PROVIDER_AUTH_TYPE.OAUTH_PKCE && input.authType !== PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION) { + return { sourceType: "manual_secret", sourceRef: null }; + } + if (!input.employeeId) throw new Error("Provider sign-in employee is required."); + const session = this.db.prepare(` + select id + from provider_auth_sessions + where org_id = ? + and provider_id = ? + and provider_key = ? + and employee_id = ? + and completed_at is null + and expires_at > ? + order by created_at desc + limit 1 + `).get(input.orgId, input.providerId, input.providerKey, input.employeeId, new Date().toISOString()) as { id: string } | undefined; + if (!session) throw new Error("Provider sign-in session is required."); + this.db.prepare("update provider_auth_sessions set completed_at = ? where id = ? and org_id = ?") + .run(new Date().toISOString(), session.id, input.orgId); + return { sourceType: "provider_sign_in_session", sourceRef: session.id }; + } + + private createAuthSession(input: { + orgId: string; + providerId: string; + providerKey: string; + employeeId: string; + signInUrl: string; + }): ProviderAuthSessionDraft { + const id = createProviderAuthSessionId(); + const state = randomToken(); + const nonce = randomToken(); + const codeVerifier = randomToken(); + const codeVerifierSha256 = createHash("sha256").update(codeVerifier).digest("hex"); + const redirectUri = `agentcompany://provider-auth/${input.providerId}`; + const partition = `persist:agentcompany-provider-${input.providerId}`; + const createdAt = new Date(); + const expiresAt = new Date(createdAt.getTime() + AUTH_SESSION_TTL_MS); + this.db.prepare(` + insert into provider_auth_sessions( + id, org_id, provider_id, provider_key, employee_id, state, nonce, + code_verifier_sha256, redirect_uri, sign_in_url, partition, + created_at, expires_at, completed_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null) + `).run( + id, + input.orgId, + input.providerId, + input.providerKey, + input.employeeId, + state, + nonce, + codeVerifierSha256, + redirectUri, + input.signInUrl, + partition, + createdAt.toISOString(), + expiresAt.toISOString(), + ); + return { id, state, nonce, codeVerifierSha256, redirectUri, partition }; + } + private recordProviderHealth(providerId: string, orgId: string, healthState: string, error: string | null): void { this.db.prepare(` update providers @@ -522,3 +616,20 @@ interface ProviderRow { monthly_request_limit: number; workspace_scope: string; } + +interface ProviderAuthSessionDraft { + id: string; + state: string; + nonce: string; + codeVerifierSha256: string; + redirectUri: string; + partition: string; +} + +function createProviderAuthSessionId(): string { + return `auths_${randomToken()}`; +} + +function randomToken(): string { + return randomBytes(24).toString("base64url"); +} diff --git a/AgentCompany-New/src/main/services/provider-sign-in-window.ts b/AgentCompany-New/src/main/services/provider-sign-in-window.ts index 5a2ccfff..20edbdf8 100644 --- a/AgentCompany-New/src/main/services/provider-sign-in-window.ts +++ b/AgentCompany-New/src/main/services/provider-sign-in-window.ts @@ -4,11 +4,11 @@ export interface LaunchProviderSignInWindowInput { providerId: string; label: string; signInUrl: string; + partition: string; } export function launchProviderSignInWindow(input: LaunchProviderSignInWindowInput): void { - const partition = `persist:agentcompany-provider-${input.providerId}`; - const providerSession = session.fromPartition(partition); + const providerSession = session.fromPartition(input.partition); providerSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); providerSession.setPermissionCheckHandler(() => false); diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index e2679966..fbb76d91 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -50,6 +50,7 @@ export class SnapshotService { .map((provider) => ({ ...provider, enabled: toBool(provider.enabled), backgroundEnabled: toBool(provider.backgroundEnabled) })); const providerAuthProfiles = list(this.db, "provider_auth_profiles", "created_at desc") .map((profile) => ({ ...profile, active: toBool(profile.active) })); + const providerAuthSessions = list(this.db, "provider_auth_sessions", "created_at desc"); const providerRateLimitObservations = list( this.db, "provider_rate_limit_observations", @@ -107,6 +108,7 @@ export class SnapshotService { scheduledPosts: list(this.db, "scheduled_posts", "scheduled_for asc"), providers, providerAuthProfiles, + providerAuthSessions, providerCatalog: PROVIDER_CATALOG, providerRequests: list(this.db, "provider_requests", "created_at desc"), providerRateLimits, diff --git a/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx b/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx index 86a1d898..d3ac9654 100644 --- a/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx @@ -65,6 +65,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) { const employees = useMemo(() => snapshot.employees.filter((item) => item.orgId === orgId), [snapshot.employees, orgId]); const agents = useMemo(() => snapshot.agents.filter((item) => item.orgId === orgId), [snapshot.agents, orgId]); const providers = useMemo(() => snapshot.providers.filter((item) => item.orgId === orgId), [snapshot.providers, orgId]); + const providerAuthProfiles = useMemo(() => snapshot.providerAuthProfiles.filter((item) => item.orgId === orgId), [snapshot.providerAuthProfiles, orgId]); const providerRateLimits = useMemo(() => snapshot.providerRateLimits.filter((item) => item.orgId === orgId), [snapshot.providerRateLimits, orgId]); const skills = useMemo(() => snapshot.skillDefinitions.filter((item) => item.orgId === orgId), [snapshot.skillDefinitions, orgId]); const skillRevisions = useMemo(() => { @@ -137,6 +138,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) { orgId={orgId} employeeId={employee.id} providers={providers} + authProfiles={providerAuthProfiles} catalog={snapshot.providerCatalog} agents={agents} workspaces={workspaces} diff --git a/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx b/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx index 66e25632..25937c9c 100644 --- a/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx +++ b/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx @@ -4,6 +4,7 @@ import { Dialog } from "../../shared/Dialog"; import type { AgentRecord, ProviderCatalogEntry, + ProviderAuthProfileRecord, ProviderRateLimitStateRecord, ProviderRecord, ProviderRequestRecord, @@ -18,6 +19,7 @@ interface ProvidersPanelProps { orgId: string; employeeId: string; providers: ProviderRecord[]; + authProfiles: ProviderAuthProfileRecord[]; catalog: ProviderCatalogEntry[]; agents: AgentRecord[]; workspaces: WorkspaceRecord[]; @@ -37,7 +39,7 @@ const CLOUD_DEFAULTS: CloudFields = { baseUrl: "", region: "", projectId: "", de const POLICY_GRID = "minmax(150px, 1.2fr) minmax(118px, 0.8fr) minmax(140px, 1fr) 112px 124px 92px"; type WorkspaceScope = "organization" | "workspace"; -export function ProvidersPanel({ orgId, employeeId, providers, catalog, agents, workspaces, providerRateLimits, providerRequests, actions }: ProvidersPanelProps) { +export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, catalog, agents, workspaces, providerRateLimits, providerRequests, actions }: ProvidersPanelProps) { const [active, setActive] = useState(null); const [signInLaunched, setSignInLaunched] = useState(false); const connected = providers.filter((provider) => provider.enabled); @@ -59,6 +61,7 @@ export function ProvidersPanel({ orgId, employeeId, providers, catalog, agents, providerId: active.id, secret, model: String(form.get("model") ?? "").trim() || null, + employeeId, })).then((result) => { if (result) close(); }); }; @@ -81,6 +84,7 @@ export function ProvidersPanel({ orgId, employeeId, providers, catalog, agents, profile.providerId === provider.id && profile.active) ?? null} agents={agents} onCheck={() => void actions.run(() => window.agentCompany.checkProviderHealth({ orgId, providerId: provider.id }))} onManage={() => setActive(provider)} @@ -388,12 +392,14 @@ function composeSecret(entry: ProviderCatalogEntry, form: FormData): string { function ProviderRow({ provider, + authProfile, agents, onCheck, onManage, onRevoke, }: { provider: ProviderRecord; + authProfile: ProviderAuthProfileRecord | null; agents: AgentRecord[]; onCheck: () => void; onManage: () => void; @@ -415,8 +421,8 @@ function ProviderRow({
{provider.label}
-
- {provider.model ? `${provider.model} · ` : ""}{readableAuth(provider.authType)} +
+ {provider.model ? `${provider.model} · ` : ""}{readableAuth(provider.authType)}{authProfile ? ` · ${authProfile.sourceType}` : ""}
diff --git a/AgentCompany-New/src/shared/commands.ts b/AgentCompany-New/src/shared/commands.ts index 0e36b0ef..3784a4a0 100644 --- a/AgentCompany-New/src/shared/commands.ts +++ b/AgentCompany-New/src/shared/commands.ts @@ -160,6 +160,7 @@ export interface SaveProviderCredentialInput { providerId: string; secret: string; model: string | null; + employeeId?: string | null; } export interface RevokeProviderCredentialInput { diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index 3da1652c..0b4dc237 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -4,7 +4,7 @@ import type { ComplianceExportRecord, LegalHoldRecord, RetentionFreezeRecord } f import type { AccessDecisionRecord, EmployeeIdentityRecord, RoleGrantRecord, ServiceIdentityRecord } from "./identity-domain"; import type { ApprovalAssignmentRecord, ApprovalRoutingRuleRecord, HiringPolicyRecord, OrgDepartmentRecord } from "./org-governance-domain"; import type { ChaosDrillRecord, ChaosDrillRunRecord, FeatureFlagRecord, OperationalEventRecord, SloTargetRecord } from "./operations-domain"; -import type { ProviderAuthProfileRecord } from "./provider-auth-domain"; +import type { ProviderAuthProfileRecord, ProviderAuthSessionRecord } from "./provider-auth-domain"; import type { ProviderDataPolicy } from "./provider-policy-domain"; import type { ProviderRequestRecord } from "./provider-request-domain"; import type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord } from "./provider-runtime-domain"; @@ -17,7 +17,7 @@ export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportR 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 { ROUTE_TO_TYPE, type ApprovalAssignmentRecord, type ApprovalRoutingRuleRecord, type HiringPolicyRecord, type OrgDepartmentRecord, type RouteToType } from "./org-governance-domain"; export { CHAOS_DRILL_SCENARIO, CHAOS_DRILL_STATE, FEATURE_FLAG_KEY, SLO_SIGNAL_KEY, type ChaosDrillRecord, type ChaosDrillRunRecord, type ChaosDrillScenario, type ChaosDrillState, type FeatureFlagKey, type FeatureFlagRecord, type OperationalEventRecord, type SloSignalKey, type SloTargetRecord } from "./operations-domain"; -export type { ProviderAuthProfileRecord } from "./provider-auth-domain"; +export type { ProviderAuthProfileRecord, ProviderAuthSessionRecord } from "./provider-auth-domain"; export { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, type ProviderDataPolicy, type ProviderExecutionMode } from "./provider-policy-domain"; export { PROVIDER_REQUEST_STATE, type ProviderRequestRecord, type ProviderRequestState, type ProviderStreamRendererEvent } from "./provider-request-domain"; export type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord } from "./provider-runtime-domain"; @@ -465,6 +465,7 @@ export interface AppSnapshot { scheduledPosts: ScheduledPostRecord[]; providers: ProviderRecord[]; providerAuthProfiles: ProviderAuthProfileRecord[]; + providerAuthSessions: ProviderAuthSessionRecord[]; providerCatalog: ProviderCatalogEntry[]; providerRequests: ProviderRequestRecord[]; providerRateLimits: ProviderRateLimitStateRecord[]; diff --git a/AgentCompany-New/src/shared/provider-auth-domain.ts b/AgentCompany-New/src/shared/provider-auth-domain.ts index c5d65678..bd17bcfe 100644 --- a/AgentCompany-New/src/shared/provider-auth-domain.ts +++ b/AgentCompany-New/src/shared/provider-auth-domain.ts @@ -15,3 +15,20 @@ export interface ProviderAuthProfileRecord { active: boolean; createdAt: string; } + +export interface ProviderAuthSessionRecord { + id: string; + orgId: string; + providerId: string; + providerKey: string; + employeeId: string; + state: string; + nonce: string; + codeVerifierSha256: string; + redirectUri: string; + signInUrl: string; + partition: string; + createdAt: string; + expiresAt: string; + completedAt: string | null; +} diff --git a/AgentCompany-New/tests/unit/compliance-service.test.ts b/AgentCompany-New/tests/unit/compliance-service.test.ts index ba18f653..8863bb3e 100644 --- a/AgentCompany-New/tests/unit/compliance-service.test.ts +++ b/AgentCompany-New/tests/unit/compliance-service.test.ts @@ -107,6 +107,28 @@ describe("compliance service", () => { createdByEmployeeId: employeeId, authorizedEmployeeId: employeeId, }); + const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'github-copilot'") + .get(orgId) as { id: string }).id; + connection.db.prepare(` + insert into provider_auth_sessions( + id, org_id, provider_id, provider_key, employee_id, state, nonce, code_verifier_sha256, + redirect_uri, sign_in_url, partition, created_at, expires_at, completed_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null) + `).run( + "auths-test", + orgId, + providerId, + "github-copilot", + employeeId, + "state", + "nonce", + "a".repeat(64), + `agentcompany://provider-auth/${providerId}`, + "https://github.com/login", + `persist:agentcompany-provider-${providerId}`, + new Date().toISOString(), + new Date(Date.now() + 60000).toISOString(), + ); holdId = compliance.createLegalHold({ orgId, entityType: "employee", @@ -144,6 +166,7 @@ describe("compliance service", () => { expect(redactedApproval?.decidedByEmployeeId).toBeNull(); expect(redactedSocialAccount?.authorizedEmployeeId).toBeNull(); expect(state.socialAccountGrants).toHaveLength(0); + expect(state.providerAuthSessions).toHaveLength(0); expect(state.complianceExports[0]?.exportType).toBe("gdpr_delete"); expect(state.complianceExports[0]?.requestedByEmployeeId).toBeNull(); expect(state.auditEvents.some((event) => event.entityType === "compliance_export" && event.action === "gdpr_deleted")).toBe(true); diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 450a6a64..502ccd8e 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, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34]); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/migration-service.test.ts b/AgentCompany-New/tests/unit/migration-service.test.ts index 54580c8d..127e9d1d 100644 --- a/AgentCompany-New/tests/unit/migration-service.test.ts +++ b/AgentCompany-New/tests/unit/migration-service.test.ts @@ -152,6 +152,8 @@ describe("migration service", () => { create table provider_requests(id text primary key, org_id text, provider_id text, workspace_id text); create table provider_usage_windows(id text primary key, org_id text, provider_id text); create table provider_rate_limit_observations(id text primary key, org_id text, provider_id text); + create table provider_auth_profiles(id text primary key, org_id text, provider_id text, credential_id text); + create table provider_auth_sessions(id text primary key, org_id text, provider_id text, employee_id text); create table credentials(id text primary key, org_id text); create table skill_definitions(id text primary key, org_id text); create table skill_revisions(id text primary key, skill_id text); @@ -228,6 +230,8 @@ describe("migration service", () => { insert into provider_requests(id, org_id, provider_id, workspace_id) values ('req-1', 'org-1', 'prov-1', 'wsp-1'); insert into provider_usage_windows(id, org_id, provider_id) values ('usage-1', 'org-1', 'prov-1'); insert into provider_rate_limit_observations(id, org_id, provider_id) values ('quota-1', 'org-1', 'prov-1'); + insert into provider_auth_profiles(id, org_id, provider_id, credential_id) values ('authp-1', 'org-1', 'prov-1', 'cred-1'); + insert into provider_auth_sessions(id, org_id, provider_id, employee_id) values ('auths-1', 'org-1', 'prov-1', 'emp-1'); insert into skill_definitions(id, org_id) values ('skill-1', 'org-1'); insert into skill_revisions(id, skill_id) values ('rev-1', 'skill-1'), ('rev-2', 'skill-1'); insert into skill_artifacts(id, revision_id) values ('artifact-1', 'rev-1'); @@ -289,6 +293,8 @@ describe("migration service", () => { expect(body.tableCounts.provider_requests).toBe(1); expect(body.tableCounts.provider_usage_windows).toBe(1); expect(body.tableCounts.provider_rate_limit_observations).toBe(1); + expect(body.tableCounts.provider_auth_profiles).toBe(1); + expect(body.tableCounts.provider_auth_sessions).toBe(1); expect(body.tableCounts.browser_session_containers).toBe(1); expect(body.tableCounts.browser_action_policies).toBe(1); expect(body.tableCounts.social_approval_routes).toBe(1); @@ -317,6 +323,8 @@ describe("migration service", () => { expect(body.idMaps.migration_reconciliation_items).toEqual(["reconcile-1"]); expect(body.idMaps.provider_usage_windows).toEqual(["usage-1"]); expect(body.idMaps.provider_rate_limit_observations).toEqual(["quota-1"]); + expect(body.idMaps.provider_auth_profiles).toEqual(["authp-1"]); + expect(body.idMaps.provider_auth_sessions).toEqual(["auths-1"]); expect(body.idMaps.browser_session_containers).toEqual(["container-1"]); expect(body.idMaps.browser_action_policies).toEqual(["browser-policy-1"]); expect(body.idMaps.social_approval_routes).toEqual(["social-route-1"]); diff --git a/AgentCompany-New/tests/unit/provider-service.test.ts b/AgentCompany-New/tests/unit/provider-service.test.ts index 4ed6efb5..343fc2d3 100644 --- a/AgentCompany-New/tests/unit/provider-service.test.ts +++ b/AgentCompany-New/tests/unit/provider-service.test.ts @@ -173,7 +173,7 @@ describe("provider service", () => { const connection = createConnection(); const organization = new OrganizationService(connection.db); const snapshot = new SnapshotService(connection.db); - let launched: { providerId: string; label: string; signInUrl: string } | null = null; + let launched: { providerId: string; label: string; signInUrl: string; partition: string } | null = null; const providers = new ProviderService(connection.db, providerAdapters, testVault, (input) => { launched = input; }); let orgId = ""; let employeeId = ""; @@ -198,7 +198,19 @@ describe("provider service", () => { providerId: copilotProviderId, label: "GitHub Copilot", signInUrl: "https://github.com/login", + partition: `persist:agentcompany-provider-${copilotProviderId}`, }); + expect(state.providerAuthSessions[0]).toMatchObject({ + orgId, + providerId: copilotProviderId, + providerKey: "github-copilot", + employeeId, + partition: `persist:agentcompany-provider-${copilotProviderId}`, + completedAt: null, + }); + expect(state.providerAuthSessions[0]?.state).toHaveLength(32); + expect(state.providerAuthSessions[0]?.nonce).toHaveLength(32); + expect(state.providerAuthSessions[0]?.codeVerifierSha256).toHaveLength(64); expect(state.auditEvents).toContainEqual(expect.objectContaining({ entityType: "provider", entityId: copilotProviderId, @@ -209,6 +221,50 @@ describe("provider service", () => { connection.close(); }); + test("binds OAuth credentials to a sign-in session source", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const snapshot = new SnapshotService(connection.db); + const providers = new ProviderService(connection.db, providerAdapters, testVault, () => {}); + let orgId = ""; + let employeeId = ""; + let copilotProviderId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + copilotProviderId = providerId(connection, orgId, "github-copilot"); + }); + + expect(() => connection.transaction(() => providers.saveCredential({ + orgId, + providerId: copilotProviderId, + secret: "ghu_live_provider", + model: "claude-opus-4-5", + employeeId, + }))).toThrow("Provider sign-in session is required."); + + connection.transaction(() => { + providers.launchSignInWindow({ orgId, providerId: copilotProviderId, employeeId }); + providers.saveCredential({ + orgId, + providerId: copilotProviderId, + secret: "ghu_live_provider", + model: "claude-opus-4-5", + employeeId, + }); + }); + + const state = snapshot.load(); + const profile = state.providerAuthProfiles.find((item) => item.providerId === copilotProviderId && item.active); + const session = state.providerAuthSessions.find((item) => item.providerId === copilotProviderId); + if (!profile || !session) throw new Error("Provider auth evidence was not created."); + expect(profile).toMatchObject({ sourceType: "provider_sign_in_session", sourceRef: session.id }); + expect(session.completedAt).toBeTruthy(); + expect(state.providers.find((item) => item.id === copilotProviderId)?.enabled).toBe(true); + connection.close(); + }); + test("records provider request results through direct execution adapters", async () => { const connection = createConnection(); const organization = new OrganizationService(connection.db);