From 9e44cbb31b32c2d5b205cd8b3d28a11f6a607c4c 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:39:18 -0400 Subject: [PATCH] Add provider credential revocation --- AgentCompany-New/src/main/app/ipc-handlers.ts | 1 + .../src/main/services/auth-broker-service.ts | 33 ++++++++++++ .../src/main/services/provider-service.ts | 31 ++++++++++- AgentCompany-New/src/preload/index.ts | 1 + .../src/pages/settings/ProvidersPanel.tsx | 4 ++ AgentCompany-New/src/shared/commands.ts | 5 ++ AgentCompany-New/src/shared/desktop-api.ts | 2 + AgentCompany-New/src/shared/ipc.ts | 1 + .../tests/unit/provider-service.test.ts | 51 +++++++++++++++++++ 9 files changed, 128 insertions(+), 1 deletion(-) diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index f30e3d60..0912d9c7 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -94,6 +94,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.sendMessage, (_event, input) => mutate(() => chat.sendMessage(input))); ipcMain.handle(IPC_CHANNELS.markChatSpaceRead, (_event, input) => mutate(() => chat.markSpaceRead(input))); ipcMain.handle(IPC_CHANNELS.saveProviderCredential, (_event, input) => mutate(() => providers.saveCredential(input))); + ipcMain.handle(IPC_CHANNELS.revokeProviderCredential, (_event, input) => mutate(() => providers.revokeCredential(input))); ipcMain.handle(IPC_CHANNELS.updateProviderPolicy, (_event, input) => mutate(() => providers.updatePolicy(input))); ipcMain.handle(IPC_CHANNELS.checkProviderHealth, async (_event, input) => { try { diff --git a/AgentCompany-New/src/main/services/auth-broker-service.ts b/AgentCompany-New/src/main/services/auth-broker-service.ts index db8416fb..52a9e4dc 100644 --- a/AgentCompany-New/src/main/services/auth-broker-service.ts +++ b/AgentCompany-New/src/main/services/auth-broker-service.ts @@ -28,6 +28,12 @@ export interface ProviderAuthProfileRef { displayHint: string; } +export interface RevokeProviderAuthProfileInput { + orgId: string; + providerId: string; + providerKey: string; +} + export class AuthBrokerService { private readonly audit: AuditService; @@ -118,6 +124,33 @@ export class AuthBrokerService { if (!row) throw new Error("Provider credential was not found."); return this.vault.decryptSecret(Buffer.from(row.ciphertext)); } + + revokeProviderProfile(input: RevokeProviderAuthProfileInput): void { + const row = this.db.prepare(` + select id, credential_id + from provider_auth_profiles + where org_id = ? + and provider_id = ? + and provider_key = ? + and active = 1 + `).get(input.orgId, input.providerId, input.providerKey) as { id: string; credential_id: string } | undefined; + if (!row) throw new Error("Provider credential was not found."); + this.db.prepare("update provider_auth_profiles set active = 0 where id = ? and org_id = ?") + .run(row.id, input.orgId); + this.audit.record({ + orgId: input.orgId, + entityType: "provider_auth_profile", + entityId: row.id, + actorType: "system", + actorId: null, + action: "revoked", + payload: { + providerId: input.providerId, + providerKey: input.providerKey, + credentialId: row.credential_id, + }, + }); + } } function credentialDisplayHint(secret: string): string { diff --git a/AgentCompany-New/src/main/services/provider-service.ts b/AgentCompany-New/src/main/services/provider-service.ts index 7149f5a9..dd197538 100644 --- a/AgentCompany-New/src/main/services/provider-service.ts +++ b/AgentCompany-New/src/main/services/provider-service.ts @@ -1,7 +1,7 @@ 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 type { CreateProviderResponseInput, RevokeProviderCredentialInput, 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 ProviderAuthType, type ProviderExecutionMode } from "../../shared/domain"; import { PROVIDER_CATALOG } from "../../shared/provider-catalog"; import { currentProviderUsageWindow } from "../../shared/provider-runtime-domain"; @@ -13,6 +13,7 @@ import { SecretVault } from "./secret-vault"; import { providerAdapters, type ProviderAdapterRegistry } from "./provider-adapters"; const AUTH_STATE_CONFIGURED = "configured"; +const AUTH_STATE_REVOKED = "revoked"; const HEALTH_STATE_HEALTHY = "healthy"; const HEALTH_STATE_FAILED = "failed"; const HEALTH_STATE_UNCHECKED = "unchecked"; @@ -69,6 +70,34 @@ export class ProviderService { }); } + revokeCredential(input: RevokeProviderCredentialInput): void { + const provider = this.db.prepare(` + select id, provider_key + from providers + where id = ? and org_id = ? + `).get(input.providerId, input.orgId) as { id: string; provider_key: string } | undefined; + if (!provider) throw new Error("Provider was not found."); + this.authBroker.revokeProviderProfile({ + orgId: input.orgId, + providerId: input.providerId, + providerKey: provider.provider_key, + }); + this.db.prepare(` + update providers + set credential_id = null, enabled = 0, auth_state = ?, health_state = ?, last_error = null, last_checked_at = null + where id = ? and org_id = ? + `).run(AUTH_STATE_REVOKED, HEALTH_STATE_UNCHECKED, input.providerId, input.orgId); + this.audit.record({ + orgId: input.orgId, + entityType: "provider", + entityId: input.providerId, + actorType: "system", + actorId: null, + action: "credential_revoked", + payload: { providerKey: provider.provider_key }, + }); + } + async checkHealth(orgId: string, providerId: string): Promise { const provider = this.loadConfiguredProvider(orgId, providerId); const adapter = this.requireAdapter(provider.provider_key, provider.auth_type); diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index bf06160d..edbfeef1 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -23,6 +23,7 @@ const api: DesktopApi = { sendMessage: (input) => invoke(IPC_CHANNELS.sendMessage, input), markChatSpaceRead: (input) => invoke(IPC_CHANNELS.markChatSpaceRead, input), saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input), + revokeProviderCredential: (input) => invoke(IPC_CHANNELS.revokeProviderCredential, input), launchProviderSignIn: (input) => invoke(IPC_CHANNELS.launchProviderSignIn, input), checkProviderHealth: (input) => invoke(IPC_CHANNELS.checkProviderHealth, input), updateProviderPolicy: (input) => invoke(IPC_CHANNELS.updateProviderPolicy, input), diff --git a/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx b/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx index 15c360ee..67ef6423 100644 --- a/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx +++ b/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx @@ -76,6 +76,7 @@ export function ProvidersPanel({ orgId, providers, catalog, agents, providerRate agents={agents} onCheck={() => void actions.run(() => window.agentCompany.checkProviderHealth({ orgId, providerId: provider.id }))} onManage={() => setActive(provider)} + onRevoke={() => void actions.run(() => window.agentCompany.revokeProviderCredential({ orgId, providerId: provider.id }))} /> ))} @@ -364,11 +365,13 @@ function ProviderRow({ agents, onCheck, onManage, + onRevoke, }: { provider: ProviderRecord; agents: AgentRecord[]; onCheck: () => void; onManage: () => void; + onRevoke: () => void; }) { const boundCount = agents.filter((agent) => agent.modelProviderId === provider.id).length; return ( @@ -397,6 +400,7 @@ function ProviderRow({ + ); } diff --git a/AgentCompany-New/src/shared/commands.ts b/AgentCompany-New/src/shared/commands.ts index 0b1fc18d..89b442fe 100644 --- a/AgentCompany-New/src/shared/commands.ts +++ b/AgentCompany-New/src/shared/commands.ts @@ -141,6 +141,11 @@ export interface SaveProviderCredentialInput { model: string | null; } +export interface RevokeProviderCredentialInput { + orgId: string; + providerId: string; +} + export interface LaunchProviderSignInInput { orgId: string; providerId: string; diff --git a/AgentCompany-New/src/shared/desktop-api.ts b/AgentCompany-New/src/shared/desktop-api.ts index 80a61386..9d7e9989 100644 --- a/AgentCompany-New/src/shared/desktop-api.ts +++ b/AgentCompany-New/src/shared/desktop-api.ts @@ -31,6 +31,7 @@ import type { RunChaosDrillInput, RunEvalSuiteInput, RunSdlcReviewInput, + RevokeProviderCredentialInput, SaveProviderCredentialInput, SearchInput, SendMessageInput, @@ -68,6 +69,7 @@ export interface DesktopApi { sendMessage(input: SendMessageInput): Promise>; markChatSpaceRead(input: MarkChatSpaceReadInput): Promise>; saveProviderCredential(input: SaveProviderCredentialInput): Promise>; + revokeProviderCredential(input: RevokeProviderCredentialInput): Promise>; launchProviderSignIn(input: LaunchProviderSignInInput): Promise>; checkProviderHealth(input: CheckProviderHealthInput): Promise>; updateProviderPolicy(input: UpdateProviderPolicyInput): Promise>; diff --git a/AgentCompany-New/src/shared/ipc.ts b/AgentCompany-New/src/shared/ipc.ts index 5aba717d..01a6624e 100644 --- a/AgentCompany-New/src/shared/ipc.ts +++ b/AgentCompany-New/src/shared/ipc.ts @@ -15,6 +15,7 @@ export const IPC_CHANNELS = { sendMessage: "agentcompany:send-message", markChatSpaceRead: "agentcompany:mark-chat-space-read", saveProviderCredential: "agentcompany:save-provider-credential", + revokeProviderCredential: "agentcompany:revoke-provider-credential", launchProviderSignIn: "agentcompany:launch-provider-sign-in", checkProviderHealth: "agentcompany:check-provider-health", updateProviderPolicy: "agentcompany:update-provider-policy", diff --git a/AgentCompany-New/tests/unit/provider-service.test.ts b/AgentCompany-New/tests/unit/provider-service.test.ts index 9cd85bd9..54d86a6b 100644 --- a/AgentCompany-New/tests/unit/provider-service.test.ts +++ b/AgentCompany-New/tests/unit/provider-service.test.ts @@ -81,6 +81,57 @@ describe("provider service", () => { connection.close(); }); + test("revokes active auth broker profiles and disables provider credentials", async () => { + 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", model: "gpt-5.4" }); + providers.revokeCredential({ orgId, providerId: openAiProviderId }); + }); + + await expect(providers.createResponse({ + orgId, + providerId: openAiProviderId, + prompt: "Draft board memo", + instructions: null, + })).rejects.toThrow("Provider credential is required."); + + const profileRows = connection.db.prepare(` + select active + from provider_auth_profiles + where org_id = ? and provider_id = ? + `).all(orgId, openAiProviderId) as Array<{ active: number }>; + const credentialCount = connection.db.prepare(` + select count(*) as count + from credentials + where org_id = ? and provider_key = 'openai' + `).get(orgId) as { count: number }; + const state = snapshot.load(); + const provider = state.providers.find((item) => item.id === openAiProviderId); + + expect(profileRows).toEqual([{ active: 0 }]); + expect(credentialCount.count).toBe(1); + expect(provider).toMatchObject({ + credentialId: null, + enabled: false, + authState: "revoked", + healthState: "unchecked", + lastError: null, + lastCheckedAt: null, + }); + expect(state.providerAuthProfiles.find((profile) => profile.providerId === openAiProviderId)?.active).toBe(false); + expect(state.auditEvents.some((event) => event.entityType === "provider_auth_profile" && event.action === "revoked")).toBe(true); + expect(state.auditEvents.some((event) => event.entityType === "provider" && event.action === "credential_revoked")).toBe(true); + connection.close(); + }); + test("checks provider health through its adapter", async () => { const connection = createConnection(); const organization = new OrganizationService(connection.db);