[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:

![Provider vaults
tab](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-provider-vault-secrets/doc/screenshots/pr-6381/provider-vaults-tab.png)

AWS discovery candidate flow:

![AWS discovery candidate
flow](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-provider-vault-secrets/doc/screenshots/pr-6381/aws-discovery-candidates.png)

Provider vault removal confirmation:

![Provider vault removal
confirmation](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-provider-vault-secrets/doc/screenshots/pr-6381/remove-provider-vault-confirmation.png)

## 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:
Dotta
2026-05-19 15:50:23 -05:00
committed by GitHub
parent 9c29394f4d
commit d67347be77
23 changed files with 1602 additions and 13 deletions
@@ -454,6 +454,103 @@ describe("awsSecretsManagerProvider", () => {
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 () => {
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";
+146
View File
@@ -9,10 +9,12 @@ const mockSecretService = vi.hoisted(() => ({
listProviders: vi.fn(),
checkProviders: vi.fn(),
listProviderConfigs: vi.fn(),
previewProviderConfigDiscovery: vi.fn(),
getProviderConfigById: vi.fn(),
createProviderConfig: vi.fn(),
updateProviderConfig: vi.fn(),
disableProviderConfig: vi.fn(),
removeProviderConfig: vi.fn(),
setDefaultProviderConfig: vi.fn(),
checkProviderConfigHealth: vi.fn(),
getById: vi.fn(),
@@ -117,6 +119,22 @@ describe("secret routes", () => {
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 () => {
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "aws_secrets_manager",
@@ -132,6 +150,92 @@ describe("secret routes", () => {
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 () => {
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "vault",
@@ -241,6 +345,48 @@ describe("secret routes", () => {
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 () => {
const res = await request(createApp({
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 () => {
const companyId = await seedCompany();
const svc = secretService(db);
@@ -1207,6 +1236,111 @@ describeEmbeddedPostgres("secretService", () => {
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 () => {
const companyId = await seedCompany();
const svc = secretService(db);
+45 -9
View File
@@ -6,6 +6,7 @@ import {
remoteSecretImportPreviewSchema,
remoteSecretImportSchema,
rotateSecretSchema,
secretProviderConfigDiscoveryPreviewSchema,
updateSecretProviderConfigSchema,
updateSecretSchema,
} from "@paperclipai/shared";
@@ -41,6 +42,41 @@ export function secretRoutes(db: Db) {
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) => {
assertBoard(req);
const companyId = req.params.companyId as string;
@@ -136,27 +172,27 @@ export function secretRoutes(db: Db) {
}
assertCompanyAccess(req, existing.companyId);
const disabled = await svc.disableProviderConfig(id);
if (!disabled) {
const removed = await svc.removeProviderConfig(id);
if (!removed) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: disabled.companyId,
companyId: removed.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.disabled",
action: "secret_provider_config.removed",
entityType: "secret_provider_config",
entityId: disabled.id,
entityId: removed.id,
details: {
provider: disabled.provider,
displayName: disabled.displayName,
status: disabled.status,
provider: removed.provider,
displayName: removed.displayName,
remoteDeleted: false,
},
});
res.json(disabled);
res.json(removed);
});
router.post("/secret-provider-configs/:id/default", async (req, res) => {
@@ -1,6 +1,6 @@
import { createHash, createHmac } from "node:crypto";
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 type {
PreparedSecretVersion,
@@ -24,6 +24,8 @@ const DEFAULT_DELETE_RECOVERY_WINDOW_DAYS = 30;
const AWS_SECRETS_MANAGER_REQUEST_TIMEOUT_MS = 30_000;
const AWS_CREDENTIAL_CACHE_TTL_MS = 5 * 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 =
"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 =
@@ -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 {
if (
value &&
@@ -983,6 +1213,36 @@ export function createAwsSecretsManagerProvider(
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) {
const config = resolveConfig(input.providerConfig);
const gateway = resolveGateway(config);
+12 -1
View File
@@ -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";
export interface StoredSecretVersionMaterial {
@@ -152,6 +156,13 @@ export interface SecretProviderModule {
nextToken?: string | null;
pageSize?: number;
}): Promise<RemoteSecretListResult>;
discoverProviderConfigs?(input: {
companyId: string;
providerConfig: SecretProviderVaultRuntimeConfig;
query?: string | null;
nextToken?: string | null;
pageSize?: number;
}): Promise<SecretProviderConfigDiscoveryPreviewResult>;
resolveVersion(input: {
material: StoredSecretVersionMaterial;
externalRef: string | null;
+70
View File
@@ -20,6 +20,7 @@ import type {
RemoteSecretImportCandidate,
RemoteSecretImportConflict,
RemoteSecretImportRowResult,
SecretProviderConfigDiscoveryPreviewResult,
SecretBindingTargetType,
SecretProvider,
SecretProviderConfigHealthResponse,
@@ -34,6 +35,7 @@ import {
isUuidLike,
normalizeAgentUrlKey,
secretProviderConfigPayloadSchema,
secretProviderConfigDiscoveryPreviewSchema,
updateSecretProviderConfigSchema,
} from "@paperclipai/shared";
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
@@ -471,6 +473,19 @@ export function secretService(db: Db) {
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: {
id: string;
provider: SecretProvider;
@@ -949,6 +964,54 @@ export function secretService(db: Db) {
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) =>
db
.select()
@@ -1071,6 +1134,13 @@ export function secretService(db: Db) {
.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) => {
const existing = await getProviderConfigById(id);
if (!existing) return null;