Files
paperclip/server/src/routes/secrets.ts
T
Dotta d67347be77 [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>
2026-05-19 15:50:23 -05:00

515 lines
15 KiB
TypeScript

import { Router } from "express";
import type { Db } from "@paperclipai/db";
import {
createSecretProviderConfigSchema,
createSecretSchema,
remoteSecretImportPreviewSchema,
remoteSecretImportSchema,
rotateSecretSchema,
secretProviderConfigDiscoveryPreviewSchema,
updateSecretProviderConfigSchema,
updateSecretSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { logActivity, secretService } from "../services/index.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
export function secretRoutes(db: Db) {
const router = Router();
const svc = secretService(db);
const defaultProvider = getConfiguredSecretProvider();
router.get("/companies/:companyId/secret-providers", (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(svc.listProviders());
});
router.get("/companies/:companyId/secret-providers/health", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const checks = await svc.checkProviders();
res.json({ providers: checks });
});
router.get("/companies/:companyId/secret-provider-configs", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, 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) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const created = await svc.createProviderConfig(
companyId,
{
provider: req.body.provider,
displayName: req.body.displayName,
status: req.body.status,
isDefault: req.body.isDefault,
config: req.body.config,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.created",
entityType: "secret_provider_config",
entityId: created.id,
details: {
provider: created.provider,
displayName: created.displayName,
status: created.status,
isDefault: created.isDefault,
},
});
res.status(201).json(created);
});
router.get("/secret-provider-configs/:id", async (req, res) => {
assertBoard(req);
const existing = await svc.getProviderConfigById(req.params.id as string);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
res.json(existing);
});
router.patch("/secret-provider-configs/:id", validate(updateSecretProviderConfigSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getProviderConfigById(id);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const updated = await svc.updateProviderConfig(id, {
displayName: req.body.displayName,
status: req.body.status,
isDefault: req.body.isDefault,
config: req.body.config,
});
if (!updated) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: updated.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.updated",
entityType: "secret_provider_config",
entityId: updated.id,
details: {
provider: updated.provider,
displayName: updated.displayName,
status: updated.status,
isDefault: updated.isDefault,
},
});
res.json(updated);
});
router.delete("/secret-provider-configs/:id", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getProviderConfigById(id);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const removed = await svc.removeProviderConfig(id);
if (!removed) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: removed.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.removed",
entityType: "secret_provider_config",
entityId: removed.id,
details: {
provider: removed.provider,
displayName: removed.displayName,
remoteDeleted: false,
},
});
res.json(removed);
});
router.post("/secret-provider-configs/:id/default", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getProviderConfigById(id);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const updated = await svc.setDefaultProviderConfig(id);
if (!updated) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: updated.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.default_set",
entityType: "secret_provider_config",
entityId: updated.id,
details: {
provider: updated.provider,
displayName: updated.displayName,
isDefault: updated.isDefault,
},
});
res.json(updated);
});
router.post("/secret-provider-configs/:id/health", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getProviderConfigById(id);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const health = await svc.checkProviderConfigHealth(id);
if (!health) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: existing.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.health_checked",
entityType: "secret_provider_config",
entityId: existing.id,
details: {
provider: existing.provider,
status: health.status,
code: health.details.code,
},
});
res.json(health);
});
router.get("/companies/:companyId/secrets", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const secrets = await svc.list(companyId);
res.json(secrets);
});
router.post("/companies/:companyId/secrets", validate(createSecretSchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const created = await svc.create(
companyId,
{
name: req.body.name,
key: req.body.key,
provider: req.body.provider ?? defaultProvider,
providerConfigId: req.body.providerConfigId,
managedMode: req.body.managedMode,
value: req.body.value,
description: req.body.description,
externalRef: req.body.externalRef,
providerVersionRef: req.body.providerVersionRef,
providerMetadata: req.body.providerMetadata,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret.created",
entityType: "secret",
entityId: created.id,
details: { name: created.name, provider: created.provider },
});
res.status(201).json(created);
});
router.post(
"/companies/:companyId/secrets/remote-import/preview",
validate(remoteSecretImportPreviewSchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const preview = await svc.previewRemoteImport(companyId, {
providerConfigId: req.body.providerConfigId,
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.remote_import.previewed",
entityType: "secret_provider_config",
entityId: preview.providerConfigId,
details: {
provider: preview.provider,
candidateCount: preview.candidates.length,
readyCount: preview.candidates.filter((candidate) => candidate.status === "ready").length,
duplicateCount: preview.candidates.filter((candidate) => candidate.status === "duplicate").length,
conflictCount: preview.candidates.filter((candidate) => candidate.status === "conflict").length,
},
});
res.json(preview);
},
);
router.post(
"/companies/:companyId/secrets/remote-import",
validate(remoteSecretImportSchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.importRemoteSecrets(
companyId,
{
providerConfigId: req.body.providerConfigId,
secrets: req.body.secrets,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret.remote_import.completed",
entityType: "secret_provider_config",
entityId: result.providerConfigId,
details: {
provider: result.provider,
importedCount: result.importedCount,
skippedCount: result.skippedCount,
errorCount: result.errorCount,
},
});
res.json(result);
},
);
router.post("/secrets/:id/rotate", validate(rotateSecretSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
if (existing.status === "deleted") {
res.status(404).json({ error: "Secret not found" });
return;
}
const rotated = await svc.rotate(
id,
{
value: req.body.value,
externalRef: req.body.externalRef,
providerVersionRef: req.body.providerVersionRef,
providerConfigId: req.body.providerConfigId,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
await logActivity(db, {
companyId: rotated.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret.rotated",
entityType: "secret",
entityId: rotated.id,
details: { version: rotated.latestVersion },
});
res.json(rotated);
});
router.patch("/secrets/:id", validate(updateSecretSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
if (existing.status === "deleted") {
res.status(404).json({ error: "Secret not found" });
return;
}
const updated = await svc.update(id, {
name: req.body.name,
key: req.body.key,
status: req.body.status,
providerConfigId: req.body.providerConfigId,
description: req.body.description,
externalRef: req.body.externalRef,
providerMetadata: req.body.providerMetadata,
});
if (!updated) {
res.status(404).json({ error: "Secret not found" });
return;
}
await logActivity(db, {
companyId: updated.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret.updated",
entityType: "secret",
entityId: updated.id,
details: { name: updated.name },
});
res.json(updated);
});
router.get("/secrets/:id/usage", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const bindings = await svc.listBindingReferences(existing.companyId, existing.id);
res.json({ secretId: existing.id, bindings });
});
router.get("/secrets/:id/access-events", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const events = await svc.listAccessEvents(existing.companyId, existing.id);
res.json(events);
});
router.delete("/secrets/:id", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const removed = await svc.remove(id);
if (!removed) {
res.status(404).json({ error: "Secret not found" });
return;
}
await logActivity(db, {
companyId: removed.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret.deleted",
entityType: "secret",
entityId: removed.id,
details: { name: removed.name },
});
res.json({ ok: true });
});
return router;
}