Add provider auth broker profiles
This commit is contained in:
1 parent
c862cd1319
commit
c6be8a4ee4
10 files changed
+370
-37
No files matched your search
@@ -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'));
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -43,6 +43,8 @@ export class SnapshotService {
|
||||
.map((workspace) => ({ ...workspace, isPrimary: toBool(workspace.isPrimary) }));
|
||||
const providers = list<AppSnapshot["providers"][number]>(this.db, "providers", "label asc")
|
||||
.map((provider) => ({ ...provider, enabled: toBool(provider.enabled), backgroundEnabled: toBool(provider.backgroundEnabled) }));
|
||||
const providerAuthProfiles = list<AppSnapshot["providerAuthProfiles"][number]>(this.db, "provider_auth_profiles", "created_at desc")
|
||||
.map((profile) => ({ ...profile, active: toBool(profile.active) }));
|
||||
const skillBindings = list<AppSnapshot["skillBindings"][number]>(this.db, "skill_bindings", "priority asc")
|
||||
.map((binding) => ({ ...binding, enabled: toBool(binding.enabled) }));
|
||||
const files = list<AppSnapshot["files"][number]>(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"),
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user