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 { sdlcControlsSchema } from "./migrations/018-sdlc-controls";
|
||||||
import { auditReplaySchema } from "./migrations/019-audit-replay";
|
import { auditReplaySchema } from "./migrations/019-audit-replay";
|
||||||
import { rollbackSnapshotsSchema } from "./migrations/020-rollback-snapshots";
|
import { rollbackSnapshotsSchema } from "./migrations/020-rollback-snapshots";
|
||||||
|
import { providerAuthBrokerSchema } from "./migrations/021-provider-auth-broker";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -47,6 +48,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
{ version: 18, name: "sdlc_controls", sql: sdlcControlsSchema },
|
{ version: 18, name: "sdlc_controls", sql: sdlcControlsSchema },
|
||||||
{ version: 19, name: "audit_replay", sql: auditReplaySchema },
|
{ version: 19, name: "audit_replay", sql: auditReplaySchema },
|
||||||
{ version: 20, name: "rollback_snapshots", sql: rollbackSnapshotsSchema },
|
{ version: 20, name: "rollback_snapshots", sql: rollbackSnapshotsSchema },
|
||||||
|
{ version: 21, name: "provider_auth_broker", sql: providerAuthBrokerSchema },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
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 { createHash } from "node:crypto";
|
||||||
import { BrowserWindow, session } from "electron";
|
import { BrowserWindow, session } from "electron";
|
||||||
import type { CreateProviderResponseInput, SaveProviderCredentialInput, UpdateProviderPolicyInput } from "../../shared/commands";
|
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 { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
||||||
import { createId } from "../../shared/ids";
|
import { createId } from "../../shared/ids";
|
||||||
import { AuditService } from "./audit-service";
|
import { AuditService } from "./audit-service";
|
||||||
|
import { AuthBrokerService, type ProviderSecretVault } from "./auth-broker-service";
|
||||||
import { OperationsService } from "./operations-service";
|
import { OperationsService } from "./operations-service";
|
||||||
import { SecretVault } from "./secret-vault";
|
import { SecretVault } from "./secret-vault";
|
||||||
import { providerAdapters, type ProviderAdapterRegistry } from "./provider-adapters";
|
import { providerAdapters, type ProviderAdapterRegistry } from "./provider-adapters";
|
||||||
@@ -16,52 +17,46 @@ const HEALTH_STATE_FAILED = "failed";
|
|||||||
const HEALTH_STATE_UNCHECKED = "unchecked";
|
const HEALTH_STATE_UNCHECKED = "unchecked";
|
||||||
const WORKSPACE_SCOPES = new Set(["organization", "workspace"]);
|
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 {
|
export class ProviderService {
|
||||||
private readonly audit: AuditService;
|
private readonly audit: AuditService;
|
||||||
|
private readonly authBroker: AuthBrokerService;
|
||||||
private readonly operations: OperationsService;
|
private readonly operations: OperationsService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly db: DatabaseSync,
|
private readonly db: DatabaseSync,
|
||||||
private readonly adapters: ProviderAdapterRegistry = providerAdapters,
|
private readonly adapters: ProviderAdapterRegistry = providerAdapters,
|
||||||
private readonly vault: SecretVault = new SecretVault(),
|
vault: ProviderSecretVault = new SecretVault(),
|
||||||
) {
|
) {
|
||||||
this.audit = new AuditService(db);
|
this.audit = new AuditService(db);
|
||||||
|
this.authBroker = new AuthBrokerService(db, vault);
|
||||||
this.operations = new OperationsService(db);
|
this.operations = new OperationsService(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveCredential(input: SaveProviderCredentialInput): void {
|
saveCredential(input: SaveProviderCredentialInput): void {
|
||||||
const provider = this.db.prepare("select * from providers where id = ? and org_id = ?")
|
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.");
|
if (!provider) throw new Error("Provider was not found.");
|
||||||
const secret = input.secret.trim();
|
const secret = input.secret.trim();
|
||||||
if (!secret) throw new Error("Credential is required.");
|
if (!secret) throw new Error("Credential is required.");
|
||||||
const adapter = this.requireAdapter(provider.provider_key, provider.auth_type);
|
const adapter = this.requireAdapter(provider.provider_key, provider.auth_type);
|
||||||
adapter.parseAuthProfile(secret);
|
adapter.parseAuthProfile(secret);
|
||||||
const credentialId = createId("cred");
|
const catalogEntry = this.requireCatalogEntry(provider.provider_key);
|
||||||
const now = new Date().toISOString();
|
const authProfile = this.authBroker.createProviderProfile({
|
||||||
this.db.prepare(`
|
orgId: input.orgId,
|
||||||
insert into credentials(id, org_id, provider_key, kind, ciphertext, display_hint, created_at)
|
providerId: input.providerId,
|
||||||
values (?, ?, ?, ?, ?, ?, ?)
|
providerKey: provider.provider_key,
|
||||||
`).run(
|
authType: provider.auth_type,
|
||||||
credentialId,
|
secret,
|
||||||
input.orgId,
|
profileKind: provider.auth_type,
|
||||||
provider.provider_key,
|
sourceType: "manual_secret",
|
||||||
provider.auth_type,
|
sourceRef: null,
|
||||||
this.vault.encryptSecret(secret),
|
capabilityTags: catalogEntry.capabilityTags,
|
||||||
displayHint(secret),
|
});
|
||||||
now,
|
|
||||||
);
|
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
update providers
|
update providers
|
||||||
set credential_id = ?, model = ?, enabled = 1, auth_state = ?, health_state = ?, last_error = null, last_checked_at = null
|
set credential_id = ?, model = ?, enabled = 1, auth_state = ?, health_state = ?, last_error = null, last_checked_at = null
|
||||||
where id = ? and org_id = ?
|
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({
|
this.audit.record({
|
||||||
orgId: input.orgId,
|
orgId: input.orgId,
|
||||||
entityType: "provider",
|
entityType: "provider",
|
||||||
@@ -69,7 +64,7 @@ export class ProviderService {
|
|||||||
actorType: "system",
|
actorType: "system",
|
||||||
actorId: null,
|
actorId: null,
|
||||||
action: "credential_saved",
|
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 adapter = this.requireAdapter(provider.provider_key, provider.auth_type);
|
||||||
const credential = this.loadCredential(orgId, provider);
|
const credential = this.loadCredential(orgId, provider);
|
||||||
try {
|
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 });
|
await adapter.checkHealth({ authProfile: profile, model: provider.model });
|
||||||
this.recordProviderHealth(provider.id, orgId, HEALTH_STATE_HEALTHY, null);
|
this.recordProviderHealth(provider.id, orgId, HEALTH_STATE_HEALTHY, null);
|
||||||
this.audit.record({
|
this.audit.record({
|
||||||
@@ -213,7 +208,7 @@ export class ProviderService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authProfile = adapter.parseAuthProfile(this.vault.decryptSecret(Buffer.from(credential.ciphertext)));
|
const authProfile = adapter.parseAuthProfile(credential.secret);
|
||||||
const result = await adapter.createResponse({
|
const result = await adapter.createResponse({
|
||||||
authProfile,
|
authProfile,
|
||||||
model: provider.model,
|
model: provider.model,
|
||||||
@@ -299,15 +294,16 @@ export class ProviderService {
|
|||||||
return provider;
|
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.");
|
if (!provider.credential_id) throw new Error("Provider credential is required.");
|
||||||
const credential = this.db.prepare(`
|
return {
|
||||||
select ciphertext
|
secret: this.authBroker.loadProviderSecret({
|
||||||
from credentials
|
orgId,
|
||||||
where id = ? and org_id = ? and provider_key = ?
|
providerId: provider.id,
|
||||||
`).get(provider.credential_id, orgId, provider.provider_key) as { ciphertext: Uint8Array } | undefined;
|
providerKey: provider.provider_key,
|
||||||
if (!credential) throw new Error("Provider credential was not found.");
|
credentialId: provider.credential_id,
|
||||||
return credential;
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private requireAdapter(providerKey: string, authType: string): ProviderAdapterRegistry[string] {
|
private requireAdapter(providerKey: string, authType: string): ProviderAdapterRegistry[string] {
|
||||||
@@ -317,6 +313,12 @@ export class ProviderService {
|
|||||||
return adapter;
|
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 {
|
private attachNavigationPolicy(win: BrowserWindow, signInUrl: string): void {
|
||||||
const allowedHost = new URL(signInUrl).hostname.toLowerCase();
|
const allowedHost = new URL(signInUrl).hostname.toLowerCase();
|
||||||
const allow = (url: string) => {
|
const allow = (url: string) => {
|
||||||
@@ -358,7 +360,7 @@ export class ProviderService {
|
|||||||
interface ProviderRow {
|
interface ProviderRow {
|
||||||
id: string;
|
id: string;
|
||||||
provider_key: string;
|
provider_key: string;
|
||||||
auth_type: string;
|
auth_type: ProviderAuthType;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
credential_id: string | null;
|
credential_id: string | null;
|
||||||
enabled: number;
|
enabled: number;
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export class SnapshotService {
|
|||||||
.map((workspace) => ({ ...workspace, isPrimary: toBool(workspace.isPrimary) }));
|
.map((workspace) => ({ ...workspace, isPrimary: toBool(workspace.isPrimary) }));
|
||||||
const providers = list<AppSnapshot["providers"][number]>(this.db, "providers", "label asc")
|
const providers = list<AppSnapshot["providers"][number]>(this.db, "providers", "label asc")
|
||||||
.map((provider) => ({ ...provider, enabled: toBool(provider.enabled), backgroundEnabled: toBool(provider.backgroundEnabled) }));
|
.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")
|
const skillBindings = list<AppSnapshot["skillBindings"][number]>(this.db, "skill_bindings", "priority asc")
|
||||||
.map((binding) => ({ ...binding, enabled: toBool(binding.enabled) }));
|
.map((binding) => ({ ...binding, enabled: toBool(binding.enabled) }));
|
||||||
const files = list<AppSnapshot["files"][number]>(this.db, "file_objects", "created_at desc")
|
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"),
|
fileAccessGrants: list(this.db, "file_access_grants", "created_at desc"),
|
||||||
scheduledPosts: list(this.db, "scheduled_posts", "scheduled_for asc"),
|
scheduledPosts: list(this.db, "scheduled_posts", "scheduled_for asc"),
|
||||||
providers,
|
providers,
|
||||||
|
providerAuthProfiles,
|
||||||
providerCatalog: PROVIDER_CATALOG,
|
providerCatalog: PROVIDER_CATALOG,
|
||||||
providerRequests: list(this.db, "provider_requests", "created_at desc"),
|
providerRequests: list(this.db, "provider_requests", "created_at desc"),
|
||||||
skillDefinitions: list(this.db, "skill_definitions", "created_at asc"),
|
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 { AccessDecisionRecord, EmployeeIdentityRecord, RoleGrantRecord, ServiceIdentityRecord } from "./identity-domain";
|
||||||
import type { ApprovalAssignmentRecord, ApprovalRoutingRuleRecord, HiringPolicyRecord, OrgDepartmentRecord } from "./org-governance-domain";
|
import type { ApprovalAssignmentRecord, ApprovalRoutingRuleRecord, HiringPolicyRecord, OrgDepartmentRecord } from "./org-governance-domain";
|
||||||
import type { ChaosDrillRecord, ChaosDrillRunRecord, FeatureFlagRecord, OperationalEventRecord, SloTargetRecord } from "./operations-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 { ProviderDataPolicy, ProviderExecutionMode } from "./provider-policy-domain";
|
||||||
import type { RollbackSnapshotRecord } from "./rollback-domain";
|
import type { RollbackSnapshotRecord } from "./rollback-domain";
|
||||||
import type { EvalCaseRecord, EvalRunRecord, EvalSuiteRecord, PrBotGateRecord, SdlcReviewAgentRecord, SdlcReviewRunRecord } from "./sdlc-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 { 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 { 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 { 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 { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, type ProviderDataPolicy, type ProviderExecutionMode } from "./provider-policy-domain";
|
||||||
export type { RollbackSnapshotRecord } from "./rollback-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";
|
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[];
|
fileAccessGrants: FileAccessGrantRecord[];
|
||||||
scheduledPosts: ScheduledPostRecord[];
|
scheduledPosts: ScheduledPostRecord[];
|
||||||
providers: ProviderRecord[];
|
providers: ProviderRecord[];
|
||||||
|
providerAuthProfiles: ProviderAuthProfileRecord[];
|
||||||
providerCatalog: ProviderCatalogEntry[];
|
providerCatalog: ProviderCatalogEntry[];
|
||||||
providerRequests: ProviderRequestRecord[];
|
providerRequests: ProviderRequestRecord[];
|
||||||
skillDefinitions: SkillDefinitionRecord[];
|
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 connection = createConnection();
|
||||||
|
|
||||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
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();
|
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 () => {
|
test("checks provider health through its adapter", async () => {
|
||||||
const connection = createConnection();
|
const connection = createConnection();
|
||||||
const organization = new OrganizationService(connection.db);
|
const organization = new OrganizationService(connection.db);
|
||||||
|
|||||||
Reference in new issue
Block a user