Add provider credential revocation

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 19:39:18 -04:00
1 parent 203a48060e
commit 9e44cbb31b
9 files changed
+128 -1

No files matched your search

@@ -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 {
@@ -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 {
@@ -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<void> {
const provider = this.loadConfiguredProvider(orgId, providerId);
const adapter = this.requireAdapter(provider.provider_key, provider.auth_type);
+1
View File
@@ -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),
@@ -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 }))}
/>
))}
</div>
@@ -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({
</div>
<Button onClick={onCheck}>Check</Button>
<Button onClick={onManage}>Manage</Button>
<Button tone="ghost" onClick={onRevoke}>Revoke</Button>
</div>
);
}
+5
View File
@@ -141,6 +141,11 @@ export interface SaveProviderCredentialInput {
model: string | null;
}
export interface RevokeProviderCredentialInput {
orgId: string;
providerId: string;
}
export interface LaunchProviderSignInInput {
orgId: string;
providerId: string;
@@ -31,6 +31,7 @@ import type {
RunChaosDrillInput,
RunEvalSuiteInput,
RunSdlcReviewInput,
RevokeProviderCredentialInput,
SaveProviderCredentialInput,
SearchInput,
SendMessageInput,
@@ -68,6 +69,7 @@ export interface DesktopApi {
sendMessage(input: SendMessageInput): Promise<DesktopResult<AppSnapshot>>;
markChatSpaceRead(input: MarkChatSpaceReadInput): Promise<DesktopResult<AppSnapshot>>;
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
revokeProviderCredential(input: RevokeProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
launchProviderSignIn(input: LaunchProviderSignInInput): Promise<DesktopResult<null>>;
checkProviderHealth(input: CheckProviderHealthInput): Promise<DesktopResult<AppSnapshot>>;
updateProviderPolicy(input: UpdateProviderPolicyInput): Promise<DesktopResult<AppSnapshot>>;
+1
View File
@@ -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",
@@ -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);