mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[codex] Provider vault secrets UX (#6381)
## Thinking Path > - Paperclip orchestrates AI agents that need scoped, auditable access to secrets > - Hosted and external deployments need provider vault configuration without exposing secret values in Paperclip metadata > - AWS Secrets Manager vault setup previously required too much manual operator knowledge > - Provider vault discovery and removal belong together as an independent secrets-management improvement > - This pull request adds AWS provider vault discovery/prefill plus vault removal flows > - The benefit is a safer operator path for configuring external secret storage before higher-level cloud workflows depend on it ## What Changed - Added shared validators/types for AWS provider vault discovery payloads and safe provider metadata. - Implemented AWS provider vault discovery preview on the server. - Added provider vault removal service/route behavior. - Added Secrets page UI for discovery prefill, removal messaging, and related rendering coverage. - Added Storybook provider-vault fixtures and captured screenshots for the new UX states. ## Verification - `pnpm install --frozen-lockfile --ignore-scripts` - `pnpm exec vitest run packages/shared/src/validators/secret.test.ts server/src/__tests__/aws-secrets-manager-provider.test.ts server/src/__tests__/secrets-routes.test.ts server/src/__tests__/secrets-service.test.ts ui/src/pages/Secrets.render.test.tsx` - Result: 4 files passed, 1 embedded Postgres-backed file skipped on this host because local Postgres init was unavailable. - `pnpm --filter @paperclipai/ui exec vitest run src/pages/Secrets.render.test.tsx` - `pnpm --filter @paperclipai/ui typecheck` - Storybook screenshot capture against `Product/Secrets` on `http://127.0.0.1:60381/iframe.html?id=product-secrets--secrets-inventory&viewMode=story&globals=theme:dark` ## Screenshots Provider vaults tab after this change:  AWS discovery candidate flow:  Provider vault removal confirmation:  ## Risks - Secret provider metadata handling must remain non-sensitive; validators reject credential-bearing Vault URLs and sensitive AWS discovery keys. - AWS discovery depends on deployment credentials being configured correctly outside Paperclip-managed company secrets. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent with local shell/git/tool use. Exact hosted model ID and context-window size are not exposed by the local Paperclip adapter runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
9c29394f4d
commit
d67347be77
23 changed files with 1602 additions and 13 deletions
BIN
doc/screenshots/pr-6381/aws-discovery-candidates.png
Normal file
BIN
doc/screenshots/pr-6381/aws-discovery-candidates.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
BIN
doc/screenshots/pr-6381/provider-vaults-tab.png
Normal file
BIN
doc/screenshots/pr-6381/provider-vaults-tab.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
doc/screenshots/pr-6381/remove-provider-vault-confirmation.png
Normal file
BIN
doc/screenshots/pr-6381/remove-provider-vault-confirmation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
|
|
@ -12,6 +12,7 @@ export const API = {
|
||||||
approvals: `${API_PREFIX}/approvals`,
|
approvals: `${API_PREFIX}/approvals`,
|
||||||
secrets: `${API_PREFIX}/secrets`,
|
secrets: `${API_PREFIX}/secrets`,
|
||||||
secretProviderConfigs: `${API_PREFIX}/secret-provider-configs`,
|
secretProviderConfigs: `${API_PREFIX}/secret-provider-configs`,
|
||||||
|
secretProviderConfigDiscoveryPreview: `${API_PREFIX}/companies/:companyId/secret-provider-configs/discovery/preview`,
|
||||||
costs: `${API_PREFIX}/costs`,
|
costs: `${API_PREFIX}/costs`,
|
||||||
activity: `${API_PREFIX}/activity`,
|
activity: `${API_PREFIX}/activity`,
|
||||||
dashboard: `${API_PREFIX}/dashboard`,
|
dashboard: `${API_PREFIX}/dashboard`,
|
||||||
|
|
|
||||||
|
|
@ -558,6 +558,10 @@ export type {
|
||||||
SecretProviderConfigPayload,
|
SecretProviderConfigPayload,
|
||||||
SecretProviderConfigHealthDetails,
|
SecretProviderConfigHealthDetails,
|
||||||
SecretProviderConfigHealthResponse,
|
SecretProviderConfigHealthResponse,
|
||||||
|
SecretProviderConfigDiscoveryCandidate,
|
||||||
|
SecretProviderConfigDiscoveryPreviewResult,
|
||||||
|
SecretProviderConfigDiscoverySample,
|
||||||
|
SecretProviderConfigDiscoverySignal,
|
||||||
CompanySecretBinding,
|
CompanySecretBinding,
|
||||||
CompanySecretBindingTarget,
|
CompanySecretBindingTarget,
|
||||||
CompanySecretUsageBinding,
|
CompanySecretUsageBinding,
|
||||||
|
|
@ -885,6 +889,7 @@ export {
|
||||||
createSecretSchema,
|
createSecretSchema,
|
||||||
createSecretProviderConfigSchema,
|
createSecretProviderConfigSchema,
|
||||||
updateSecretProviderConfigSchema,
|
updateSecretProviderConfigSchema,
|
||||||
|
secretProviderConfigDiscoveryPreviewSchema,
|
||||||
remoteSecretImportPreviewSchema,
|
remoteSecretImportPreviewSchema,
|
||||||
remoteSecretImportSchema,
|
remoteSecretImportSchema,
|
||||||
remoteSecretImportSelectionSchema,
|
remoteSecretImportSelectionSchema,
|
||||||
|
|
@ -911,6 +916,7 @@ export {
|
||||||
type CreateSecret,
|
type CreateSecret,
|
||||||
type CreateSecretProviderConfig,
|
type CreateSecretProviderConfig,
|
||||||
type UpdateSecretProviderConfig,
|
type UpdateSecretProviderConfig,
|
||||||
|
type SecretProviderConfigDiscoveryPreview,
|
||||||
type RemoteSecretImportPreview,
|
type RemoteSecretImportPreview,
|
||||||
type RemoteSecretImport,
|
type RemoteSecretImport,
|
||||||
type RemoteSecretImportSelection,
|
type RemoteSecretImportSelection,
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,10 @@ export type {
|
||||||
SecretProviderConfigPayload,
|
SecretProviderConfigPayload,
|
||||||
SecretProviderConfigHealthDetails,
|
SecretProviderConfigHealthDetails,
|
||||||
SecretProviderConfigHealthResponse,
|
SecretProviderConfigHealthResponse,
|
||||||
|
SecretProviderConfigDiscoveryCandidate,
|
||||||
|
SecretProviderConfigDiscoveryPreviewResult,
|
||||||
|
SecretProviderConfigDiscoverySample,
|
||||||
|
SecretProviderConfigDiscoverySignal,
|
||||||
CompanySecretBinding,
|
CompanySecretBinding,
|
||||||
CompanySecretBindingTarget,
|
CompanySecretBindingTarget,
|
||||||
CompanySecretUsageBinding,
|
CompanySecretUsageBinding,
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,43 @@ export interface SecretProviderConfigHealthResponse {
|
||||||
checkedAt: Date;
|
checkedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SecretProviderConfigDiscoverySignal {
|
||||||
|
namespace: string | null;
|
||||||
|
secretNamePrefix: string | null;
|
||||||
|
environmentTag: string | null;
|
||||||
|
ownerTag: string | null;
|
||||||
|
kmsKeyId: string | null;
|
||||||
|
hasKmsKey: boolean;
|
||||||
|
sampleCount: number;
|
||||||
|
paperclipManagedSampleCount: number;
|
||||||
|
skippedForeignPaperclipSampleCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretProviderConfigDiscoverySample {
|
||||||
|
name: string;
|
||||||
|
hasKmsKey: boolean;
|
||||||
|
tagKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretProviderConfigDiscoveryCandidate {
|
||||||
|
provider: SecretProvider;
|
||||||
|
displayName: string;
|
||||||
|
config: SecretProviderConfigPayload;
|
||||||
|
sampleCount: number;
|
||||||
|
samples: SecretProviderConfigDiscoverySample[];
|
||||||
|
signals: SecretProviderConfigDiscoverySignal;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretProviderConfigDiscoveryPreviewResult {
|
||||||
|
provider: SecretProvider;
|
||||||
|
nextToken: string | null;
|
||||||
|
sampledSecretCount: number;
|
||||||
|
skippedForeignPaperclipSampleCount: number;
|
||||||
|
candidates: SecretProviderConfigDiscoveryCandidate[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CompanySecretVersion {
|
export interface CompanySecretVersion {
|
||||||
id: string;
|
id: string;
|
||||||
secretId: string;
|
secretId: string;
|
||||||
|
|
|
||||||
|
|
@ -293,6 +293,7 @@ export {
|
||||||
createSecretSchema,
|
createSecretSchema,
|
||||||
createSecretProviderConfigSchema,
|
createSecretProviderConfigSchema,
|
||||||
updateSecretProviderConfigSchema,
|
updateSecretProviderConfigSchema,
|
||||||
|
secretProviderConfigDiscoveryPreviewSchema,
|
||||||
remoteSecretImportPreviewSchema,
|
remoteSecretImportPreviewSchema,
|
||||||
remoteSecretImportSchema,
|
remoteSecretImportSchema,
|
||||||
remoteSecretImportSelectionSchema,
|
remoteSecretImportSelectionSchema,
|
||||||
|
|
@ -309,6 +310,7 @@ export {
|
||||||
type CreateSecret,
|
type CreateSecret,
|
||||||
type CreateSecretProviderConfig,
|
type CreateSecretProviderConfig,
|
||||||
type UpdateSecretProviderConfig,
|
type UpdateSecretProviderConfig,
|
||||||
|
type SecretProviderConfigDiscoveryPreview,
|
||||||
type RemoteSecretImportPreview,
|
type RemoteSecretImportPreview,
|
||||||
type RemoteSecretImport,
|
type RemoteSecretImport,
|
||||||
type RemoteSecretImportSelection,
|
type RemoteSecretImportSelection,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
createSecretSchema,
|
createSecretSchema,
|
||||||
remoteSecretImportPreviewSchema,
|
remoteSecretImportPreviewSchema,
|
||||||
remoteSecretImportSchema,
|
remoteSecretImportSchema,
|
||||||
|
secretProviderConfigDiscoveryPreviewSchema,
|
||||||
secretProviderConfigPayloadSchema,
|
secretProviderConfigPayloadSchema,
|
||||||
updateSecretProviderConfigSchema,
|
updateSecretProviderConfigSchema,
|
||||||
} from "./secret.js";
|
} from "./secret.js";
|
||||||
|
|
@ -140,6 +141,40 @@ describe("secret validators", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("validates AWS provider vault discovery draft config without allowing sensitive keys", () => {
|
||||||
|
expect(
|
||||||
|
secretProviderConfigDiscoveryPreviewSchema.parse({
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
config: {
|
||||||
|
region: "us-east-1",
|
||||||
|
namespace: "production",
|
||||||
|
secretNamePrefix: "paperclip",
|
||||||
|
},
|
||||||
|
query: "paperclip",
|
||||||
|
pageSize: 50,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
config: {
|
||||||
|
region: "us-east-1",
|
||||||
|
namespace: "production",
|
||||||
|
secretNamePrefix: "paperclip",
|
||||||
|
},
|
||||||
|
query: "paperclip",
|
||||||
|
pageSize: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
secretProviderConfigDiscoveryPreviewSchema.parse({
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
config: {
|
||||||
|
region: "us-east-1",
|
||||||
|
accessKeyId: "AKIA...",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toThrow(/sensitive field/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("caps AWS remote import paging and row counts", () => {
|
it("caps AWS remote import paging and row counts", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
remoteSecretImportPreviewSchema.parse({
|
remoteSecretImportPreviewSchema.parse({
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,30 @@ export const remoteSecretImportPreviewSchema = z.object({
|
||||||
|
|
||||||
export type RemoteSecretImportPreview = z.infer<typeof remoteSecretImportPreviewSchema>;
|
export type RemoteSecretImportPreview = z.infer<typeof remoteSecretImportPreviewSchema>;
|
||||||
|
|
||||||
|
export const secretProviderConfigDiscoveryPreviewSchema = z.object({
|
||||||
|
provider: z.enum(SECRET_PROVIDERS),
|
||||||
|
config: z.record(z.unknown()).default({}),
|
||||||
|
query: z.string().trim().max(200).optional().nullable(),
|
||||||
|
nextToken: z.string().trim().min(1).max(4096).optional().nullable(),
|
||||||
|
pageSize: z.number().int().min(1).max(100).optional(),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
rejectSensitiveProviderConfigKeys(value.config, ctx);
|
||||||
|
const parsed = secretProviderConfigPayloadSchema.safeParse({
|
||||||
|
provider: value.provider,
|
||||||
|
config: value.config,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
for (const issue of parsed.error.issues) {
|
||||||
|
ctx.addIssue({
|
||||||
|
...issue,
|
||||||
|
path: issue.path[0] === "config" ? issue.path : ["config", ...issue.path],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SecretProviderConfigDiscoveryPreview = z.infer<typeof secretProviderConfigDiscoveryPreviewSchema>;
|
||||||
|
|
||||||
export const remoteSecretImportSelectionSchema = z.object({
|
export const remoteSecretImportSelectionSchema = z.object({
|
||||||
externalRef: z.string().trim().min(1).max(2048),
|
externalRef: z.string().trim().min(1).max(2048),
|
||||||
name: z.string().trim().min(1).max(160).optional().nullable(),
|
name: z.string().trim().min(1).max(160).optional().nullable(),
|
||||||
|
|
|
||||||
|
|
@ -454,6 +454,103 @@ describe("awsSecretsManagerProvider", () => {
|
||||||
expect(JSON.stringify(listed)).not.toContain("team");
|
expect(JSON.stringify(listed)).not.toContain("team");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("discovers AWS provider vault prefill candidates from metadata without reading values", async () => {
|
||||||
|
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
|
||||||
|
const provider = createAwsSecretsManagerProvider({
|
||||||
|
gateway: {
|
||||||
|
async createSecret() {
|
||||||
|
throw new Error("not used");
|
||||||
|
},
|
||||||
|
async putSecretValue() {
|
||||||
|
throw new Error("not used");
|
||||||
|
},
|
||||||
|
async getSecretValue() {
|
||||||
|
throw new Error("GetSecretValue must not be used for provider vault discovery");
|
||||||
|
},
|
||||||
|
async deleteSecret() {
|
||||||
|
throw new Error("not used");
|
||||||
|
},
|
||||||
|
async listSecrets(input) {
|
||||||
|
calls.push({ op: "listSecrets", input });
|
||||||
|
return {
|
||||||
|
NextToken: "next-page",
|
||||||
|
SecretList: [
|
||||||
|
{
|
||||||
|
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai",
|
||||||
|
Name: "paperclip/prod-use1/company-1/openai",
|
||||||
|
KmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/prod",
|
||||||
|
Tags: [
|
||||||
|
{ Key: "paperclip:managed-by", Value: "paperclip" },
|
||||||
|
{ Key: "paperclip:deployment-id", Value: "prod-use1" },
|
||||||
|
{ Key: "paperclip:company-id", Value: "company-1" },
|
||||||
|
{ Key: "paperclip:environment", Value: "production" },
|
||||||
|
{ Key: "paperclip:provider-owner", Value: "platform" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/stripe",
|
||||||
|
Name: "paperclip/prod-use1/company-2/stripe",
|
||||||
|
Tags: [
|
||||||
|
{ Key: "paperclip:managed-by", Value: "paperclip" },
|
||||||
|
{ Key: "paperclip:company-id", Value: "company-2" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = await provider.discoverProviderConfigs?.({
|
||||||
|
companyId: "company-1",
|
||||||
|
providerConfig: {
|
||||||
|
id: "draft",
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
status: "ready",
|
||||||
|
config: { region: "us-east-1" },
|
||||||
|
},
|
||||||
|
query: "paperclip",
|
||||||
|
pageSize: 25,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{
|
||||||
|
op: "listSecrets",
|
||||||
|
input: {
|
||||||
|
MaxResults: 25,
|
||||||
|
NextToken: undefined,
|
||||||
|
IncludePlannedDeletion: false,
|
||||||
|
Filters: [{ Key: "all", Values: ["paperclip"] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(preview).toMatchObject({
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
nextToken: "next-page",
|
||||||
|
sampledSecretCount: 1,
|
||||||
|
skippedForeignPaperclipSampleCount: 1,
|
||||||
|
candidates: [
|
||||||
|
expect.objectContaining({
|
||||||
|
displayName: "AWS production",
|
||||||
|
config: expect.objectContaining({
|
||||||
|
region: "us-east-1",
|
||||||
|
namespace: "prod-use1",
|
||||||
|
secretNamePrefix: "paperclip",
|
||||||
|
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/prod",
|
||||||
|
ownerTag: "platform",
|
||||||
|
environmentTag: "production",
|
||||||
|
}),
|
||||||
|
signals: expect.objectContaining({
|
||||||
|
paperclipManagedSampleCount: 1,
|
||||||
|
skippedForeignPaperclipSampleCount: 1,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(preview)).not.toContain("SecretString");
|
||||||
|
expect(JSON.stringify(preview)).not.toContain("company-2/stripe");
|
||||||
|
});
|
||||||
|
|
||||||
it("redacts AWS provider exception text when remote listing fails", async () => {
|
it("redacts AWS provider exception text when remote listing fails", async () => {
|
||||||
const rawProviderMessage =
|
const rawProviderMessage =
|
||||||
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai";
|
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai";
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,12 @@ const mockSecretService = vi.hoisted(() => ({
|
||||||
listProviders: vi.fn(),
|
listProviders: vi.fn(),
|
||||||
checkProviders: vi.fn(),
|
checkProviders: vi.fn(),
|
||||||
listProviderConfigs: vi.fn(),
|
listProviderConfigs: vi.fn(),
|
||||||
|
previewProviderConfigDiscovery: vi.fn(),
|
||||||
getProviderConfigById: vi.fn(),
|
getProviderConfigById: vi.fn(),
|
||||||
createProviderConfig: vi.fn(),
|
createProviderConfig: vi.fn(),
|
||||||
updateProviderConfig: vi.fn(),
|
updateProviderConfig: vi.fn(),
|
||||||
disableProviderConfig: vi.fn(),
|
disableProviderConfig: vi.fn(),
|
||||||
|
removeProviderConfig: vi.fn(),
|
||||||
setDefaultProviderConfig: vi.fn(),
|
setDefaultProviderConfig: vi.fn(),
|
||||||
checkProviderConfigHealth: vi.fn(),
|
checkProviderConfigHealth: vi.fn(),
|
||||||
getById: vi.fn(),
|
getById: vi.fn(),
|
||||||
|
|
@ -117,6 +119,22 @@ describe("secret routes", () => {
|
||||||
expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled();
|
expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects provider vault discovery preview for non-board actors", async () => {
|
||||||
|
const res = await request(createApp({
|
||||||
|
type: "agent",
|
||||||
|
agentId: "agent-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
}))
|
||||||
|
.post("/api/companies/company-1/secret-provider-configs/discovery/preview")
|
||||||
|
.send({
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
config: { region: "us-east-1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(mockSecretService.previewProviderConfigDiscovery).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects sensitive provider vault config fields", async () => {
|
it("rejects sensitive provider vault config fields", async () => {
|
||||||
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
||||||
provider: "aws_secrets_manager",
|
provider: "aws_secrets_manager",
|
||||||
|
|
@ -132,6 +150,92 @@ describe("secret routes", () => {
|
||||||
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
|
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects sensitive provider vault discovery draft config fields", async () => {
|
||||||
|
const res = await request(createApp())
|
||||||
|
.post("/api/companies/company-1/secret-provider-configs/discovery/preview")
|
||||||
|
.send({
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
config: {
|
||||||
|
region: "us-east-1",
|
||||||
|
secretAccessKey: "secret",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(JSON.stringify(res.body)).toMatch(/sensitive field/i);
|
||||||
|
expect(mockSecretService.previewProviderConfigDiscovery).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("previews provider vault discovery and logs only aggregate metadata", async () => {
|
||||||
|
mockSecretService.previewProviderConfigDiscovery.mockResolvedValue({
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
nextToken: null,
|
||||||
|
sampledSecretCount: 2,
|
||||||
|
skippedForeignPaperclipSampleCount: 0,
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
displayName: "AWS production",
|
||||||
|
config: {
|
||||||
|
region: "us-east-1",
|
||||||
|
namespace: "prod-use1",
|
||||||
|
secretNamePrefix: "paperclip",
|
||||||
|
environmentTag: "production",
|
||||||
|
ownerTag: "platform",
|
||||||
|
kmsKeyId: null,
|
||||||
|
},
|
||||||
|
sampleCount: 2,
|
||||||
|
samples: [
|
||||||
|
{ name: "paperclip/prod-use1/company-1/openai", hasKmsKey: false, tagKeys: ["environment"] },
|
||||||
|
],
|
||||||
|
signals: {
|
||||||
|
namespace: "prod-use1",
|
||||||
|
secretNamePrefix: "paperclip",
|
||||||
|
environmentTag: "production",
|
||||||
|
ownerTag: "platform",
|
||||||
|
kmsKeyId: null,
|
||||||
|
hasKmsKey: false,
|
||||||
|
sampleCount: 2,
|
||||||
|
paperclipManagedSampleCount: 0,
|
||||||
|
skippedForeignPaperclipSampleCount: 0,
|
||||||
|
},
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(createApp())
|
||||||
|
.post("/api/companies/company-1/secret-provider-configs/discovery/preview")
|
||||||
|
.send({
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
config: { region: "us-east-1" },
|
||||||
|
query: "paperclip",
|
||||||
|
pageSize: 25,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockSecretService.previewProviderConfigDiscovery).toHaveBeenCalledWith("company-1", {
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
config: { region: "us-east-1" },
|
||||||
|
query: "paperclip",
|
||||||
|
nextToken: undefined,
|
||||||
|
pageSize: 25,
|
||||||
|
});
|
||||||
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||||
|
action: "secret_provider_config.discovery_previewed",
|
||||||
|
entityType: "secret_provider_config_discovery",
|
||||||
|
entityId: "company-1",
|
||||||
|
details: {
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
candidateCount: 1,
|
||||||
|
sampledSecretCount: 2,
|
||||||
|
warningCount: 0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("paperclip/prod-use1/company-1/openai");
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects ready status for coming-soon provider vaults", async () => {
|
it("rejects ready status for coming-soon provider vaults", async () => {
|
||||||
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
||||||
provider: "vault",
|
provider: "vault",
|
||||||
|
|
@ -241,6 +345,48 @@ describe("secret routes", () => {
|
||||||
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("accessKey");
|
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("accessKey");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes provider vault config locally without deleting remote provider data", async () => {
|
||||||
|
const createdAt = new Date("2026-05-06T00:00:00.000Z");
|
||||||
|
const providerConfig = {
|
||||||
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
companyId: "company-1",
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
displayName: "AWS prod",
|
||||||
|
status: "ready",
|
||||||
|
isDefault: false,
|
||||||
|
config: { region: "us-east-1" },
|
||||||
|
healthStatus: null,
|
||||||
|
healthCheckedAt: null,
|
||||||
|
healthMessage: null,
|
||||||
|
healthDetails: null,
|
||||||
|
disabledAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-1",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
};
|
||||||
|
mockSecretService.getProviderConfigById.mockResolvedValue(providerConfig);
|
||||||
|
mockSecretService.removeProviderConfig.mockResolvedValue(providerConfig);
|
||||||
|
|
||||||
|
const res = await request(createApp()).delete(
|
||||||
|
"/api/secret-provider-configs/11111111-1111-4111-8111-111111111111",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockSecretService.removeProviderConfig).toHaveBeenCalledWith(
|
||||||
|
"11111111-1111-4111-8111-111111111111",
|
||||||
|
);
|
||||||
|
expect(mockSecretService.disableProviderConfig).not.toHaveBeenCalled();
|
||||||
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||||
|
action: "secret_provider_config.removed",
|
||||||
|
details: {
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
displayName: "AWS prod",
|
||||||
|
remoteDeleted: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects remote import preview for non-board actors", async () => {
|
it("rejects remote import preview for non-board actors", async () => {
|
||||||
const res = await request(createApp({
|
const res = await request(createApp({
|
||||||
type: "agent",
|
type: "agent",
|
||||||
|
|
|
||||||
|
|
@ -492,6 +492,35 @@ describeEmbeddedPostgres("secretService", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes provider vault config locally without deleting remote AWS secrets", async () => {
|
||||||
|
const companyId = await seedCompany();
|
||||||
|
const svc = secretService(db);
|
||||||
|
const vault = await svc.createProviderConfig(companyId, {
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
displayName: "AWS production",
|
||||||
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||||
|
});
|
||||||
|
const secret = await svc.create(companyId, {
|
||||||
|
name: `external-${randomUUID()}`,
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
providerConfigId: vault.id,
|
||||||
|
managedMode: "external_reference",
|
||||||
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/external",
|
||||||
|
});
|
||||||
|
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
|
||||||
|
|
||||||
|
const removed = await svc.removeProviderConfig(vault.id);
|
||||||
|
|
||||||
|
expect(removed?.id).toBe(vault.id);
|
||||||
|
await expect(svc.getProviderConfigById(vault.id)).resolves.toBeNull();
|
||||||
|
const [persistedSecret] = await db
|
||||||
|
.select()
|
||||||
|
.from(companySecrets)
|
||||||
|
.where(eq(companySecrets.id, secret.id));
|
||||||
|
expect(persistedSecret?.providerConfigId).toBeNull();
|
||||||
|
expect(deleteSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("hides soft-deleted secrets and allows name/key reuse", async () => {
|
it("hides soft-deleted secrets and allows name/key reuse", async () => {
|
||||||
const companyId = await seedCompany();
|
const companyId = await seedCompany();
|
||||||
const svc = secretService(db);
|
const svc = secretService(db);
|
||||||
|
|
@ -1207,6 +1236,111 @@ describeEmbeddedPostgres("secretService", () => {
|
||||||
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
|
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("previews AWS provider vault discovery from draft config without persisting a provider vault", async () => {
|
||||||
|
const companyId = await seedCompany();
|
||||||
|
const svc = secretService(db);
|
||||||
|
const discoverSpy = vi.spyOn(awsSecretsManagerProvider, "discoverProviderConfigs").mockResolvedValue({
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
nextToken: null,
|
||||||
|
sampledSecretCount: 1,
|
||||||
|
skippedForeignPaperclipSampleCount: 0,
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
displayName: "AWS production",
|
||||||
|
config: {
|
||||||
|
region: "us-east-1",
|
||||||
|
namespace: "prod-use1",
|
||||||
|
secretNamePrefix: "paperclip",
|
||||||
|
kmsKeyId: null,
|
||||||
|
ownerTag: "platform",
|
||||||
|
environmentTag: "production",
|
||||||
|
},
|
||||||
|
sampleCount: 1,
|
||||||
|
samples: [
|
||||||
|
{ name: "paperclip/prod-use1/company-1/openai", hasKmsKey: false, tagKeys: ["paperclip:environment"] },
|
||||||
|
],
|
||||||
|
signals: {
|
||||||
|
namespace: "prod-use1",
|
||||||
|
secretNamePrefix: "paperclip",
|
||||||
|
environmentTag: "production",
|
||||||
|
ownerTag: "platform",
|
||||||
|
kmsKeyId: null,
|
||||||
|
hasKmsKey: false,
|
||||||
|
sampleCount: 1,
|
||||||
|
paperclipManagedSampleCount: 0,
|
||||||
|
skippedForeignPaperclipSampleCount: 0,
|
||||||
|
},
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = await svc.previewProviderConfigDiscovery(companyId, {
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
config: { region: "us-east-1" },
|
||||||
|
query: "openai",
|
||||||
|
pageSize: 25,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(discoverSpy).toHaveBeenCalledWith({
|
||||||
|
companyId,
|
||||||
|
providerConfig: {
|
||||||
|
id: `discovery-preview-${companyId}`,
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
status: "ready",
|
||||||
|
config: { region: "us-east-1" },
|
||||||
|
},
|
||||||
|
query: "openai",
|
||||||
|
nextToken: undefined,
|
||||||
|
pageSize: 25,
|
||||||
|
});
|
||||||
|
expect(preview.candidates[0]?.config).toMatchObject({
|
||||||
|
region: "us-east-1",
|
||||||
|
namespace: "prod-use1",
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(preview)).not.toContain("runtime-secret");
|
||||||
|
const persistedVaults = await db.select().from(companySecretProviderConfigs);
|
||||||
|
expect(persistedVaults).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes AWS provider vault discovery errors before crossing the service boundary", async () => {
|
||||||
|
const companyId = await seedCompany();
|
||||||
|
const svc = secretService(db);
|
||||||
|
const rawProviderMessage =
|
||||||
|
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets";
|
||||||
|
|
||||||
|
vi.spyOn(awsSecretsManagerProvider, "discoverProviderConfigs").mockRejectedValueOnce(
|
||||||
|
new SecretProviderClientError({
|
||||||
|
code: "access_denied",
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
operation: "discoverProviderConfigs",
|
||||||
|
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
||||||
|
rawMessage: rawProviderMessage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await svc.previewProviderConfigDiscovery(companyId, {
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
config: { region: "us-east-1" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(thrown).toMatchObject({
|
||||||
|
status: 403,
|
||||||
|
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
||||||
|
details: { code: "access_denied" },
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(thrown)).not.toContain("arn:aws");
|
||||||
|
expect(JSON.stringify(thrown)).not.toContain("123456789012");
|
||||||
|
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
|
||||||
|
});
|
||||||
|
|
||||||
it("imports AWS remote references row-by-row without fetching plaintext", async () => {
|
it("imports AWS remote references row-by-row without fetching plaintext", async () => {
|
||||||
const companyId = await seedCompany();
|
const companyId = await seedCompany();
|
||||||
const svc = secretService(db);
|
const svc = secretService(db);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
remoteSecretImportPreviewSchema,
|
remoteSecretImportPreviewSchema,
|
||||||
remoteSecretImportSchema,
|
remoteSecretImportSchema,
|
||||||
rotateSecretSchema,
|
rotateSecretSchema,
|
||||||
|
secretProviderConfigDiscoveryPreviewSchema,
|
||||||
updateSecretProviderConfigSchema,
|
updateSecretProviderConfigSchema,
|
||||||
updateSecretSchema,
|
updateSecretSchema,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
|
|
@ -41,6 +42,41 @@ export function secretRoutes(db: Db) {
|
||||||
res.json(await svc.listProviderConfigs(companyId));
|
res.json(await svc.listProviderConfigs(companyId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/companies/:companyId/secret-provider-configs/discovery/preview",
|
||||||
|
validate(secretProviderConfigDiscoveryPreviewSchema),
|
||||||
|
async (req, res) => {
|
||||||
|
assertBoard(req);
|
||||||
|
const companyId = req.params.companyId as string;
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
|
|
||||||
|
const preview = await svc.previewProviderConfigDiscovery(companyId, {
|
||||||
|
provider: req.body.provider,
|
||||||
|
config: req.body.config,
|
||||||
|
query: req.body.query,
|
||||||
|
nextToken: req.body.nextToken,
|
||||||
|
pageSize: req.body.pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId,
|
||||||
|
actorType: "user",
|
||||||
|
actorId: req.actor.userId ?? "board",
|
||||||
|
action: "secret_provider_config.discovery_previewed",
|
||||||
|
entityType: "secret_provider_config_discovery",
|
||||||
|
entityId: companyId,
|
||||||
|
details: {
|
||||||
|
provider: preview.provider,
|
||||||
|
candidateCount: preview.candidates.length,
|
||||||
|
sampledSecretCount: preview.sampledSecretCount,
|
||||||
|
warningCount: preview.warnings.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(preview);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.post("/companies/:companyId/secret-provider-configs", validate(createSecretProviderConfigSchema), async (req, res) => {
|
router.post("/companies/:companyId/secret-provider-configs", validate(createSecretProviderConfigSchema), async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
|
|
@ -136,27 +172,27 @@ export function secretRoutes(db: Db) {
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, existing.companyId);
|
assertCompanyAccess(req, existing.companyId);
|
||||||
|
|
||||||
const disabled = await svc.disableProviderConfig(id);
|
const removed = await svc.removeProviderConfig(id);
|
||||||
if (!disabled) {
|
if (!removed) {
|
||||||
res.status(404).json({ error: "Provider vault not found" });
|
res.status(404).json({ error: "Provider vault not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
companyId: disabled.companyId,
|
companyId: removed.companyId,
|
||||||
actorType: "user",
|
actorType: "user",
|
||||||
actorId: req.actor.userId ?? "board",
|
actorId: req.actor.userId ?? "board",
|
||||||
action: "secret_provider_config.disabled",
|
action: "secret_provider_config.removed",
|
||||||
entityType: "secret_provider_config",
|
entityType: "secret_provider_config",
|
||||||
entityId: disabled.id,
|
entityId: removed.id,
|
||||||
details: {
|
details: {
|
||||||
provider: disabled.provider,
|
provider: removed.provider,
|
||||||
displayName: disabled.displayName,
|
displayName: removed.displayName,
|
||||||
status: disabled.status,
|
remoteDeleted: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(disabled);
|
res.json(removed);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/secret-provider-configs/:id/default", async (req, res) => {
|
router.post("/secret-provider-configs/:id/default", async (req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createHash, createHmac } from "node:crypto";
|
import { createHash, createHmac } from "node:crypto";
|
||||||
import { S3Client } from "@aws-sdk/client-s3";
|
import { S3Client } from "@aws-sdk/client-s3";
|
||||||
import type { DeploymentMode } from "@paperclipai/shared";
|
import type { DeploymentMode, SecretProviderConfigDiscoveryPreviewResult } from "@paperclipai/shared";
|
||||||
import { unprocessable } from "../errors.js";
|
import { unprocessable } from "../errors.js";
|
||||||
import type {
|
import type {
|
||||||
PreparedSecretVersion,
|
PreparedSecretVersion,
|
||||||
|
|
@ -24,6 +24,8 @@ const DEFAULT_DELETE_RECOVERY_WINDOW_DAYS = 30;
|
||||||
const AWS_SECRETS_MANAGER_REQUEST_TIMEOUT_MS = 30_000;
|
const AWS_SECRETS_MANAGER_REQUEST_TIMEOUT_MS = 30_000;
|
||||||
const AWS_CREDENTIAL_CACHE_TTL_MS = 5 * 60_000;
|
const AWS_CREDENTIAL_CACHE_TTL_MS = 5 * 60_000;
|
||||||
const AWS_CREDENTIAL_EXPIRATION_SKEW_MS = 60_000;
|
const AWS_CREDENTIAL_EXPIRATION_SKEW_MS = 60_000;
|
||||||
|
const PROVIDER_CONFIG_DISCOVERY_SAMPLE_LIMIT = 3;
|
||||||
|
const PROVIDER_CONFIG_DISCOVERY_CANDIDATE_LIMIT = 6;
|
||||||
const AWS_RUNTIME_CREDENTIAL_WARNING =
|
const AWS_RUNTIME_CREDENTIAL_WARNING =
|
||||||
"AWS bootstrap credentials must be available to the Paperclip server runtime through the AWS SDK default credential provider chain: IAM role/workload identity, AWS_PROFILE/SSO/shared credentials, web identity, container/instance metadata, or short-lived shell credentials.";
|
"AWS bootstrap credentials must be available to the Paperclip server runtime through the AWS SDK default credential provider chain: IAM role/workload identity, AWS_PROFILE/SSO/shared credentials, web identity, container/instance metadata, or short-lived shell credentials.";
|
||||||
const AWS_CREDENTIAL_CUSTODY_WARNING =
|
const AWS_CREDENTIAL_CUSTODY_WARNING =
|
||||||
|
|
@ -590,6 +592,234 @@ function createRemoteSecretMetadata(entry: AwsSecretsManagerListSecretEntry): Re
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tagValue(tags: Map<string, string>, keys: string[]) {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = tags.get(key.toLowerCase());
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAwsTags(tags: AwsSecretsManagerTag[] | undefined) {
|
||||||
|
const normalized = new Map<string, string>();
|
||||||
|
for (const tag of tags ?? []) {
|
||||||
|
const key = tag.Key?.trim();
|
||||||
|
const value = tag.Value?.trim();
|
||||||
|
if (key && value) normalized.set(key.toLowerCase(), value);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commonValue(values: Array<string | null | undefined>) {
|
||||||
|
const nonEmpty = values.filter((value): value is string => Boolean(value?.trim()));
|
||||||
|
if (nonEmpty.length === 0) return null;
|
||||||
|
const first = nonEmpty[0];
|
||||||
|
return nonEmpty.every((value) => value === first) ? first : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueValues(values: Array<string | null | undefined>) {
|
||||||
|
return [...new Set(values.filter((value): value is string => Boolean(value?.trim())))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathSegments(name: string) {
|
||||||
|
return name.split("/").map((segment) => segment.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferPathSignals(entry: AwsSecretsManagerListSecretEntry, tags: Map<string, string>) {
|
||||||
|
const name = entry.Name?.trim() || entry.ARN?.trim() || "";
|
||||||
|
const segments = pathSegments(name);
|
||||||
|
const paperclipDeploymentId = tagValue(tags, ["paperclip:deployment-id"]);
|
||||||
|
const paperclipManaged = tagValue(tags, ["paperclip:managed-by"])?.toLowerCase() === "paperclip";
|
||||||
|
|
||||||
|
if (paperclipDeploymentId || paperclipManaged) {
|
||||||
|
return {
|
||||||
|
prefix: segments[0] ?? DEFAULT_PREFIX,
|
||||||
|
namespace: paperclipDeploymentId ?? segments[1] ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length >= 3) {
|
||||||
|
return {
|
||||||
|
prefix: segments[0] ?? null,
|
||||||
|
namespace: segments[1] ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
prefix: segments[0] ?? null,
|
||||||
|
namespace: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoveryDisplayName(input: {
|
||||||
|
environmentTag: string | null;
|
||||||
|
ownerTag: string | null;
|
||||||
|
namespace: string | null;
|
||||||
|
secretNamePrefix: string | null;
|
||||||
|
}) {
|
||||||
|
const qualifier =
|
||||||
|
input.environmentTag ??
|
||||||
|
input.namespace ??
|
||||||
|
input.secretNamePrefix ??
|
||||||
|
input.ownerTag ??
|
||||||
|
"discovered";
|
||||||
|
return `AWS ${qualifier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverAwsProviderConfigCandidates(input: {
|
||||||
|
companyId: string;
|
||||||
|
config: AwsSecretsManagerConfig;
|
||||||
|
draftConfig: Record<string, unknown>;
|
||||||
|
entries: AwsSecretsManagerListSecretEntry[];
|
||||||
|
nextToken: string | null;
|
||||||
|
}): SecretProviderConfigDiscoveryPreviewResult {
|
||||||
|
type DiscoverySample = {
|
||||||
|
entry: AwsSecretsManagerListSecretEntry;
|
||||||
|
name: string;
|
||||||
|
tags: Map<string, string>;
|
||||||
|
prefix: string | null;
|
||||||
|
namespace: string | null;
|
||||||
|
environmentTag: string | null;
|
||||||
|
ownerTag: string | null;
|
||||||
|
kmsKeyId: string | null;
|
||||||
|
paperclipManaged: boolean;
|
||||||
|
paperclipCompanyId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const skippedWarnings: string[] = [];
|
||||||
|
let skippedForeignPaperclipSampleCount = 0;
|
||||||
|
const samples: DiscoverySample[] = [];
|
||||||
|
|
||||||
|
for (const entry of input.entries) {
|
||||||
|
const name = entry.Name?.trim() || entry.ARN?.trim();
|
||||||
|
if (!name) continue;
|
||||||
|
const tags = normalizeAwsTags(entry.Tags);
|
||||||
|
const paperclipManaged = tagValue(tags, ["paperclip:managed-by"])?.toLowerCase() === "paperclip";
|
||||||
|
const paperclipCompanyId = tagValue(tags, ["paperclip:company-id"]);
|
||||||
|
if (paperclipManaged && paperclipCompanyId !== input.companyId) {
|
||||||
|
skippedForeignPaperclipSampleCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const path = inferPathSignals(entry, tags);
|
||||||
|
samples.push({
|
||||||
|
entry,
|
||||||
|
name,
|
||||||
|
tags,
|
||||||
|
prefix: path.prefix,
|
||||||
|
namespace: path.namespace,
|
||||||
|
environmentTag: tagValue(tags, ["paperclip:environment", "environment", "env", "stage"]),
|
||||||
|
ownerTag: tagValue(tags, ["paperclip:provider-owner", "owner", "team", "service", "application"]),
|
||||||
|
kmsKeyId: asOptionalNonEmptyString(entry.KmsKeyId),
|
||||||
|
paperclipManaged,
|
||||||
|
paperclipCompanyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skippedForeignPaperclipSampleCount > 0) {
|
||||||
|
skippedWarnings.push(
|
||||||
|
`Skipped ${skippedForeignPaperclipSampleCount} Paperclip-managed AWS secret sample(s) that were not tagged for this company.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftNamespace = asOptionalNonEmptyString(input.draftConfig.namespace);
|
||||||
|
const draftPrefix = asOptionalNonEmptyString(input.draftConfig.secretNamePrefix);
|
||||||
|
const draftKmsKeyId = asOptionalNonEmptyString(input.draftConfig.kmsKeyId);
|
||||||
|
const draftEnvironmentTag = asOptionalNonEmptyString(input.draftConfig.environmentTag);
|
||||||
|
const draftOwnerTag = asOptionalNonEmptyString(input.draftConfig.ownerTag);
|
||||||
|
const groups = new Map<string, DiscoverySample[]>();
|
||||||
|
|
||||||
|
for (const sample of samples) {
|
||||||
|
const key = [
|
||||||
|
draftPrefix ?? sample.prefix ?? "",
|
||||||
|
draftNamespace ?? sample.namespace ?? "",
|
||||||
|
].join("\0");
|
||||||
|
groups.set(key, [...(groups.get(key) ?? []), sample]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [...groups.values()]
|
||||||
|
.sort((a, b) => b.length - a.length)
|
||||||
|
.slice(0, PROVIDER_CONFIG_DISCOVERY_CANDIDATE_LIMIT)
|
||||||
|
.map((group) => {
|
||||||
|
const prefix = draftPrefix ?? commonValue(group.map((sample) => sample.prefix)) ?? input.config.prefix;
|
||||||
|
const namespace = draftNamespace ?? commonValue(group.map((sample) => sample.namespace)) ?? null;
|
||||||
|
const environmentTag = draftEnvironmentTag ?? commonValue(group.map((sample) => sample.environmentTag));
|
||||||
|
const ownerTag = draftOwnerTag ?? commonValue(group.map((sample) => sample.ownerTag));
|
||||||
|
const kmsKeys = uniqueValues(group.map((sample) => sample.kmsKeyId));
|
||||||
|
const commonKmsKey = commonValue(group.map((sample) => sample.kmsKeyId));
|
||||||
|
const kmsKeyId = draftKmsKeyId ?? commonKmsKey;
|
||||||
|
const candidateWarnings: string[] = [];
|
||||||
|
|
||||||
|
if (!namespace) {
|
||||||
|
candidateWarnings.push("No stable namespace signal was found in the sampled AWS secret names or tags.");
|
||||||
|
}
|
||||||
|
if (!environmentTag) {
|
||||||
|
candidateWarnings.push("No common environment tag was found in the sampled AWS secrets.");
|
||||||
|
}
|
||||||
|
if (!ownerTag) {
|
||||||
|
candidateWarnings.push("No common owner/team tag was found in the sampled AWS secrets.");
|
||||||
|
}
|
||||||
|
if (kmsKeys.length > 1 && !draftKmsKeyId) {
|
||||||
|
candidateWarnings.push("Sampled AWS secrets use multiple KMS keys; choose the intended KMS key before saving.");
|
||||||
|
}
|
||||||
|
if (group.some((sample) => sample.paperclipManaged && sample.paperclipCompanyId === input.companyId)) {
|
||||||
|
candidateWarnings.push("Sample includes Paperclip-managed secrets for this company; do not import them as external references.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "aws_secrets_manager" as const,
|
||||||
|
displayName: discoveryDisplayName({
|
||||||
|
environmentTag,
|
||||||
|
ownerTag,
|
||||||
|
namespace,
|
||||||
|
secretNamePrefix: prefix,
|
||||||
|
}),
|
||||||
|
config: {
|
||||||
|
region: input.config.region,
|
||||||
|
namespace,
|
||||||
|
secretNamePrefix: prefix,
|
||||||
|
kmsKeyId: kmsKeyId ?? null,
|
||||||
|
ownerTag,
|
||||||
|
environmentTag,
|
||||||
|
},
|
||||||
|
sampleCount: group.length,
|
||||||
|
samples: group.slice(0, PROVIDER_CONFIG_DISCOVERY_SAMPLE_LIMIT).map((sample) => ({
|
||||||
|
name: sample.name,
|
||||||
|
hasKmsKey: Boolean(sample.kmsKeyId),
|
||||||
|
tagKeys: [...sample.tags.keys()].sort(),
|
||||||
|
})),
|
||||||
|
signals: {
|
||||||
|
namespace,
|
||||||
|
secretNamePrefix: prefix,
|
||||||
|
environmentTag,
|
||||||
|
ownerTag,
|
||||||
|
kmsKeyId: kmsKeyId ?? null,
|
||||||
|
hasKmsKey: kmsKeys.length > 0,
|
||||||
|
sampleCount: group.length,
|
||||||
|
paperclipManagedSampleCount: group.filter((sample) => sample.paperclipManaged).length,
|
||||||
|
skippedForeignPaperclipSampleCount,
|
||||||
|
},
|
||||||
|
warnings: candidateWarnings,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const warnings = [...skippedWarnings];
|
||||||
|
if (samples.length === 0) {
|
||||||
|
warnings.push("AWS Secrets Manager returned no metadata samples for this draft provider vault config.");
|
||||||
|
}
|
||||||
|
if (groups.size > PROVIDER_CONFIG_DISCOVERY_CANDIDATE_LIMIT) {
|
||||||
|
warnings.push("Additional AWS secret name groups were omitted from this preview; refine the query to inspect them.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
nextToken: input.nextToken,
|
||||||
|
sampledSecretCount: samples.length,
|
||||||
|
skippedForeignPaperclipSampleCount,
|
||||||
|
candidates,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function asAwsSecretsManagerMaterial(value: StoredSecretVersionMaterial): AwsSecretsManagerMaterial {
|
function asAwsSecretsManagerMaterial(value: StoredSecretVersionMaterial): AwsSecretsManagerMaterial {
|
||||||
if (
|
if (
|
||||||
value &&
|
value &&
|
||||||
|
|
@ -983,6 +1213,36 @@ export function createAwsSecretsManagerProvider(
|
||||||
normalizeAwsError("listSecrets", error);
|
normalizeAwsError("listSecrets", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async discoverProviderConfigs(input): Promise<SecretProviderConfigDiscoveryPreviewResult> {
|
||||||
|
const config = resolveConfig(input.providerConfig);
|
||||||
|
const gateway = resolveGateway(config);
|
||||||
|
const query = input.query?.trim();
|
||||||
|
const pageSize =
|
||||||
|
input.pageSize && Number.isFinite(input.pageSize)
|
||||||
|
? Math.min(Math.max(Math.trunc(input.pageSize), 1), 100)
|
||||||
|
: 100;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!gateway.listSecrets) {
|
||||||
|
throw new Error("ListSecrets gateway operation is unavailable");
|
||||||
|
}
|
||||||
|
const listed = await gateway.listSecrets({
|
||||||
|
MaxResults: pageSize,
|
||||||
|
NextToken: input.nextToken?.trim() || undefined,
|
||||||
|
IncludePlannedDeletion: false,
|
||||||
|
Filters: query ? [{ Key: "all", Values: [query] }] : undefined,
|
||||||
|
});
|
||||||
|
return discoverAwsProviderConfigCandidates({
|
||||||
|
companyId: input.companyId,
|
||||||
|
config,
|
||||||
|
draftConfig: input.providerConfig.config,
|
||||||
|
entries: listed.SecretList ?? [],
|
||||||
|
nextToken: listed.NextToken ?? null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
normalizeAwsError("discoverProviderConfigs", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
async resolveVersion(input) {
|
async resolveVersion(input) {
|
||||||
const config = resolveConfig(input.providerConfig);
|
const config = resolveConfig(input.providerConfig);
|
||||||
const gateway = resolveGateway(config);
|
const gateway = resolveGateway(config);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import type { SecretProvider, SecretProviderDescriptor } from "@paperclipai/shared";
|
import type {
|
||||||
|
SecretProvider,
|
||||||
|
SecretProviderConfigDiscoveryPreviewResult,
|
||||||
|
SecretProviderDescriptor,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
import type { DeploymentMode } from "@paperclipai/shared";
|
import type { DeploymentMode } from "@paperclipai/shared";
|
||||||
|
|
||||||
export interface StoredSecretVersionMaterial {
|
export interface StoredSecretVersionMaterial {
|
||||||
|
|
@ -152,6 +156,13 @@ export interface SecretProviderModule {
|
||||||
nextToken?: string | null;
|
nextToken?: string | null;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}): Promise<RemoteSecretListResult>;
|
}): Promise<RemoteSecretListResult>;
|
||||||
|
discoverProviderConfigs?(input: {
|
||||||
|
companyId: string;
|
||||||
|
providerConfig: SecretProviderVaultRuntimeConfig;
|
||||||
|
query?: string | null;
|
||||||
|
nextToken?: string | null;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<SecretProviderConfigDiscoveryPreviewResult>;
|
||||||
resolveVersion(input: {
|
resolveVersion(input: {
|
||||||
material: StoredSecretVersionMaterial;
|
material: StoredSecretVersionMaterial;
|
||||||
externalRef: string | null;
|
externalRef: string | null;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import type {
|
||||||
RemoteSecretImportCandidate,
|
RemoteSecretImportCandidate,
|
||||||
RemoteSecretImportConflict,
|
RemoteSecretImportConflict,
|
||||||
RemoteSecretImportRowResult,
|
RemoteSecretImportRowResult,
|
||||||
|
SecretProviderConfigDiscoveryPreviewResult,
|
||||||
SecretBindingTargetType,
|
SecretBindingTargetType,
|
||||||
SecretProvider,
|
SecretProvider,
|
||||||
SecretProviderConfigHealthResponse,
|
SecretProviderConfigHealthResponse,
|
||||||
|
|
@ -34,6 +35,7 @@ import {
|
||||||
isUuidLike,
|
isUuidLike,
|
||||||
normalizeAgentUrlKey,
|
normalizeAgentUrlKey,
|
||||||
secretProviderConfigPayloadSchema,
|
secretProviderConfigPayloadSchema,
|
||||||
|
secretProviderConfigDiscoveryPreviewSchema,
|
||||||
updateSecretProviderConfigSchema,
|
updateSecretProviderConfigSchema,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
|
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
|
||||||
|
|
@ -471,6 +473,19 @@ export function secretService(db: Db) {
|
||||||
return parsed.data.config;
|
return parsed.data.config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toDraftProviderVaultRuntimeConfig(input: {
|
||||||
|
companyId: string;
|
||||||
|
provider: SecretProvider;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}): SecretProviderVaultRuntimeConfig {
|
||||||
|
return {
|
||||||
|
id: `discovery-preview-${input.companyId}`,
|
||||||
|
provider: input.provider,
|
||||||
|
status: "ready",
|
||||||
|
config: validateProviderConfigPayload(input.provider, input.config),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function providerConfigHealth(input: {
|
function providerConfigHealth(input: {
|
||||||
id: string;
|
id: string;
|
||||||
provider: SecretProvider;
|
provider: SecretProvider;
|
||||||
|
|
@ -949,6 +964,54 @@ export function secretService(db: Db) {
|
||||||
|
|
||||||
checkProviders: () => checkSecretProviders(),
|
checkProviders: () => checkSecretProviders(),
|
||||||
|
|
||||||
|
previewProviderConfigDiscovery: async (
|
||||||
|
companyId: string,
|
||||||
|
input: {
|
||||||
|
provider: SecretProvider;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
query?: string | null;
|
||||||
|
nextToken?: string | null;
|
||||||
|
pageSize?: number;
|
||||||
|
},
|
||||||
|
): Promise<SecretProviderConfigDiscoveryPreviewResult> => {
|
||||||
|
const parsed = secretProviderConfigDiscoveryPreviewSchema.safeParse({
|
||||||
|
provider: input.provider,
|
||||||
|
config: input.config ?? {},
|
||||||
|
query: input.query,
|
||||||
|
nextToken: input.nextToken,
|
||||||
|
pageSize: input.pageSize,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw unprocessable("Invalid provider vault discovery config", parsed.error.flatten());
|
||||||
|
}
|
||||||
|
const providerId = parsed.data.provider as SecretProvider;
|
||||||
|
const provider = getSecretProvider(providerId);
|
||||||
|
if (!provider.discoverProviderConfigs) {
|
||||||
|
throw unprocessable(`${providerId} provider does not support provider vault discovery`);
|
||||||
|
}
|
||||||
|
const runtimeConfig = toDraftProviderVaultRuntimeConfig({
|
||||||
|
companyId,
|
||||||
|
provider: providerId,
|
||||||
|
config: parsed.data.config,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await provider.discoverProviderConfigs({
|
||||||
|
companyId,
|
||||||
|
providerConfig: runtimeConfig,
|
||||||
|
query: parsed.data.query,
|
||||||
|
nextToken: parsed.data.nextToken,
|
||||||
|
pageSize: parsed.data.pageSize,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw remoteProviderHttpError(error, {
|
||||||
|
companyId,
|
||||||
|
provider: providerId,
|
||||||
|
providerConfigId: "discovery-preview",
|
||||||
|
operation: "secret_provider_config.discovery.preview",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
listProviderConfigs: (companyId: string) =>
|
listProviderConfigs: (companyId: string) =>
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -1071,6 +1134,13 @@ export function secretService(db: Db) {
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
removeProviderConfig: async (id: string) =>
|
||||||
|
db
|
||||||
|
.delete(companySecretProviderConfigs)
|
||||||
|
.where(eq(companySecretProviderConfigs.id, id))
|
||||||
|
.returning()
|
||||||
|
.then((rows) => rows[0] ?? null),
|
||||||
|
|
||||||
setDefaultProviderConfig: async (id: string) => {
|
setDefaultProviderConfig: async (id: string) => {
|
||||||
const existing = await getProviderConfigById(id);
|
const existing = await getProviderConfigById(id);
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type {
|
||||||
CompanySecret,
|
CompanySecret,
|
||||||
CompanySecretUsageBinding,
|
CompanySecretUsageBinding,
|
||||||
CompanySecretProviderConfig,
|
CompanySecretProviderConfig,
|
||||||
|
SecretProviderConfigDiscoveryPreviewResult,
|
||||||
RemoteSecretImportPreviewResult,
|
RemoteSecretImportPreviewResult,
|
||||||
RemoteSecretImportResult,
|
RemoteSecretImportResult,
|
||||||
SecretAccessEvent,
|
SecretAccessEvent,
|
||||||
|
|
@ -95,6 +96,14 @@ export interface RemoteImportInput {
|
||||||
secrets: RemoteImportSelectionInput[];
|
secrets: RemoteImportSelectionInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SecretProviderConfigDiscoveryPreviewInput {
|
||||||
|
provider: SecretProvider;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
query?: string | null;
|
||||||
|
nextToken?: string | null;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const secretsApi = {
|
export const secretsApi = {
|
||||||
list: (companyId: string) => api.get<CompanySecret[]>(`/companies/${companyId}/secrets`),
|
list: (companyId: string) => api.get<CompanySecret[]>(`/companies/${companyId}/secrets`),
|
||||||
providers: (companyId: string) =>
|
providers: (companyId: string) =>
|
||||||
|
|
@ -103,11 +112,21 @@ export const secretsApi = {
|
||||||
api.get<SecretProviderHealthResponse>(`/companies/${companyId}/secret-providers/health`),
|
api.get<SecretProviderHealthResponse>(`/companies/${companyId}/secret-providers/health`),
|
||||||
providerConfigs: (companyId: string) =>
|
providerConfigs: (companyId: string) =>
|
||||||
api.get<CompanySecretProviderConfig[]>(`/companies/${companyId}/secret-provider-configs`),
|
api.get<CompanySecretProviderConfig[]>(`/companies/${companyId}/secret-provider-configs`),
|
||||||
|
providerConfigDiscoveryPreview: (
|
||||||
|
companyId: string,
|
||||||
|
data: SecretProviderConfigDiscoveryPreviewInput,
|
||||||
|
) =>
|
||||||
|
api.post<SecretProviderConfigDiscoveryPreviewResult>(
|
||||||
|
`/companies/${companyId}/secret-provider-configs/discovery/preview`,
|
||||||
|
data,
|
||||||
|
),
|
||||||
createProviderConfig: (companyId: string, data: CreateSecretProviderConfigInput) =>
|
createProviderConfig: (companyId: string, data: CreateSecretProviderConfigInput) =>
|
||||||
api.post<CompanySecretProviderConfig>(`/companies/${companyId}/secret-provider-configs`, data),
|
api.post<CompanySecretProviderConfig>(`/companies/${companyId}/secret-provider-configs`, data),
|
||||||
updateProviderConfig: (id: string, data: UpdateSecretProviderConfigInput) =>
|
updateProviderConfig: (id: string, data: UpdateSecretProviderConfigInput) =>
|
||||||
api.patch<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`, data),
|
api.patch<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`, data),
|
||||||
disableProviderConfig: (id: string) =>
|
disableProviderConfig: (id: string) =>
|
||||||
|
api.patch<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`, { status: "disabled" }),
|
||||||
|
removeProviderConfig: (id: string) =>
|
||||||
api.delete<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`),
|
api.delete<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`),
|
||||||
setDefaultProviderConfig: (id: string) =>
|
setDefaultProviderConfig: (id: string) =>
|
||||||
api.post<CompanySecretProviderConfig>(`/secret-provider-configs/${id}/default`, {}),
|
api.post<CompanySecretProviderConfig>(`/secret-provider-configs/${id}/default`, {}),
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,25 @@ import { act } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import type { CompanySecretProviderConfig, SecretProviderDescriptor } from "@paperclipai/shared";
|
import type {
|
||||||
|
CompanySecretProviderConfig,
|
||||||
|
SecretProviderConfigDiscoveryPreviewResult,
|
||||||
|
SecretProviderDescriptor,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ProviderVaultsTab, Secrets } from "./Secrets";
|
import { ProviderVaultsTab, Secrets } from "./Secrets";
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
|
|
||||||
const mockSecretsApi = vi.hoisted(() => ({
|
const mockSecretsApi = vi.hoisted(() => ({
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
providers: vi.fn(),
|
providers: vi.fn(),
|
||||||
providerHealth: vi.fn(),
|
providerHealth: vi.fn(),
|
||||||
providerConfigs: vi.fn(),
|
providerConfigs: vi.fn(),
|
||||||
|
providerConfigDiscoveryPreview: vi.fn(),
|
||||||
createProviderConfig: vi.fn(),
|
createProviderConfig: vi.fn(),
|
||||||
updateProviderConfig: vi.fn(),
|
updateProviderConfig: vi.fn(),
|
||||||
disableProviderConfig: vi.fn(),
|
disableProviderConfig: vi.fn(),
|
||||||
|
removeProviderConfig: vi.fn(),
|
||||||
setDefaultProviderConfig: vi.fn(),
|
setDefaultProviderConfig: vi.fn(),
|
||||||
checkProviderConfigHealth: vi.fn(),
|
checkProviderConfigHealth: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
|
|
@ -133,6 +140,79 @@ async function flushReact() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeDiscoveryPreview(
|
||||||
|
overrides: Partial<SecretProviderConfigDiscoveryPreviewResult> = {},
|
||||||
|
): SecretProviderConfigDiscoveryPreviewResult {
|
||||||
|
return {
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
nextToken: null,
|
||||||
|
sampledSecretCount: 2,
|
||||||
|
skippedForeignPaperclipSampleCount: 0,
|
||||||
|
warnings: [],
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
displayName: "AWS production",
|
||||||
|
config: {
|
||||||
|
region: "us-east-1",
|
||||||
|
namespace: "prod-use1",
|
||||||
|
secretNamePrefix: "paperclip",
|
||||||
|
kmsKeyId: "alias/paperclip-secrets",
|
||||||
|
ownerTag: "platform",
|
||||||
|
environmentTag: "production",
|
||||||
|
},
|
||||||
|
sampleCount: 2,
|
||||||
|
samples: [
|
||||||
|
{
|
||||||
|
name: "paperclip/prod-use1/company-1/openai",
|
||||||
|
hasKmsKey: true,
|
||||||
|
tagKeys: ["owner", "environment"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
signals: {
|
||||||
|
namespace: "prod-use1",
|
||||||
|
secretNamePrefix: "paperclip",
|
||||||
|
environmentTag: "production",
|
||||||
|
ownerTag: "platform",
|
||||||
|
kmsKeyId: "alias/paperclip-secrets",
|
||||||
|
hasKmsKey: true,
|
||||||
|
sampleCount: 2,
|
||||||
|
paperclipManagedSampleCount: 0,
|
||||||
|
skippedForeignPaperclipSampleCount: 0,
|
||||||
|
},
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInputValue(input: HTMLInputElement, value: string) {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||||
|
setter?.call(input, value);
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAwsVaultDialog() {
|
||||||
|
const vaultTabButton = [...document.querySelectorAll("button")].find(
|
||||||
|
(button) => button.textContent?.includes("Provider vaults"),
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
vaultTabButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||||
|
vaultTabButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||||
|
vaultTabButton?.click();
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
const addVaultButtons = [...document.querySelectorAll("button")].filter(
|
||||||
|
(button) => button.textContent?.includes("Add vault"),
|
||||||
|
) as HTMLButtonElement[];
|
||||||
|
await act(async () => {
|
||||||
|
addVaultButtons[1]?.click();
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
}
|
||||||
|
|
||||||
describe("Secrets page layout", () => {
|
describe("Secrets page layout", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
|
@ -153,6 +233,7 @@ describe("Secrets page layout", () => {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs);
|
mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs);
|
||||||
|
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValue(makeDiscoveryPreview());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -200,6 +281,7 @@ describe("Secrets page layout", () => {
|
||||||
onCreate={vi.fn()}
|
onCreate={vi.fn()}
|
||||||
onEdit={vi.fn()}
|
onEdit={vi.fn()}
|
||||||
onDisable={vi.fn()}
|
onDisable={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
onSetDefault={vi.fn()}
|
onSetDefault={vi.fn()}
|
||||||
onHealthCheck={vi.fn()}
|
onHealthCheck={vi.fn()}
|
||||||
pendingActionId={null}
|
pendingActionId={null}
|
||||||
|
|
@ -218,6 +300,64 @@ describe("Secrets page layout", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("warns that removing a provider vault only removes Paperclip config", async () => {
|
||||||
|
mockSecretsApi.removeProviderConfig.mockResolvedValueOnce(providerConfigs[1]);
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Secrets />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
const vaultTabButton = [...document.querySelectorAll("button")].find(
|
||||||
|
(button) => button.textContent?.includes("Provider vaults"),
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
vaultTabButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||||
|
vaultTabButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||||
|
vaultTabButton?.click();
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
const removeButtons = [...document.querySelectorAll("button")].filter(
|
||||||
|
(button) => button.textContent?.trim() === "Remove",
|
||||||
|
) as HTMLButtonElement[];
|
||||||
|
await act(async () => {
|
||||||
|
removeButtons[1]?.click();
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain("Remove provider vault");
|
||||||
|
expect(document.body.textContent).toContain("from Paperclip only");
|
||||||
|
expect(document.body.textContent).toContain("does not delete");
|
||||||
|
expect(document.body.textContent).toContain("AWS Secrets Manager");
|
||||||
|
|
||||||
|
const confirmButton = [...document.querySelectorAll("button")].find(
|
||||||
|
(button) => button.textContent?.includes("Remove from Paperclip"),
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
confirmButton?.click();
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(mockSecretsApi.removeProviderConfig).toHaveBeenCalledWith("vault-aws");
|
||||||
|
expect(mockSecretsApi.disableProviderConfig).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("opens reference details from the secrets table count", async () => {
|
it("opens reference details from the secrets table count", async () => {
|
||||||
mockSecretsApi.list.mockResolvedValue([
|
mockSecretsApi.list.mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|
@ -344,4 +484,173 @@ describe("Secrets page layout", () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("discovers AWS provider vault candidates and applies selected values as prefill", async () => {
|
||||||
|
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce(makeDiscoveryPreview());
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Secrets />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
await openAwsVaultDialog();
|
||||||
|
|
||||||
|
const discoveryButton = document.querySelector(
|
||||||
|
'[data-testid="aws-vault-discovery-button"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(discoveryButton).not.toBeNull();
|
||||||
|
expect(discoveryButton?.disabled).toBe(true);
|
||||||
|
|
||||||
|
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement | null;
|
||||||
|
const prefixInput = document.getElementById("provider-vault-secret-name-prefix") as HTMLInputElement | null;
|
||||||
|
expect(regionInput).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
setInputValue(regionInput!, "us-east-1");
|
||||||
|
setInputValue(prefixInput!, "paperclip");
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(discoveryButton?.disabled).toBe(false);
|
||||||
|
await act(async () => {
|
||||||
|
discoveryButton?.click();
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(mockSecretsApi.providerConfigDiscoveryPreview).toHaveBeenCalledWith("company-1", {
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
config: {
|
||||||
|
region: "us-east-1",
|
||||||
|
namespace: null,
|
||||||
|
secretNamePrefix: "paperclip",
|
||||||
|
kmsKeyId: null,
|
||||||
|
ownerTag: null,
|
||||||
|
environmentTag: null,
|
||||||
|
},
|
||||||
|
query: "paperclip",
|
||||||
|
pageSize: 25,
|
||||||
|
});
|
||||||
|
expect(document.body.textContent).toContain("AWS production");
|
||||||
|
|
||||||
|
const useValuesButton = [...document.querySelectorAll("button")].find(
|
||||||
|
(button) => button.textContent?.includes("Use values"),
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
useValuesButton?.click();
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect((document.getElementById("vault-name") as HTMLInputElement).value).toBe("AWS production");
|
||||||
|
expect((document.getElementById("provider-vault-namespace") as HTMLInputElement).value).toBe("prod-use1");
|
||||||
|
expect((document.getElementById("provider-vault-secret-name-prefix") as HTMLInputElement).value).toBe("paperclip");
|
||||||
|
expect((document.getElementById("provider-vault-kms-key-id") as HTMLInputElement).value).toBe("alias/paperclip-secrets");
|
||||||
|
expect((document.getElementById("provider-vault-owner-tag") as HTMLInputElement).value).toBe("platform");
|
||||||
|
expect((document.getElementById("provider-vault-environment-tag") as HTMLInputElement).value).toBe("production");
|
||||||
|
expect(mockSecretsApi.createProviderConfig).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows AWS discovery errors without replacing manual vault form values", async () => {
|
||||||
|
mockSecretsApi.providerConfigDiscoveryPreview.mockRejectedValueOnce(
|
||||||
|
new ApiError("AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", 403, {
|
||||||
|
details: { code: "access_denied" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Secrets />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
await openAwsVaultDialog();
|
||||||
|
|
||||||
|
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement;
|
||||||
|
const namespaceInput = document.getElementById("provider-vault-namespace") as HTMLInputElement;
|
||||||
|
await act(async () => {
|
||||||
|
setInputValue(regionInput, "us-west-2");
|
||||||
|
setInputValue(namespaceInput, "manual-prod");
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
const discoveryButton = document.querySelector(
|
||||||
|
'[data-testid="aws-vault-discovery-button"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
await act(async () => {
|
||||||
|
discoveryButton?.click();
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain("AWS Secrets Manager denied the request");
|
||||||
|
expect(regionInput.value).toBe("us-west-2");
|
||||||
|
expect(namespaceInput.value).toBe("manual-prod");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an empty AWS discovery result without blocking manual entry", async () => {
|
||||||
|
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce(
|
||||||
|
makeDiscoveryPreview({ candidates: [], sampledSecretCount: 0 }),
|
||||||
|
);
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Secrets />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
await openAwsVaultDialog();
|
||||||
|
|
||||||
|
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement;
|
||||||
|
await act(async () => {
|
||||||
|
setInputValue(regionInput, "us-east-2");
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await act(async () => {
|
||||||
|
(document.querySelector('[data-testid="aws-vault-discovery-button"]') as HTMLButtonElement | null)?.click();
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain("No AWS vault metadata candidates found");
|
||||||
|
expect(regionInput.value).toBe("us-east-2");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ import type {
|
||||||
CompanySecret,
|
CompanySecret,
|
||||||
CompanySecretUsageBinding,
|
CompanySecretUsageBinding,
|
||||||
CompanySecretProviderConfig,
|
CompanySecretProviderConfig,
|
||||||
|
SecretProviderConfigDiscoveryCandidate,
|
||||||
|
SecretProviderConfigDiscoveryPreviewResult,
|
||||||
SecretAccessEvent,
|
SecretAccessEvent,
|
||||||
SecretManagedMode,
|
SecretManagedMode,
|
||||||
SecretProvider,
|
SecretProvider,
|
||||||
|
|
@ -325,6 +327,16 @@ function buildProviderVaultConfig(form: ProviderVaultForm): Record<string, unkno
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAwsProviderVaultDiscoveryQuery(form: ProviderVaultForm): string | null {
|
||||||
|
return (
|
||||||
|
form.secretNamePrefix.trim() ||
|
||||||
|
form.namespace.trim() ||
|
||||||
|
form.environmentTag.trim() ||
|
||||||
|
form.ownerTag.trim() ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getAwsManagedPathPreview(input: {
|
export function getAwsManagedPathPreview(input: {
|
||||||
provider: SecretProviderDescriptor | null | undefined;
|
provider: SecretProviderDescriptor | null | undefined;
|
||||||
health: SecretProviderHealthResponse | null;
|
health: SecretProviderHealthResponse | null;
|
||||||
|
|
@ -372,8 +384,12 @@ export function Secrets() {
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<CompanySecret | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<CompanySecret | null>(null);
|
||||||
const [vaultDialogOpen, setVaultDialogOpen] = useState(false);
|
const [vaultDialogOpen, setVaultDialogOpen] = useState(false);
|
||||||
const [editingVault, setEditingVault] = useState<CompanySecretProviderConfig | null>(null);
|
const [editingVault, setEditingVault] = useState<CompanySecretProviderConfig | null>(null);
|
||||||
|
const [removeVaultConfirm, setRemoveVaultConfirm] = useState<CompanySecretProviderConfig | null>(null);
|
||||||
const [vaultForm, setVaultForm] = useState<ProviderVaultForm>(() => emptyProviderVaultForm());
|
const [vaultForm, setVaultForm] = useState<ProviderVaultForm>(() => emptyProviderVaultForm());
|
||||||
const [vaultError, setVaultError] = useState<string | null>(null);
|
const [vaultError, setVaultError] = useState<string | null>(null);
|
||||||
|
const [vaultDiscovery, setVaultDiscovery] =
|
||||||
|
useState<SecretProviderConfigDiscoveryPreviewResult | null>(null);
|
||||||
|
const [vaultDiscoveryError, setVaultDiscoveryError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "Secrets" }]);
|
setBreadcrumbs([{ label: "Secrets" }]);
|
||||||
|
|
@ -648,6 +664,24 @@ export function Secrets() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const discoverVaultMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
secretsApi.providerConfigDiscoveryPreview(selectedCompanyId!, {
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
config: buildProviderVaultConfig(vaultForm),
|
||||||
|
query: getAwsProviderVaultDiscoveryQuery(vaultForm),
|
||||||
|
pageSize: 25,
|
||||||
|
}),
|
||||||
|
onSuccess: (preview) => {
|
||||||
|
setVaultDiscovery(preview);
|
||||||
|
setVaultDiscoveryError(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setVaultDiscovery(null);
|
||||||
|
setVaultDiscoveryError(error instanceof ApiError ? error.message : (error as Error).message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const disableVaultMutation = useMutation({
|
const disableVaultMutation = useMutation({
|
||||||
mutationFn: (id: string) => secretsApi.disableProviderConfig(id),
|
mutationFn: (id: string) => secretsApi.disableProviderConfig(id),
|
||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
|
|
@ -663,6 +697,26 @@ export function Secrets() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const removeVaultMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => secretsApi.removeProviderConfig(id),
|
||||||
|
onSuccess: (removed) => {
|
||||||
|
pushToast({
|
||||||
|
title: "Provider vault removed",
|
||||||
|
body: `${removed.displayName} was removed from Paperclip only.`,
|
||||||
|
tone: "info",
|
||||||
|
});
|
||||||
|
setRemoveVaultConfirm(null);
|
||||||
|
invalidateAll();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
pushToast({
|
||||||
|
title: "Remove failed",
|
||||||
|
body: error instanceof Error ? error.message : "Try again",
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const defaultVaultMutation = useMutation({
|
const defaultVaultMutation = useMutation({
|
||||||
mutationFn: (id: string) => secretsApi.setDefaultProviderConfig(id),
|
mutationFn: (id: string) => secretsApi.setDefaultProviderConfig(id),
|
||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
|
|
@ -735,6 +789,8 @@ export function Secrets() {
|
||||||
setEditingVault(null);
|
setEditingVault(null);
|
||||||
setVaultForm(emptyProviderVaultForm(provider));
|
setVaultForm(emptyProviderVaultForm(provider));
|
||||||
setVaultError(null);
|
setVaultError(null);
|
||||||
|
setVaultDiscovery(null);
|
||||||
|
setVaultDiscoveryError(null);
|
||||||
setVaultDialogOpen(true);
|
setVaultDialogOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -742,9 +798,26 @@ export function Secrets() {
|
||||||
setEditingVault(config);
|
setEditingVault(config);
|
||||||
setVaultForm(providerVaultFormFromConfig(config));
|
setVaultForm(providerVaultFormFromConfig(config));
|
||||||
setVaultError(null);
|
setVaultError(null);
|
||||||
|
setVaultDiscovery(null);
|
||||||
|
setVaultDiscoveryError(null);
|
||||||
setVaultDialogOpen(true);
|
setVaultDialogOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyVaultDiscoveryCandidate(candidate: SecretProviderConfigDiscoveryCandidate) {
|
||||||
|
if (candidate.provider !== "aws_secrets_manager") return;
|
||||||
|
const config = candidate.config as Record<string, unknown>;
|
||||||
|
setVaultForm((current) => ({
|
||||||
|
...current,
|
||||||
|
displayName: current.displayName.trim() ? current.displayName : candidate.displayName,
|
||||||
|
region: providerConfigValue(config, "region"),
|
||||||
|
namespace: providerConfigValue(config, "namespace"),
|
||||||
|
secretNamePrefix: providerConfigValue(config, "secretNamePrefix"),
|
||||||
|
kmsKeyId: providerConfigValue(config, "kmsKeyId"),
|
||||||
|
ownerTag: providerConfigValue(config, "ownerTag"),
|
||||||
|
environmentTag: providerConfigValue(config, "environmentTag"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 text-sm text-muted-foreground">Select a company to manage secrets.</div>
|
<div className="p-6 text-sm text-muted-foreground">Select a company to manage secrets.</div>
|
||||||
|
|
@ -923,10 +996,12 @@ export function Secrets() {
|
||||||
onCreate={openCreateVault}
|
onCreate={openCreateVault}
|
||||||
onEdit={openEditVault}
|
onEdit={openEditVault}
|
||||||
onDisable={(config) => disableVaultMutation.mutate(config.id)}
|
onDisable={(config) => disableVaultMutation.mutate(config.id)}
|
||||||
|
onRemove={(config) => setRemoveVaultConfirm(config)}
|
||||||
onSetDefault={(config) => defaultVaultMutation.mutate(config.id)}
|
onSetDefault={(config) => defaultVaultMutation.mutate(config.id)}
|
||||||
onHealthCheck={(config) => healthVaultMutation.mutate(config.id)}
|
onHealthCheck={(config) => healthVaultMutation.mutate(config.id)}
|
||||||
pendingActionId={
|
pendingActionId={
|
||||||
disableVaultMutation.variables ??
|
disableVaultMutation.variables ??
|
||||||
|
removeVaultMutation.variables ??
|
||||||
defaultVaultMutation.variables ??
|
defaultVaultMutation.variables ??
|
||||||
healthVaultMutation.variables ??
|
healthVaultMutation.variables ??
|
||||||
null
|
null
|
||||||
|
|
@ -1305,6 +1380,8 @@ export function Secrets() {
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const provider = event.target.value as SecretProvider;
|
const provider = event.target.value as SecretProvider;
|
||||||
setVaultForm(emptyProviderVaultForm(provider));
|
setVaultForm(emptyProviderVaultForm(provider));
|
||||||
|
setVaultDiscovery(null);
|
||||||
|
setVaultDiscoveryError(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PROVIDER_ORDER.map((provider) => (
|
{PROVIDER_ORDER.map((provider) => (
|
||||||
|
|
@ -1367,6 +1444,21 @@ export function Secrets() {
|
||||||
|
|
||||||
<ProviderVaultFields form={vaultForm} onChange={setVaultForm} />
|
<ProviderVaultFields form={vaultForm} onChange={setVaultForm} />
|
||||||
|
|
||||||
|
{!editingVault && vaultForm.provider === "aws_secrets_manager" ? (
|
||||||
|
<AwsProviderVaultDiscoveryPanel
|
||||||
|
form={vaultForm}
|
||||||
|
preview={vaultDiscovery}
|
||||||
|
error={vaultDiscoveryError}
|
||||||
|
loading={discoverVaultMutation.isPending}
|
||||||
|
onDiscover={() => {
|
||||||
|
setVaultDiscovery(null);
|
||||||
|
setVaultDiscoveryError(null);
|
||||||
|
discoverVaultMutation.mutate();
|
||||||
|
}}
|
||||||
|
onApply={applyVaultDiscoveryCandidate}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{vaultForm.provider === "gcp_secret_manager" || vaultForm.provider === "vault" ? (
|
{vaultForm.provider === "gcp_secret_manager" || vaultForm.provider === "vault" ? (
|
||||||
<div className="rounded-md border border-sky-500/30 bg-sky-500/5 p-3 text-xs text-sky-700 dark:text-sky-300">
|
<div className="rounded-md border border-sky-500/30 bg-sky-500/5 p-3 text-xs text-sky-700 dark:text-sky-300">
|
||||||
This provider can save draft routing metadata, but runtime writes and resolution stay disabled until
|
This provider can save draft routing metadata, but runtime writes and resolution stay disabled until
|
||||||
|
|
@ -1510,6 +1602,32 @@ export function Secrets() {
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(removeVaultConfirm)} onOpenChange={(open) => !open && setRemoveVaultConfirm(null)}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remove provider vault</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Removes <strong>{removeVaultConfirm?.displayName}</strong> from Paperclip only.{" "}
|
||||||
|
{removeVaultConfirm?.provider === "aws_secrets_manager"
|
||||||
|
? "This does not delete the remote AWS Secrets Manager vault, secrets, or any AWS data."
|
||||||
|
: "This does not delete any remote provider data."}{" "}
|
||||||
|
Secrets using this vault will lose the vault association until you assign another one.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRemoveVaultConfirm(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => removeVaultConfirm && removeVaultMutation.mutate(removeVaultConfirm.id)}
|
||||||
|
disabled={removeVaultMutation.isPending}
|
||||||
|
>
|
||||||
|
{removeVaultMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
|
||||||
|
Remove from Paperclip
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1748,6 +1866,7 @@ export function ProviderVaultsTab({
|
||||||
onCreate,
|
onCreate,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDisable,
|
onDisable,
|
||||||
|
onRemove,
|
||||||
onSetDefault,
|
onSetDefault,
|
||||||
onHealthCheck,
|
onHealthCheck,
|
||||||
pendingActionId,
|
pendingActionId,
|
||||||
|
|
@ -1760,6 +1879,7 @@ export function ProviderVaultsTab({
|
||||||
onCreate: (provider: SecretProvider) => void;
|
onCreate: (provider: SecretProvider) => void;
|
||||||
onEdit: (config: CompanySecretProviderConfig) => void;
|
onEdit: (config: CompanySecretProviderConfig) => void;
|
||||||
onDisable: (config: CompanySecretProviderConfig) => void;
|
onDisable: (config: CompanySecretProviderConfig) => void;
|
||||||
|
onRemove: (config: CompanySecretProviderConfig) => void;
|
||||||
onSetDefault: (config: CompanySecretProviderConfig) => void;
|
onSetDefault: (config: CompanySecretProviderConfig) => void;
|
||||||
onHealthCheck: (config: CompanySecretProviderConfig) => void;
|
onHealthCheck: (config: CompanySecretProviderConfig) => void;
|
||||||
pendingActionId: string | null;
|
pendingActionId: string | null;
|
||||||
|
|
@ -1840,6 +1960,7 @@ export function ProviderVaultsTab({
|
||||||
pending={pendingActionId === config.id}
|
pending={pendingActionId === config.id}
|
||||||
onEdit={() => onEdit(config)}
|
onEdit={() => onEdit(config)}
|
||||||
onDisable={() => onDisable(config)}
|
onDisable={() => onDisable(config)}
|
||||||
|
onRemove={() => onRemove(config)}
|
||||||
onSetDefault={() => onSetDefault(config)}
|
onSetDefault={() => onSetDefault(config)}
|
||||||
onHealthCheck={() => onHealthCheck(config)}
|
onHealthCheck={() => onHealthCheck(config)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1858,6 +1979,7 @@ function ProviderVaultCard({
|
||||||
pending,
|
pending,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDisable,
|
onDisable,
|
||||||
|
onRemove,
|
||||||
onSetDefault,
|
onSetDefault,
|
||||||
onHealthCheck,
|
onHealthCheck,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -1865,6 +1987,7 @@ function ProviderVaultCard({
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDisable: () => void;
|
onDisable: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
onSetDefault: () => void;
|
onSetDefault: () => void;
|
||||||
onHealthCheck: () => void;
|
onHealthCheck: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -1936,6 +2059,16 @@ function ProviderVaultCard({
|
||||||
<Ban className="h-3.5 w-3.5 mr-1" />
|
<Ban className="h-3.5 w-3.5 mr-1" />
|
||||||
Disable
|
Disable
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={onRemove}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -2002,6 +2135,162 @@ function ProviderVaultFields({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AwsProviderVaultDiscoveryPanel({
|
||||||
|
form,
|
||||||
|
preview,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
onDiscover,
|
||||||
|
onApply,
|
||||||
|
}: {
|
||||||
|
form: ProviderVaultForm;
|
||||||
|
preview: SecretProviderConfigDiscoveryPreviewResult | null;
|
||||||
|
error: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
onDiscover: () => void;
|
||||||
|
onApply: (candidate: SecretProviderConfigDiscoveryCandidate) => void;
|
||||||
|
}) {
|
||||||
|
const canDiscover = Boolean(form.region.trim());
|
||||||
|
const warnings = preview?.warnings ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 border-t border-border pt-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">AWS discovery</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Uses the current draft routing fields to inspect AWS Secrets Manager metadata. Values are not read.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDiscover}
|
||||||
|
disabled={!canDiscover || loading}
|
||||||
|
data-testid="aws-vault-discovery-button"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||||
|
) : (
|
||||||
|
<Search className="h-3.5 w-3.5 mr-1" />
|
||||||
|
)}
|
||||||
|
Find existing AWS values
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canDiscover ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Enter an AWS region before discovery.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Searching AWS Secrets Manager metadata
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{warnings.length > 0 ? (
|
||||||
|
<div className="space-y-1 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
{warnings.map((warning) => (
|
||||||
|
<div key={warning} className="flex gap-2">
|
||||||
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>{warning}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{preview && preview.candidates.length === 0 && !loading ? (
|
||||||
|
<div className="rounded-md border border-dashed border-border bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||||
|
No AWS vault metadata candidates found. Manual entry is still available.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{preview && preview.candidates.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Database className="h-3.5 w-3.5" />
|
||||||
|
<span>
|
||||||
|
{preview.candidates.length} candidate{preview.candidates.length === 1 ? "" : "s"} from{" "}
|
||||||
|
{preview.sampledSecretCount} sampled secret{preview.sampledSecretCount === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2" data-testid="aws-vault-discovery-candidates">
|
||||||
|
{preview.candidates.map((candidate, index) => (
|
||||||
|
<AwsProviderVaultDiscoveryCandidateRow
|
||||||
|
key={`${candidate.displayName}-${index}`}
|
||||||
|
candidate={candidate}
|
||||||
|
onApply={() => onApply(candidate)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AwsProviderVaultDiscoveryCandidateRow({
|
||||||
|
candidate,
|
||||||
|
onApply,
|
||||||
|
}: {
|
||||||
|
candidate: SecretProviderConfigDiscoveryCandidate;
|
||||||
|
onApply: () => void;
|
||||||
|
}) {
|
||||||
|
const fieldSummary = [
|
||||||
|
providerConfigValue(candidate.config, "region"),
|
||||||
|
providerConfigValue(candidate.config, "namespace"),
|
||||||
|
providerConfigValue(candidate.config, "secretNamePrefix"),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-background p-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-medium leading-snug">{candidate.displayName}</p>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{candidate.sampleCount} sample{candidate.sampleCount === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||||
|
{fieldSummary.length > 0 ? fieldSummary.join(" / ") : "No stable namespace or prefix detected"}
|
||||||
|
</p>
|
||||||
|
{candidate.samples[0] ? (
|
||||||
|
<p className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
|
||||||
|
{candidate.samples[0].name}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={onApply}>
|
||||||
|
Use values
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{candidate.warnings.length > 0 ? (
|
||||||
|
<div className="mt-2 space-y-1 text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
{candidate.warnings.map((warning) => (
|
||||||
|
<div key={warning} className="flex gap-2">
|
||||||
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>{warning}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TextField({
|
function TextField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import {
|
||||||
storybookProjects,
|
storybookProjects,
|
||||||
storybookSecretAccessEvents,
|
storybookSecretAccessEvents,
|
||||||
storybookSecretBindings,
|
storybookSecretBindings,
|
||||||
|
storybookSecretProviderConfigs,
|
||||||
|
storybookSecretProviderDiscoveryPreview,
|
||||||
storybookSecretProviderHealth,
|
storybookSecretProviderHealth,
|
||||||
storybookSecretProviders,
|
storybookSecretProviders,
|
||||||
storybookSecrets,
|
storybookSecrets,
|
||||||
|
|
@ -187,6 +189,20 @@ function installStorybookApiFixtures() {
|
||||||
return Response.json(storybookSecretProviderHealth);
|
return Response.json(storybookSecretProviderHealth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const secretProviderConfigsMatch = url.pathname.match(
|
||||||
|
/^\/api\/companies\/([^/]+)\/secret-provider-configs$/,
|
||||||
|
);
|
||||||
|
if (secretProviderConfigsMatch) {
|
||||||
|
return Response.json(storybookSecretProviderConfigs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretProviderConfigDiscoveryPreviewMatch = url.pathname.match(
|
||||||
|
/^\/api\/companies\/([^/]+)\/secret-provider-configs\/discovery\/preview$/,
|
||||||
|
);
|
||||||
|
if (secretProviderConfigDiscoveryPreviewMatch && init?.method?.toUpperCase() === "POST") {
|
||||||
|
return Response.json(storybookSecretProviderDiscoveryPreview);
|
||||||
|
}
|
||||||
|
|
||||||
const secretUsageMatch = url.pathname.match(/^\/api\/secrets\/([^/]+)\/usage$/);
|
const secretUsageMatch = url.pathname.match(/^\/api\/secrets\/([^/]+)\/usage$/);
|
||||||
if (secretUsageMatch) {
|
if (secretUsageMatch) {
|
||||||
const [, secretId] = secretUsageMatch;
|
const [, secretId] = secretUsageMatch;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type {
|
||||||
Company,
|
Company,
|
||||||
CompanySecret,
|
CompanySecret,
|
||||||
CompanySecretBinding,
|
CompanySecretBinding,
|
||||||
|
CompanySecretProviderConfig,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
ExecutionWorkspace,
|
ExecutionWorkspace,
|
||||||
Goal,
|
Goal,
|
||||||
|
|
@ -15,6 +16,7 @@ import type {
|
||||||
IssueLabel,
|
IssueLabel,
|
||||||
Project,
|
Project,
|
||||||
SecretAccessEvent,
|
SecretAccessEvent,
|
||||||
|
SecretProviderConfigDiscoveryPreviewResult,
|
||||||
SecretProviderDescriptor,
|
SecretProviderDescriptor,
|
||||||
SidebarBadges,
|
SidebarBadges,
|
||||||
WorkspaceRuntimeService,
|
WorkspaceRuntimeService,
|
||||||
|
|
@ -1324,6 +1326,98 @@ export const storybookSecretProviders: SecretProviderDescriptor[] = [
|
||||||
{ id: "vault", label: "HashiCorp Vault", requiresExternalRef: false },
|
{ id: "vault", label: "HashiCorp Vault", requiresExternalRef: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const storybookSecretProviderConfigs: CompanySecretProviderConfig[] = [
|
||||||
|
{
|
||||||
|
id: "provider-config-local",
|
||||||
|
companyId: "company-storybook",
|
||||||
|
provider: "local_encrypted",
|
||||||
|
displayName: "Local encrypted default",
|
||||||
|
status: "ready",
|
||||||
|
isDefault: true,
|
||||||
|
config: { backupReminderAcknowledged: true },
|
||||||
|
healthStatus: "ready",
|
||||||
|
healthCheckedAt: recent(45),
|
||||||
|
healthMessage: "Local encrypted provider is healthy.",
|
||||||
|
healthDetails: null,
|
||||||
|
disabledAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-board",
|
||||||
|
createdAt: recent(2_400),
|
||||||
|
updatedAt: recent(45),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "provider-config-aws-prod",
|
||||||
|
companyId: "company-storybook",
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
displayName: "AWS production",
|
||||||
|
status: "warning",
|
||||||
|
isDefault: false,
|
||||||
|
config: {
|
||||||
|
region: "us-east-1",
|
||||||
|
namespace: "prod-use1",
|
||||||
|
secretNamePrefix: "paperclip",
|
||||||
|
kmsKeyId: "alias/paperclip-secrets",
|
||||||
|
ownerTag: "platform",
|
||||||
|
environmentTag: "production",
|
||||||
|
},
|
||||||
|
healthStatus: "warning",
|
||||||
|
healthCheckedAt: recent(18),
|
||||||
|
healthMessage: "Connected; KMS key rotation policy not yet enforced.",
|
||||||
|
healthDetails: {
|
||||||
|
code: "kms_rotation_policy",
|
||||||
|
message: "Connected; KMS key rotation policy not yet enforced.",
|
||||||
|
guidance: ["Enable automatic key rotation before using this vault for production agents."],
|
||||||
|
},
|
||||||
|
disabledAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-board",
|
||||||
|
createdAt: recent(1_800),
|
||||||
|
updatedAt: recent(18),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const storybookSecretProviderDiscoveryPreview: SecretProviderConfigDiscoveryPreviewResult = {
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
nextToken: null,
|
||||||
|
sampledSecretCount: 6,
|
||||||
|
skippedForeignPaperclipSampleCount: 1,
|
||||||
|
warnings: ["Skipped 1 Paperclip-managed AWS secret from a different deployment namespace."],
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
provider: "aws_secrets_manager",
|
||||||
|
displayName: "AWS production",
|
||||||
|
config: {
|
||||||
|
region: "us-east-1",
|
||||||
|
namespace: "prod-use1",
|
||||||
|
secretNamePrefix: "paperclip",
|
||||||
|
kmsKeyId: "alias/paperclip-secrets",
|
||||||
|
ownerTag: "platform",
|
||||||
|
environmentTag: "production",
|
||||||
|
},
|
||||||
|
sampleCount: 5,
|
||||||
|
samples: [
|
||||||
|
{
|
||||||
|
name: "paperclip/prod-use1/company-storybook/openai_api_key",
|
||||||
|
hasKmsKey: true,
|
||||||
|
tagKeys: ["paperclip:managed-by", "paperclip:environment", "paperclip:provider-owner"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
signals: {
|
||||||
|
namespace: "prod-use1",
|
||||||
|
secretNamePrefix: "paperclip",
|
||||||
|
environmentTag: "production",
|
||||||
|
ownerTag: "platform",
|
||||||
|
kmsKeyId: "alias/paperclip-secrets",
|
||||||
|
hasKmsKey: true,
|
||||||
|
sampleCount: 5,
|
||||||
|
paperclipManagedSampleCount: 5,
|
||||||
|
skippedForeignPaperclipSampleCount: 1,
|
||||||
|
},
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export const storybookSecrets: CompanySecret[] = [
|
export const storybookSecrets: CompanySecret[] = [
|
||||||
{
|
{
|
||||||
id: "secret-openai",
|
id: "secret-openai",
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ if (typeof window !== "undefined") {
|
||||||
function StorybookSecretsFixtures({ children }: { children: ReactNode }) {
|
function StorybookSecretsFixtures({ children }: { children: ReactNode }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
// Seed query caches synchronously so children hydrate from cache on first render.
|
// Seed query caches synchronously so children hydrate from cache on first render.
|
||||||
queryClient.setQueryData(queryKeys.companies.all, storybookCompanies);
|
|
||||||
queryClient.setQueryData(queryKeys.secrets.list(COMPANY_ID), storybookSecrets);
|
queryClient.setQueryData(queryKeys.secrets.list(COMPANY_ID), storybookSecrets);
|
||||||
|
|
||||||
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue