Add provider credential revocation
This commit is contained in:
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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user