From c6be8a4ee44599c7a6fb20f0406b22a026d03fc4 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 19:23:55 -0400 Subject: [PATCH] Add provider auth broker profiles --- .../migrations/021-provider-auth-broker.ts | 59 ++++++++ AgentCompany-New/src/main/database/schema.ts | 2 + .../src/main/services/auth-broker-service.ts | 128 ++++++++++++++++++ .../src/main/services/provider-service.ts | 74 +++++----- .../src/main/services/snapshot-service.ts | 3 + AgentCompany-New/src/shared/domain.ts | 3 + .../src/shared/provider-auth-domain.ts | 17 +++ .../tests/unit/domain-services.test.ts | 2 +- .../provider-auth-broker-migration.test.ts | 79 +++++++++++ .../tests/unit/provider-service.test.ts | 40 ++++++ 10 files changed, 370 insertions(+), 37 deletions(-) create mode 100644 AgentCompany-New/src/main/database/migrations/021-provider-auth-broker.ts create mode 100644 AgentCompany-New/src/main/services/auth-broker-service.ts create mode 100644 AgentCompany-New/src/shared/provider-auth-domain.ts create mode 100644 AgentCompany-New/tests/unit/provider-auth-broker-migration.test.ts diff --git a/AgentCompany-New/src/main/database/migrations/021-provider-auth-broker.ts b/AgentCompany-New/src/main/database/migrations/021-provider-auth-broker.ts new file mode 100644 index 00000000..be51159b --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/021-provider-auth-broker.ts @@ -0,0 +1,59 @@ +export const providerAuthBrokerSchema = ` + create table if not exists provider_auth_profiles ( + 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, + credential_id text not null references credentials(id) on delete restrict, + provider_key text not null, + auth_type text not null, + profile_kind text not null, + display_hint text not null, + source_type text not null, + source_ref text, + capability_json text not null, + active integer not null check(active in (0, 1)), + created_at text not null + ); + + create index if not exists idx_provider_auth_profiles_org_provider + on provider_auth_profiles(org_id, provider_id, created_at); + create index if not exists idx_provider_auth_profiles_credential + on provider_auth_profiles(credential_id); + create unique index if not exists idx_provider_auth_profiles_active + on provider_auth_profiles(provider_id) where active = 1; + + insert into provider_auth_profiles( + id, org_id, provider_id, credential_id, provider_key, auth_type, + profile_kind, display_hint, source_type, source_ref, capability_json, + active, created_at + ) + select + 'authp_' || lower(hex(randomblob(16))), + providers.org_id, + providers.id, + credentials.id, + providers.provider_key, + providers.auth_type, + providers.auth_type, + credentials.display_hint, + 'migrated_credential', + null, + '{"providerKey":"' || providers.provider_key || '","authType":"' || providers.auth_type || '","capabilityTags":[]}', + 1, + credentials.created_at + from providers + join credentials + on credentials.id = providers.credential_id + and credentials.org_id = providers.org_id + and credentials.provider_key = providers.provider_key + where providers.credential_id is not null + and not exists ( + select 1 + from provider_auth_profiles existing_profiles + where existing_profiles.provider_id = providers.id + and existing_profiles.active = 1 + ); + + insert into schema_migrations(version, name, applied_at) + values (21, 'provider_auth_broker', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index a1a632c0..05d5c73d 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -19,6 +19,7 @@ import { operationsControlsSchema } from "./migrations/017-operations-controls"; import { sdlcControlsSchema } from "./migrations/018-sdlc-controls"; import { auditReplaySchema } from "./migrations/019-audit-replay"; import { rollbackSnapshotsSchema } from "./migrations/020-rollback-snapshots"; +import { providerAuthBrokerSchema } from "./migrations/021-provider-auth-broker"; interface Migration { version: number; @@ -47,6 +48,7 @@ const MIGRATIONS: Migration[] = [ { version: 18, name: "sdlc_controls", sql: sdlcControlsSchema }, { version: 19, name: "audit_replay", sql: auditReplaySchema }, { version: 20, name: "rollback_snapshots", sql: rollbackSnapshotsSchema }, + { version: 21, name: "provider_auth_broker", sql: providerAuthBrokerSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/auth-broker-service.ts b/AgentCompany-New/src/main/services/auth-broker-service.ts new file mode 100644 index 00000000..db8416fb --- /dev/null +++ b/AgentCompany-New/src/main/services/auth-broker-service.ts @@ -0,0 +1,128 @@ +import type { DatabaseSync } from "node:sqlite"; +import { createHash } from "node:crypto"; +import type { ProviderAuthType } from "../../shared/domain"; +import { createId } from "../../shared/ids"; +import { AuditService } from "./audit-service"; +import { SecretVault } from "./secret-vault"; + +export interface ProviderSecretVault { + encryptSecret(secret: string): Buffer; + decryptSecret(ciphertext: Buffer): string; +} + +export interface CreateProviderAuthProfileInput { + orgId: string; + providerId: string; + providerKey: string; + authType: ProviderAuthType; + secret: string; + profileKind: string; + sourceType: string; + sourceRef: string | null; + capabilityTags: string[]; +} + +export interface ProviderAuthProfileRef { + authProfileId: string; + credentialId: string; + displayHint: string; +} + +export class AuthBrokerService { + private readonly audit: AuditService; + + constructor( + private readonly db: DatabaseSync, + private readonly vault: ProviderSecretVault = new SecretVault(), + ) { + this.audit = new AuditService(db); + } + + createProviderProfile(input: CreateProviderAuthProfileInput): ProviderAuthProfileRef { + const secret = input.secret.trim(); + if (!secret) throw new Error("Credential is required."); + const credentialId = createId("cred"); + const authProfileId = createId("authp"); + const displayHint = credentialDisplayHint(secret); + const now = new Date().toISOString(); + const capabilityJson = JSON.stringify({ + providerKey: input.providerKey, + authType: input.authType, + capabilityTags: input.capabilityTags, + }); + + this.db.prepare(` + insert into credentials(id, org_id, provider_key, kind, ciphertext, display_hint, created_at) + values (?, ?, ?, ?, ?, ?, ?) + `).run(credentialId, input.orgId, input.providerKey, input.authType, this.vault.encryptSecret(secret), displayHint, now); + this.db.prepare(` + update provider_auth_profiles + set active = 0 + where org_id = ? and provider_id = ? and active = 1 + `).run(input.orgId, input.providerId); + this.db.prepare(` + insert into provider_auth_profiles( + id, org_id, provider_id, credential_id, provider_key, auth_type, + profile_kind, display_hint, source_type, source_ref, capability_json, + active, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) + `).run( + authProfileId, + input.orgId, + input.providerId, + credentialId, + input.providerKey, + input.authType, + input.profileKind, + displayHint, + input.sourceType, + input.sourceRef, + capabilityJson, + now, + ); + this.audit.record({ + orgId: input.orgId, + entityType: "provider_auth_profile", + entityId: authProfileId, + actorType: "system", + actorId: null, + action: "created", + payload: { + providerId: input.providerId, + providerKey: input.providerKey, + authType: input.authType, + profileKind: input.profileKind, + }, + }); + return { authProfileId, credentialId, displayHint }; + } + + loadProviderSecret(input: { + orgId: string; + providerId: string; + providerKey: string; + credentialId: string; + }): string { + const row = this.db.prepare(` + select credentials.ciphertext + from provider_auth_profiles + join credentials on credentials.id = provider_auth_profiles.credential_id + where provider_auth_profiles.org_id = ? + and provider_auth_profiles.provider_id = ? + and provider_auth_profiles.provider_key = ? + and provider_auth_profiles.credential_id = ? + and provider_auth_profiles.active = 1 + and credentials.org_id = provider_auth_profiles.org_id + and credentials.provider_key = provider_auth_profiles.provider_key + `).get(input.orgId, input.providerId, input.providerKey, input.credentialId) as { ciphertext: Uint8Array } | undefined; + if (!row) throw new Error("Provider credential was not found."); + return this.vault.decryptSecret(Buffer.from(row.ciphertext)); + } +} + +function credentialDisplayHint(secret: string): string { + const clean = secret.trim(); + if (clean.startsWith("{")) return `json:${createHash("sha256").update(clean).digest("hex").slice(0, 10)}`; + if (clean.length <= 10) return `${clean.slice(0, 2)}...`; + return `${clean.slice(0, 6)}...${clean.slice(-4)}`; +} diff --git a/AgentCompany-New/src/main/services/provider-service.ts b/AgentCompany-New/src/main/services/provider-service.ts index 4d9c08ac..d3dabff8 100644 --- a/AgentCompany-New/src/main/services/provider-service.ts +++ b/AgentCompany-New/src/main/services/provider-service.ts @@ -2,10 +2,11 @@ import type { DatabaseSync } from "node:sqlite"; import { createHash } from "node:crypto"; import { BrowserWindow, session } from "electron"; import type { CreateProviderResponseInput, SaveProviderCredentialInput, UpdateProviderPolicyInput } from "../../shared/commands"; -import { FEATURE_FLAG_KEY, PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, PROVIDER_REQUEST_STATE, SLO_SIGNAL_KEY, type ProviderExecutionMode } from "../../shared/domain"; +import { FEATURE_FLAG_KEY, PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, PROVIDER_REQUEST_STATE, SLO_SIGNAL_KEY, type ProviderAuthType, type ProviderExecutionMode } from "../../shared/domain"; import { PROVIDER_CATALOG } from "../../shared/provider-catalog"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; +import { AuthBrokerService, type ProviderSecretVault } from "./auth-broker-service"; import { OperationsService } from "./operations-service"; import { SecretVault } from "./secret-vault"; import { providerAdapters, type ProviderAdapterRegistry } from "./provider-adapters"; @@ -16,52 +17,46 @@ const HEALTH_STATE_FAILED = "failed"; const HEALTH_STATE_UNCHECKED = "unchecked"; const WORKSPACE_SCOPES = new Set(["organization", "workspace"]); -function displayHint(secret: string): string { - const clean = secret.trim(); - if (clean.length <= 10) return `${clean.slice(0, 2)}...`; - return `${clean.slice(0, 6)}...${clean.slice(-4)}`; -} - export class ProviderService { private readonly audit: AuditService; + private readonly authBroker: AuthBrokerService; private readonly operations: OperationsService; constructor( private readonly db: DatabaseSync, private readonly adapters: ProviderAdapterRegistry = providerAdapters, - private readonly vault: SecretVault = new SecretVault(), + vault: ProviderSecretVault = new SecretVault(), ) { this.audit = new AuditService(db); + this.authBroker = new AuthBrokerService(db, vault); this.operations = new OperationsService(db); } saveCredential(input: SaveProviderCredentialInput): void { const provider = this.db.prepare("select * from providers where id = ? and org_id = ?") - .get(input.providerId, input.orgId) as { provider_key: string; auth_type: string } | undefined; + .get(input.providerId, input.orgId) as { provider_key: string; auth_type: ProviderAuthType } | undefined; if (!provider) throw new Error("Provider was not found."); const secret = input.secret.trim(); if (!secret) throw new Error("Credential is required."); const adapter = this.requireAdapter(provider.provider_key, provider.auth_type); adapter.parseAuthProfile(secret); - const credentialId = createId("cred"); - const now = new Date().toISOString(); - this.db.prepare(` - insert into credentials(id, org_id, provider_key, kind, ciphertext, display_hint, created_at) - values (?, ?, ?, ?, ?, ?, ?) - `).run( - credentialId, - input.orgId, - provider.provider_key, - provider.auth_type, - this.vault.encryptSecret(secret), - displayHint(secret), - now, - ); + const catalogEntry = this.requireCatalogEntry(provider.provider_key); + const authProfile = this.authBroker.createProviderProfile({ + orgId: input.orgId, + providerId: input.providerId, + providerKey: provider.provider_key, + authType: provider.auth_type, + secret, + profileKind: provider.auth_type, + sourceType: "manual_secret", + sourceRef: null, + capabilityTags: catalogEntry.capabilityTags, + }); this.db.prepare(` update providers set credential_id = ?, model = ?, enabled = 1, auth_state = ?, health_state = ?, last_error = null, last_checked_at = null where id = ? and org_id = ? - `).run(credentialId, input.model, AUTH_STATE_CONFIGURED, HEALTH_STATE_UNCHECKED, input.providerId, input.orgId); + `).run(authProfile.credentialId, input.model, AUTH_STATE_CONFIGURED, HEALTH_STATE_UNCHECKED, input.providerId, input.orgId); this.audit.record({ orgId: input.orgId, entityType: "provider", @@ -69,7 +64,7 @@ export class ProviderService { actorType: "system", actorId: null, action: "credential_saved", - payload: { providerKey: provider.provider_key, authType: provider.auth_type }, + payload: { providerKey: provider.provider_key, authType: provider.auth_type, authProfileId: authProfile.authProfileId }, }); } @@ -78,7 +73,7 @@ export class ProviderService { const adapter = this.requireAdapter(provider.provider_key, provider.auth_type); const credential = this.loadCredential(orgId, provider); try { - const profile = adapter.parseAuthProfile(this.vault.decryptSecret(Buffer.from(credential.ciphertext))); + const profile = adapter.parseAuthProfile(credential.secret); await adapter.checkHealth({ authProfile: profile, model: provider.model }); this.recordProviderHealth(provider.id, orgId, HEALTH_STATE_HEALTHY, null); this.audit.record({ @@ -213,7 +208,7 @@ export class ProviderService { ); try { - const authProfile = adapter.parseAuthProfile(this.vault.decryptSecret(Buffer.from(credential.ciphertext))); + const authProfile = adapter.parseAuthProfile(credential.secret); const result = await adapter.createResponse({ authProfile, model: provider.model, @@ -299,15 +294,16 @@ export class ProviderService { return provider; } - private loadCredential(orgId: string, provider: ProviderRow): { ciphertext: Uint8Array } { + private loadCredential(orgId: string, provider: ProviderRow): { secret: string } { if (!provider.credential_id) throw new Error("Provider credential is required."); - const credential = this.db.prepare(` - select ciphertext - from credentials - where id = ? and org_id = ? and provider_key = ? - `).get(provider.credential_id, orgId, provider.provider_key) as { ciphertext: Uint8Array } | undefined; - if (!credential) throw new Error("Provider credential was not found."); - return credential; + return { + secret: this.authBroker.loadProviderSecret({ + orgId, + providerId: provider.id, + providerKey: provider.provider_key, + credentialId: provider.credential_id, + }), + }; } private requireAdapter(providerKey: string, authType: string): ProviderAdapterRegistry[string] { @@ -317,6 +313,12 @@ export class ProviderService { return adapter; } + private requireCatalogEntry(providerKey: string): (typeof PROVIDER_CATALOG)[number] { + const entry = PROVIDER_CATALOG.find((item) => item.key === providerKey); + if (!entry) throw new Error(`Provider catalog entry missing for ${providerKey}.`); + return entry; + } + private attachNavigationPolicy(win: BrowserWindow, signInUrl: string): void { const allowedHost = new URL(signInUrl).hostname.toLowerCase(); const allow = (url: string) => { @@ -358,7 +360,7 @@ export class ProviderService { interface ProviderRow { id: string; provider_key: string; - auth_type: string; + auth_type: ProviderAuthType; model: string | null; credential_id: string | null; enabled: number; diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index 81fd7607..1596948d 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -43,6 +43,8 @@ export class SnapshotService { .map((workspace) => ({ ...workspace, isPrimary: toBool(workspace.isPrimary) })); const providers = list(this.db, "providers", "label asc") .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 skillBindings = list(this.db, "skill_bindings", "priority asc") .map((binding) => ({ ...binding, enabled: toBool(binding.enabled) })); const files = list(this.db, "file_objects", "created_at desc") @@ -90,6 +92,7 @@ export class SnapshotService { fileAccessGrants: list(this.db, "file_access_grants", "created_at desc"), scheduledPosts: list(this.db, "scheduled_posts", "scheduled_for asc"), providers, + providerAuthProfiles, providerCatalog: PROVIDER_CATALOG, providerRequests: list(this.db, "provider_requests", "created_at desc"), skillDefinitions: list(this.db, "skill_definitions", "created_at asc"), diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index 6ac50f2d..de0ce057 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -4,6 +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 { ProviderDataPolicy, ProviderExecutionMode } from "./provider-policy-domain"; import type { RollbackSnapshotRecord } from "./rollback-domain"; import type { EvalCaseRecord, EvalRunRecord, EvalSuiteRecord, PrBotGateRecord, SdlcReviewAgentRecord, SdlcReviewRunRecord } from "./sdlc-domain"; @@ -13,6 +14,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 { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, type ProviderDataPolicy, type ProviderExecutionMode } from "./provider-policy-domain"; export type { RollbackSnapshotRecord } from "./rollback-domain"; export { EVAL_SUITE_KEY, PR_BOT_GATE_KEY, SDLC_REVIEW_AGENT_KEY, SDLC_REVIEW_TARGET_TYPE, SDLC_RUN_STATE, type EvalCaseRecord, type EvalRunRecord, type EvalSuiteKey, type EvalSuiteRecord, type PrBotGateKey, type PrBotGateRecord, type SdlcReviewAgentKey, type SdlcReviewAgentRecord, type SdlcReviewRunRecord, type SdlcReviewTargetType, type SdlcRunState } from "./sdlc-domain"; @@ -465,6 +467,7 @@ export interface AppSnapshot { fileAccessGrants: FileAccessGrantRecord[]; scheduledPosts: ScheduledPostRecord[]; providers: ProviderRecord[]; + providerAuthProfiles: ProviderAuthProfileRecord[]; providerCatalog: ProviderCatalogEntry[]; providerRequests: ProviderRequestRecord[]; skillDefinitions: SkillDefinitionRecord[]; diff --git a/AgentCompany-New/src/shared/provider-auth-domain.ts b/AgentCompany-New/src/shared/provider-auth-domain.ts new file mode 100644 index 00000000..c5d65678 --- /dev/null +++ b/AgentCompany-New/src/shared/provider-auth-domain.ts @@ -0,0 +1,17 @@ +import type { ProviderAuthType } from "./domain"; + +export interface ProviderAuthProfileRecord { + id: string; + orgId: string; + providerId: string; + credentialId: string; + providerKey: string; + authType: ProviderAuthType; + profileKind: string; + displayHint: string; + sourceType: string; + sourceRef: string | null; + capabilityJson: string; + active: boolean; + createdAt: string; +} diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index e1bee669..b8b8776f 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]); + 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]); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/provider-auth-broker-migration.test.ts b/AgentCompany-New/tests/unit/provider-auth-broker-migration.test.ts new file mode 100644 index 00000000..f3cc83c9 --- /dev/null +++ b/AgentCompany-New/tests/unit/provider-auth-broker-migration.test.ts @@ -0,0 +1,79 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { describe, expect, test } from "vitest"; +import { providerAuthBrokerSchema } from "../../src/main/database/migrations/021-provider-auth-broker"; + +function createDatabase(): DatabaseSync { + return new DatabaseSync(join(tmpdir(), `agentcompany-auth-broker-migration-${randomUUID()}.db`)); +} + +describe("provider auth broker migration", () => { + test("backfills active auth profiles from configured credentials", () => { + const db = createDatabase(); + db.exec(` + create table schema_migrations(version integer primary key, name text not null, applied_at text not null); + create table organizations(id text primary key); + create table providers( + id text primary key, + org_id text not null, + provider_key text not null, + auth_type text not null, + credential_id text + ); + create table credentials( + id text primary key, + org_id text not null, + provider_key text not null, + kind text not null, + ciphertext blob not null, + display_hint text not null, + created_at text not null + ); + insert into schema_migrations(version, name, applied_at) values (20, 'rollback_snapshots', datetime('now')); + insert into organizations(id) values ('org_1'); + insert into credentials(id, org_id, provider_key, kind, ciphertext, display_hint, created_at) + values ('cred_1', 'org_1', 'openai', 'api_key', x'736563726574', 'sk-liv...ider', '2026-05-23T01:02:03.000Z'); + insert into providers(id, org_id, provider_key, auth_type, credential_id) + values ('prov_1', 'org_1', 'openai', 'api_key', 'cred_1'); + `); + + db.exec(providerAuthBrokerSchema); + + const profile = db.prepare(` + select org_id, provider_id, credential_id, provider_key, auth_type, + profile_kind, display_hint, source_type, capability_json, active, created_at + from provider_auth_profiles + `).get() as { + org_id: string; + provider_id: string; + credential_id: string; + provider_key: string; + auth_type: string; + profile_kind: string; + display_hint: string; + source_type: string; + capability_json: string; + active: number; + created_at: string; + }; + const migration = db.prepare("select version, name from schema_migrations where version = 21").get(); + + expect(profile).toMatchObject({ + org_id: "org_1", + provider_id: "prov_1", + credential_id: "cred_1", + provider_key: "openai", + auth_type: "api_key", + profile_kind: "api_key", + display_hint: "sk-liv...ider", + source_type: "migrated_credential", + active: 1, + created_at: "2026-05-23T01:02:03.000Z", + }); + expect(JSON.parse(profile.capability_json)).toEqual({ providerKey: "openai", authType: "api_key", capabilityTags: [] }); + expect(migration).toEqual({ version: 21, name: "provider_auth_broker" }); + db.close(); + }); +}); diff --git a/AgentCompany-New/tests/unit/provider-service.test.ts b/AgentCompany-New/tests/unit/provider-service.test.ts index ffa9a9a5..45efe3da 100644 --- a/AgentCompany-New/tests/unit/provider-service.test.ts +++ b/AgentCompany-New/tests/unit/provider-service.test.ts @@ -41,6 +41,46 @@ describe("provider service", () => { } }); + test("creates active auth broker profiles during credential rotation", () => { + 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 openAiProviderId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + openAiProviderId = providerId(connection, orgId, "openai"); + providers.saveCredential({ orgId, providerId: openAiProviderId, secret: "sk-live-provider-one", model: "gpt-5.4" }); + providers.saveCredential({ orgId, providerId: openAiProviderId, secret: "sk-live-provider-two", model: "gpt-5.4" }); + }); + + const rows = connection.db.prepare(` + select credential_id, display_hint, source_type, capability_json, active + from provider_auth_profiles + where org_id = ? and provider_id = ? + order by active desc, created_at desc + `).all(orgId, openAiProviderId) as Array<{ + credential_id: string; + display_hint: string; + source_type: string; + capability_json: string; + active: number; + }>; + const state = snapshot.load(); + const activeProfile = state.providerAuthProfiles.find((profile) => profile.providerId === openAiProviderId && profile.active); + const provider = state.providers.find((item) => item.id === openAiProviderId); + + expect(rows.map((row) => row.active)).toEqual([1, 0]); + expect(rows[0]).toMatchObject({ display_hint: "sk-liv...-two", source_type: "manual_secret" }); + expect(JSON.parse(rows[0].capability_json)).toMatchObject({ providerKey: "openai", capabilityTags: ["responses", "tools", "background"] }); + expect(provider?.credentialId).toBe(rows[0].credential_id); + expect(activeProfile?.credentialId).toBe(rows[0].credential_id); + expect(state.auditEvents.some((event) => event.entityType === "provider_auth_profile" && event.action === "created")).toBe(true); + connection.close(); + }); + test("checks provider health through its adapter", async () => { const connection = createConnection(); const organization = new OrganizationService(connection.db);