mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 12:10:37 +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
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue