mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies. > - Its secrets subsystem can resolve external references such as AWS Secrets Manager values without copying those values into Paperclip custody. > - Operators also need to rotate a referenced secret's value while preserving the same provider reference for consumers inside and outside Paperclip. > - Previously, external-reference rotation could only retarget metadata, and secret detail sheets were driven by local component state rather than shareable navigation state. > - This pull request adds an optional provider write capability, implements AWS Secrets Manager write-through rotation, and exposes capability-aware rotate modes in the UI. > - It also makes secret and each-user definition detail sheets URL-driven and adds a copy-link action. > - The benefit is that operators can update the canonical external value safely while keeping AWS rotation tracking intact, and they can share or navigate directly to secret details. ## Linked Issues or Issue Description ### Subsystem affected Cross-cutting (`server/`, `ui/`, and `packages/shared`). ### Problem or motivation External-reference secrets can follow a provider-managed value, but Paperclip could not write a replacement value back to providers that support it. Operators had to leave Paperclip, update the value separately, and then return without an auditable Paperclip rotation record. Secret detail sheets also could not be shared or restored through browser history because their selection lived only in component state. ### Proposed solution Add an optional `updateExternalSecretValue` provider capability and surface it as `supportsExternalValueWrites`. Implement AWS writes with `PutSecretValue` while leaving the resolution `versionId` unset so future reads continue following `AWSCURRENT`. Add write-value and retarget modes to the rotate dialog for capable providers. Drive secret detail selection from `?secret=` / `?definition=` query parameters and provide a copy-link action. ### Alternatives considered Converting an external reference into a Paperclip-managed secret would break consumers that depend on the existing provider reference. Pinning reads to the newly written AWS version would prevent later out-of-band rotations from flowing through. Keeping sheet selection only in React state would not support browser Back or shareable links. ### Roadmap alignment This extends the completed “Secrets Manager with per-agent access” roadmap capability; it does not duplicate a separate planned roadmap item. Public GitHub searches found no duplicate or closely related issue or PR. ### Additional context The PR includes focused provider, service, and UI render coverage. Cutter also generated previews for the deep-linked detail sheet and capability-aware rotate modes. ## What Changed - Added optional external-value write support to the secret provider contract and provider descriptors. - Implemented AWS Secrets Manager write-through with `PutSecretValue`, audit material, and compensation when persistence fails after the provider write. - Allowed `secretService.rotate()` value updates for external references while rejecting ambiguous value-plus-retarget combinations and unsupported providers. - Added capability-aware “Write new value” and “Change reference” rotate modes with updated custody and action copy. - Made secret and each-user definition detail sheets source their selection from URL query parameters, compose with folder paths, close through browser history, and expose a copy-link action. - Added provider, service, and UI render coverage for write-through, rollback, capability messaging, dialog modes, and deep links. ## Verification - `pnpm vitest run server/src/__tests__/aws-secrets-manager-provider.test.ts` — 18 passed. - `pnpm vitest run server/src/__tests__/secrets-service.test.ts` — 75 passed. - `pnpm vitest run ui/src/pages/Secrets.render.test.tsx` — 31 passed. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — passed with all gates clean. ## Risks - External value writes affect the canonical provider secret and therefore all consumers of that AWS secret; the UI explicitly labels this custody behavior. - A provider write can succeed before Paperclip persistence fails. The service records the written version and AWS support includes compensation coverage to restore the prior value where possible; unrecoverable failures return explicit audit-safe error details. - URL-driven sheet state changes navigation behavior; render tests cover deep links, Back/close behavior, and composition with folder query state. - No database migration or breaking API requirement is introduced; providers without the optional capability retain reference-only behavior. > 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 using exact model ID `gpt-5.6-sol`, high reasoning mode, Codex CLI `0.142.5`, with repository, shell, Git, GitHub CLI, and code-execution tools. The runtime did not expose a context-window size. - Earlier implementation commits were assisted by Anthropic `Claude Fable 5` as recorded in their commit trailers; the exact backend model ID and context-window size were not preserved in the workspace metadata. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes, or confirmed no documentation change is required - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
1374 lines
47 KiB
TypeScript
1374 lines
47 KiB
TypeScript
import { createHash, createHmac } from "node:crypto";
|
|
import { S3Client } from "@aws-sdk/client-s3";
|
|
import type { DeploymentMode, SecretProviderConfigDiscoveryPreviewResult } from "@paperclipai/shared";
|
|
import { unprocessable } from "../errors.js";
|
|
import type {
|
|
PreparedSecretVersion,
|
|
RemoteSecretListResult,
|
|
SecretProviderClientErrorCode,
|
|
SecretProviderHealthCheck,
|
|
SecretProviderModule,
|
|
SecretProviderValidationResult,
|
|
SecretProviderVaultRuntimeConfig,
|
|
SecretProviderWriteContext,
|
|
StoredSecretVersionMaterial,
|
|
} from "./types.js";
|
|
import { SecretProviderClientError } from "./types.js";
|
|
|
|
const AWS_SECRETS_MANAGER_SCHEME = "aws_secrets_manager_v1";
|
|
const DEFAULT_PREFIX = "paperclip";
|
|
const DEFAULT_OWNER_TAG = "paperclip";
|
|
const DEFAULT_VERSION_STAGE = "AWSCURRENT";
|
|
const PAPERCLIP_PENDING_VERSION_STAGE = "PAPERCLIP_PENDING";
|
|
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 =
|
|
"Do not store AWS root credentials or long-lived IAM user access keys in Paperclip company_secrets; the AWS provider bootstrap belongs in deployment infrastructure, the process environment, an AWS profile, or the orchestrator secret store.";
|
|
|
|
interface AwsSecretsManagerMaterial extends StoredSecretVersionMaterial {
|
|
scheme: typeof AWS_SECRETS_MANAGER_SCHEME;
|
|
secretId: string;
|
|
versionId: string | null;
|
|
source: "managed" | "external_reference";
|
|
lastWrittenVersionId?: string | null;
|
|
previousCurrentVersionId?: string | null;
|
|
}
|
|
|
|
interface AwsSecretsManagerConfig {
|
|
region: string;
|
|
endpoint: string;
|
|
deploymentId: string;
|
|
prefix: string;
|
|
kmsKeyId: string | null;
|
|
environmentTag: string;
|
|
providerOwnerTag: string;
|
|
deleteRecoveryWindowDays: number;
|
|
}
|
|
|
|
interface AwsSecretsManagerTag {
|
|
Key: string;
|
|
Value: string;
|
|
}
|
|
|
|
interface AwsSecretsManagerListSecretEntry {
|
|
ARN?: string;
|
|
Name?: string;
|
|
Description?: string;
|
|
KmsKeyId?: string;
|
|
CreatedDate?: string | number | Date;
|
|
LastAccessedDate?: string | number | Date;
|
|
LastChangedDate?: string | number | Date;
|
|
DeletedDate?: string | number | Date;
|
|
Tags?: AwsSecretsManagerTag[];
|
|
}
|
|
|
|
interface AwsCredentialIdentity {
|
|
accessKeyId: string;
|
|
secretAccessKey: string;
|
|
sessionToken?: string;
|
|
}
|
|
|
|
interface CachedAwsCredentialProvider {
|
|
client: S3Client;
|
|
credentials: AwsCredentialIdentity | null;
|
|
expiresAt: number;
|
|
pending: Promise<AwsCredentialIdentity> | null;
|
|
}
|
|
|
|
type ManagedSecretNamespaceContext = Pick<SecretProviderWriteContext, "companyId" | "secretKey">;
|
|
|
|
const awsCredentialProviders = new Map<string, CachedAwsCredentialProvider>();
|
|
|
|
interface AwsSecretsManagerGateway {
|
|
createSecret(input: {
|
|
Name: string;
|
|
SecretString: string;
|
|
KmsKeyId?: string;
|
|
Description?: string;
|
|
Tags: AwsSecretsManagerTag[];
|
|
}): Promise<{
|
|
ARN?: string;
|
|
Name?: string;
|
|
VersionId?: string;
|
|
}>;
|
|
putSecretValue(input: {
|
|
SecretId: string;
|
|
SecretString: string;
|
|
VersionStages?: string[];
|
|
}): Promise<{
|
|
ARN?: string;
|
|
Name?: string;
|
|
VersionId?: string;
|
|
}>;
|
|
getSecretValue(input: {
|
|
SecretId: string;
|
|
VersionId?: string;
|
|
VersionStage?: string;
|
|
}): Promise<{
|
|
SecretString?: string;
|
|
ARN?: string;
|
|
Name?: string;
|
|
VersionId?: string;
|
|
}>;
|
|
deleteSecret(input: {
|
|
SecretId: string;
|
|
RecoveryWindowInDays: number;
|
|
}): Promise<unknown>;
|
|
updateSecretVersionStage?(input: {
|
|
SecretId: string;
|
|
VersionStage: string;
|
|
RemoveFromVersionId?: string;
|
|
MoveToVersionId?: string;
|
|
}): Promise<unknown>;
|
|
listSecrets?(input: {
|
|
MaxResults?: number;
|
|
NextToken?: string;
|
|
Filters?: Array<{
|
|
Key: "all" | "name" | "description" | "tag-key" | "tag-value" | "primary-region" | "owning-service";
|
|
Values: string[];
|
|
}>;
|
|
IncludePlannedDeletion?: boolean;
|
|
}): Promise<{
|
|
SecretList?: AwsSecretsManagerListSecretEntry[];
|
|
NextToken?: string;
|
|
}>;
|
|
}
|
|
|
|
function sha256Hex(value: string): string {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
function hmac(key: string | Buffer, value: string) {
|
|
return createHmac("sha256", key).update(value).digest();
|
|
}
|
|
|
|
function awsDateParts(now = new Date()) {
|
|
const iso = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
return {
|
|
amzDate: iso,
|
|
dateStamp: iso.slice(0, 8),
|
|
};
|
|
}
|
|
|
|
function canonicalHeaderValue(value: string) {
|
|
return value.trim().replace(/\s+/g, " ");
|
|
}
|
|
|
|
function signAwsSecretsManagerRequest(input: {
|
|
endpoint: URL;
|
|
region: string;
|
|
operation: string;
|
|
body: string;
|
|
credentials: AwsCredentialIdentity;
|
|
}) {
|
|
const { amzDate, dateStamp } = awsDateParts();
|
|
const payloadHash = sha256Hex(input.body);
|
|
const headers: Record<string, string> = {
|
|
"content-type": "application/x-amz-json-1.1",
|
|
host: input.endpoint.host,
|
|
"x-amz-content-sha256": payloadHash,
|
|
"x-amz-date": amzDate,
|
|
"x-amz-target": `secretsmanager.${input.operation}`,
|
|
};
|
|
if (input.credentials.sessionToken) {
|
|
headers["x-amz-security-token"] = input.credentials.sessionToken;
|
|
}
|
|
|
|
const sortedHeaderNames = Object.keys(headers).sort();
|
|
const canonicalHeaders = sortedHeaderNames
|
|
.map((name) => `${name}:${canonicalHeaderValue(headers[name] ?? "")}\n`)
|
|
.join("");
|
|
const signedHeaders = sortedHeaderNames.join(";");
|
|
const canonicalRequest = [
|
|
"POST",
|
|
input.endpoint.pathname || "/",
|
|
"",
|
|
canonicalHeaders,
|
|
signedHeaders,
|
|
payloadHash,
|
|
].join("\n");
|
|
const credentialScope = `${dateStamp}/${input.region}/secretsmanager/aws4_request`;
|
|
const stringToSign = [
|
|
"AWS4-HMAC-SHA256",
|
|
amzDate,
|
|
credentialScope,
|
|
sha256Hex(canonicalRequest),
|
|
].join("\n");
|
|
const dateKey = hmac(`AWS4${input.credentials.secretAccessKey}`, dateStamp);
|
|
const regionKey = hmac(dateKey, input.region);
|
|
const serviceKey = hmac(regionKey, "secretsmanager");
|
|
const signingKey = hmac(serviceKey, "aws4_request");
|
|
const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex");
|
|
|
|
return {
|
|
...headers,
|
|
authorization:
|
|
`AWS4-HMAC-SHA256 Credential=${input.credentials.accessKeyId}/${credentialScope}, ` +
|
|
`SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
};
|
|
}
|
|
|
|
async function loadAwsCredentials(region: string): Promise<AwsCredentialIdentity> {
|
|
const now = Date.now();
|
|
let cached = awsCredentialProviders.get(region);
|
|
if (!cached) {
|
|
// S3Client is only used as a carrier for the AWS SDK default credential provider chain.
|
|
// No S3 API calls are made here; switch to defaultProvider({ region }) if we add that dependency.
|
|
cached = {
|
|
client: new S3Client({ region }),
|
|
credentials: null,
|
|
expiresAt: 0,
|
|
pending: null,
|
|
};
|
|
awsCredentialProviders.set(region, cached);
|
|
}
|
|
|
|
if (cached.credentials && cached.expiresAt > now) return cached.credentials;
|
|
if (cached.pending) return cached.pending;
|
|
|
|
cached.pending = (async () => {
|
|
const credentialSource = cached.client.config.credentials;
|
|
const credentials = typeof credentialSource === "function"
|
|
? await credentialSource()
|
|
: await credentialSource;
|
|
if (!credentials?.accessKeyId || !credentials.secretAccessKey) {
|
|
throw new Error("AWS SDK default credential provider chain did not return credentials");
|
|
}
|
|
const resolved = {
|
|
accessKeyId: credentials.accessKeyId,
|
|
secretAccessKey: credentials.secretAccessKey,
|
|
sessionToken: credentials.sessionToken,
|
|
};
|
|
const expiration = (credentials as { expiration?: Date }).expiration?.getTime();
|
|
cached.credentials = resolved;
|
|
cached.expiresAt = Math.min(
|
|
now + AWS_CREDENTIAL_CACHE_TTL_MS,
|
|
expiration ? expiration - AWS_CREDENTIAL_EXPIRATION_SKEW_MS : Number.POSITIVE_INFINITY,
|
|
);
|
|
return resolved;
|
|
})().finally(() => {
|
|
if (cached) cached.pending = null;
|
|
});
|
|
|
|
return cached.pending;
|
|
}
|
|
|
|
function configuredAwsSecretsManagerDescriptor() {
|
|
return {
|
|
id: "aws_secrets_manager" as const,
|
|
label: "AWS Secrets Manager",
|
|
requiresExternalRef: false,
|
|
supportsManagedValues: true,
|
|
supportsExternalReferences: true,
|
|
supportsExternalValueWrites: true,
|
|
configured: canLoadAwsSecretsManagerConfig(),
|
|
};
|
|
}
|
|
|
|
function canLoadAwsSecretsManagerConfig() {
|
|
return getAwsConfigReadiness().missingConfig.length === 0;
|
|
}
|
|
|
|
function asOptionalNonEmptyString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function readProviderVaultConfig(input: SecretProviderVaultRuntimeConfig): AwsSecretsManagerConfig {
|
|
if (input.provider !== "aws_secrets_manager") {
|
|
throw unprocessable("AWS Secrets Manager provider received a mismatched provider vault");
|
|
}
|
|
if (input.status === "disabled") {
|
|
throw unprocessable("AWS Secrets Manager provider vault is disabled");
|
|
}
|
|
if (input.status === "coming_soon") {
|
|
throw unprocessable("AWS Secrets Manager provider vault runtime is locked while coming soon");
|
|
}
|
|
const region = asOptionalNonEmptyString(input.config.region);
|
|
if (!region) {
|
|
throw unprocessable("AWS Secrets Manager provider vault requires non-secret config: region");
|
|
}
|
|
const recoveryWindowRaw = process.env.PAPERCLIP_SECRETS_AWS_DELETE_RECOVERY_DAYS?.trim();
|
|
const recoveryWindow = recoveryWindowRaw ? Number(recoveryWindowRaw) : DEFAULT_DELETE_RECOVERY_WINDOW_DAYS;
|
|
if (!Number.isFinite(recoveryWindow) || recoveryWindow < 7 || recoveryWindow > 30) {
|
|
throw unprocessable(
|
|
"PAPERCLIP_SECRETS_AWS_DELETE_RECOVERY_DAYS must be an integer between 7 and 30",
|
|
);
|
|
}
|
|
|
|
return {
|
|
region,
|
|
endpoint:
|
|
process.env.PAPERCLIP_SECRETS_AWS_ENDPOINT?.trim() ||
|
|
`https://secretsmanager.${region}.amazonaws.com`,
|
|
deploymentId: sanitizePathSegment(
|
|
asOptionalNonEmptyString(input.config.namespace) ?? input.id,
|
|
),
|
|
prefix: sanitizePathSegment(
|
|
asOptionalNonEmptyString(input.config.secretNamePrefix) || DEFAULT_PREFIX,
|
|
),
|
|
kmsKeyId: asOptionalNonEmptyString(input.config.kmsKeyId),
|
|
environmentTag:
|
|
asOptionalNonEmptyString(input.config.environmentTag) ||
|
|
process.env.NODE_ENV?.trim() ||
|
|
"unknown",
|
|
providerOwnerTag:
|
|
asOptionalNonEmptyString(input.config.ownerTag) || DEFAULT_OWNER_TAG,
|
|
deleteRecoveryWindowDays: recoveryWindow,
|
|
};
|
|
}
|
|
|
|
function getAwsConfigReadiness() {
|
|
const region = (
|
|
process.env.PAPERCLIP_SECRETS_AWS_REGION ??
|
|
process.env.AWS_REGION ??
|
|
process.env.AWS_DEFAULT_REGION
|
|
)?.trim();
|
|
const deploymentId = process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID?.trim();
|
|
const kmsKeyId = process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID?.trim();
|
|
const missingConfig: string[] = [];
|
|
|
|
if (!region) {
|
|
missingConfig.push("PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION/AWS_DEFAULT_REGION");
|
|
}
|
|
if (!deploymentId) {
|
|
missingConfig.push("PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID");
|
|
}
|
|
if (!kmsKeyId) {
|
|
missingConfig.push("PAPERCLIP_SECRETS_AWS_KMS_KEY_ID");
|
|
}
|
|
|
|
return {
|
|
missingConfig,
|
|
region: region || null,
|
|
deploymentId: deploymentId || null,
|
|
kmsKeyConfigured: Boolean(kmsKeyId),
|
|
credentialSources: describeDetectedAwsCredentialSources(),
|
|
};
|
|
}
|
|
|
|
function describeDetectedAwsCredentialSources() {
|
|
const sources: string[] = [];
|
|
if (process.env.AWS_PROFILE?.trim()) sources.push("AWS_PROFILE/shared config");
|
|
if (process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim()) {
|
|
sources.push("temporary AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY environment credentials");
|
|
}
|
|
if (process.env.AWS_WEB_IDENTITY_TOKEN_FILE?.trim() && process.env.AWS_ROLE_ARN?.trim()) {
|
|
sources.push("AWS web identity token");
|
|
}
|
|
if (
|
|
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI?.trim() ||
|
|
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI?.trim()
|
|
) {
|
|
sources.push("AWS container credentials endpoint");
|
|
}
|
|
if (process.env.AWS_SHARED_CREDENTIALS_FILE?.trim() || process.env.AWS_CONFIG_FILE?.trim()) {
|
|
sources.push("custom AWS shared credentials/config file");
|
|
}
|
|
return sources;
|
|
}
|
|
|
|
function loadAwsSecretsManagerConfig(): AwsSecretsManagerConfig {
|
|
const readiness = getAwsConfigReadiness();
|
|
const region =
|
|
process.env.PAPERCLIP_SECRETS_AWS_REGION?.trim() ||
|
|
process.env.AWS_REGION?.trim() ||
|
|
process.env.AWS_DEFAULT_REGION?.trim();
|
|
const deploymentId = process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID?.trim();
|
|
const kmsKeyId = process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID?.trim();
|
|
|
|
if (readiness.missingConfig.length > 0) {
|
|
throw unprocessable(
|
|
`AWS Secrets Manager provider requires non-secret config: ${readiness.missingConfig.join(", ")}`,
|
|
);
|
|
}
|
|
if (!region) {
|
|
throw unprocessable(
|
|
"AWS Secrets Manager provider requires PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION",
|
|
);
|
|
}
|
|
if (!deploymentId) {
|
|
throw unprocessable(
|
|
"AWS Secrets Manager provider requires PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID",
|
|
);
|
|
}
|
|
if (!kmsKeyId) {
|
|
throw unprocessable(
|
|
"AWS Secrets Manager provider requires PAPERCLIP_SECRETS_AWS_KMS_KEY_ID",
|
|
);
|
|
}
|
|
|
|
const recoveryWindowRaw = process.env.PAPERCLIP_SECRETS_AWS_DELETE_RECOVERY_DAYS?.trim();
|
|
const recoveryWindow = recoveryWindowRaw ? Number(recoveryWindowRaw) : DEFAULT_DELETE_RECOVERY_WINDOW_DAYS;
|
|
if (!Number.isFinite(recoveryWindow) || recoveryWindow < 7 || recoveryWindow > 30) {
|
|
throw unprocessable(
|
|
"PAPERCLIP_SECRETS_AWS_DELETE_RECOVERY_DAYS must be an integer between 7 and 30",
|
|
);
|
|
}
|
|
|
|
return {
|
|
region,
|
|
endpoint:
|
|
process.env.PAPERCLIP_SECRETS_AWS_ENDPOINT?.trim() ||
|
|
`https://secretsmanager.${region}.amazonaws.com`,
|
|
deploymentId,
|
|
prefix: sanitizePathSegment(process.env.PAPERCLIP_SECRETS_AWS_PREFIX?.trim() || DEFAULT_PREFIX),
|
|
kmsKeyId,
|
|
environmentTag:
|
|
process.env.PAPERCLIP_SECRETS_AWS_ENVIRONMENT?.trim() ||
|
|
process.env.NODE_ENV?.trim() ||
|
|
"unknown",
|
|
providerOwnerTag:
|
|
process.env.PAPERCLIP_SECRETS_AWS_PROVIDER_OWNER?.trim() || DEFAULT_OWNER_TAG,
|
|
deleteRecoveryWindowDays: recoveryWindow,
|
|
};
|
|
}
|
|
|
|
function sanitizePathSegment(input: string) {
|
|
return input
|
|
.trim()
|
|
.replace(/[^A-Za-z0-9/_+=.@-]+/g, "-")
|
|
.replace(/\/+/g, "/")
|
|
.replace(/^\/+|\/+$/g, "");
|
|
}
|
|
|
|
function buildManagedSecretName(
|
|
config: AwsSecretsManagerConfig,
|
|
context: ManagedSecretNamespaceContext | undefined,
|
|
) {
|
|
if (!context) {
|
|
throw unprocessable("AWS Secrets Manager provider requires secret context for managed values");
|
|
}
|
|
return [
|
|
sanitizePathSegment(config.prefix),
|
|
sanitizePathSegment(config.deploymentId),
|
|
sanitizePathSegment(context.companyId),
|
|
sanitizePathSegment(context.secretKey),
|
|
]
|
|
.filter(Boolean)
|
|
.join("/");
|
|
}
|
|
|
|
function buildManagedSecretId(
|
|
config: AwsSecretsManagerConfig,
|
|
context: ManagedSecretNamespaceContext | undefined,
|
|
) {
|
|
return buildManagedSecretName(config, context);
|
|
}
|
|
|
|
function escapeRegExp(value: string) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function extractAwsSecretName(externalRef: string) {
|
|
const trimmed = externalRef.trim();
|
|
const arnMatch = /^arn:[^:]+:secretsmanager:[^:]*:[^:]*:secret:(.+)$/i.exec(trimmed);
|
|
return arnMatch?.[1] ?? trimmed;
|
|
}
|
|
|
|
function isManagedSecretRefForContext(
|
|
config: AwsSecretsManagerConfig,
|
|
context: ManagedSecretNamespaceContext | undefined,
|
|
externalRef: string | null | undefined,
|
|
) {
|
|
if (!externalRef?.trim()) return false;
|
|
const expectedName = buildManagedSecretName(config, context);
|
|
const actualName = extractAwsSecretName(externalRef);
|
|
return new RegExp(`^${escapeRegExp(expectedName)}(?:-[A-Za-z0-9]{6})?$`).test(actualName);
|
|
}
|
|
|
|
function isManagedSecretNamespaceRef(
|
|
config: AwsSecretsManagerConfig,
|
|
externalRef: string | null | undefined,
|
|
) {
|
|
if (!externalRef?.trim()) return false;
|
|
const namespacePrefix = [
|
|
sanitizePathSegment(config.prefix),
|
|
sanitizePathSegment(config.deploymentId),
|
|
]
|
|
.filter(Boolean)
|
|
.join("/");
|
|
if (!namespacePrefix) return false;
|
|
const actualName = extractAwsSecretName(externalRef);
|
|
return actualName === namespacePrefix || actualName.startsWith(`${namespacePrefix}/`);
|
|
}
|
|
|
|
function assertNotManagedNamespaceExternalRef(
|
|
config: AwsSecretsManagerConfig,
|
|
externalRef: string,
|
|
) {
|
|
if (!isManagedSecretNamespaceRef(config, externalRef)) return;
|
|
throw unprocessable(
|
|
"AWS Paperclip-managed namespace secrets cannot be imported as external references",
|
|
);
|
|
}
|
|
|
|
function resolveManagedSecretRef(input: {
|
|
config: AwsSecretsManagerConfig;
|
|
context: ManagedSecretNamespaceContext | undefined;
|
|
externalRefs: Array<string | null | undefined>;
|
|
}) {
|
|
let sawNonEmptyExternalRef = false;
|
|
for (const externalRef of input.externalRefs) {
|
|
if (externalRef?.trim()) {
|
|
sawNonEmptyExternalRef = true;
|
|
}
|
|
if (externalRef?.trim() && isManagedSecretRefForContext(input.config, input.context, externalRef)) {
|
|
return externalRef.trim();
|
|
}
|
|
}
|
|
if (sawNonEmptyExternalRef) {
|
|
throw unprocessable(
|
|
"AWS Secrets Manager managed secret ref drifted outside the derived deployment/company scope",
|
|
);
|
|
}
|
|
return buildManagedSecretId(input.config, input.context);
|
|
}
|
|
|
|
function buildManagedSecretTags(
|
|
config: AwsSecretsManagerConfig,
|
|
context: SecretProviderWriteContext | undefined,
|
|
): AwsSecretsManagerTag[] {
|
|
if (!context) return [];
|
|
return [
|
|
{ Key: "paperclip:managed-by", Value: "paperclip" },
|
|
{ Key: "paperclip:provider-owner", Value: config.providerOwnerTag },
|
|
{ Key: "paperclip:deployment-id", Value: config.deploymentId },
|
|
{ Key: "paperclip:company-id", Value: context.companyId },
|
|
{ Key: "paperclip:secret-key", Value: context.secretKey },
|
|
{ Key: "paperclip:environment", Value: config.environmentTag },
|
|
];
|
|
}
|
|
|
|
function createExternalReferenceMaterial(
|
|
externalRef: string,
|
|
providerVersionRef: string | null,
|
|
): PreparedSecretVersion {
|
|
const normalizedExternalRef = externalRef.trim();
|
|
const normalizedProviderVersionRef = providerVersionRef?.trim() || null;
|
|
const fingerprint = sha256Hex(
|
|
`${AWS_SECRETS_MANAGER_SCHEME}:${normalizedExternalRef}:${normalizedProviderVersionRef ?? ""}`,
|
|
);
|
|
return {
|
|
material: {
|
|
scheme: AWS_SECRETS_MANAGER_SCHEME,
|
|
secretId: normalizedExternalRef,
|
|
versionId: normalizedProviderVersionRef,
|
|
source: "external_reference",
|
|
},
|
|
valueSha256: fingerprint,
|
|
fingerprintSha256: fingerprint,
|
|
externalRef: normalizedExternalRef,
|
|
providerVersionRef: normalizedProviderVersionRef,
|
|
};
|
|
}
|
|
|
|
function createManagedMaterial(secretId: string, versionId: string | null): AwsSecretsManagerMaterial {
|
|
return {
|
|
scheme: AWS_SECRETS_MANAGER_SCHEME,
|
|
secretId,
|
|
versionId,
|
|
source: "managed",
|
|
};
|
|
}
|
|
|
|
function serializeAwsDate(value: string | number | Date | undefined): string | null {
|
|
if (value === undefined) return null;
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
}
|
|
|
|
function createRemoteSecretMetadata(entry: AwsSecretsManagerListSecretEntry): Record<string, unknown> {
|
|
return {
|
|
createdDate: serializeAwsDate(entry.CreatedDate),
|
|
lastAccessedDate: serializeAwsDate(entry.LastAccessedDate),
|
|
lastChangedDate: serializeAwsDate(entry.LastChangedDate),
|
|
deletedDate: serializeAwsDate(entry.DeletedDate),
|
|
hasDescription: Boolean(entry.Description),
|
|
hasKmsKey: Boolean(entry.KmsKeyId),
|
|
tagCount: Array.isArray(entry.Tags) ? entry.Tags.length : 0,
|
|
};
|
|
}
|
|
|
|
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 &&
|
|
typeof value === "object" &&
|
|
value.scheme === AWS_SECRETS_MANAGER_SCHEME &&
|
|
typeof value.secretId === "string" &&
|
|
(typeof value.versionId === "string" || value.versionId === null) &&
|
|
(value.source === "managed" || value.source === "external_reference")
|
|
) {
|
|
return value as AwsSecretsManagerMaterial;
|
|
}
|
|
throw unprocessable("Invalid AWS Secrets Manager material");
|
|
}
|
|
|
|
function classifyAwsProviderError(message: string): SecretProviderClientErrorCode {
|
|
if (/ResourceExistsException|AlreadyExists/i.test(message)) return "conflict";
|
|
if (/ResourceNotFoundException|NotFound/i.test(message)) return "not_found";
|
|
if (/AccessDeniedException|AccessDenied|UnrecognizedClientException|InvalidClientTokenId|not authorized/i.test(message)) {
|
|
return "access_denied";
|
|
}
|
|
if (/Throttl|TooManyRequests|RequestLimitExceeded|Rate exceeded/i.test(message)) return "throttled";
|
|
if (/ValidationException|InvalidParameter|InvalidRequest/i.test(message)) return "invalid_request";
|
|
if (/fetch failed|ECONN|ENOTFOUND|ETIMEDOUT|network|timeout/i.test(message)) return "provider_unavailable";
|
|
return "provider_error";
|
|
}
|
|
|
|
function awsProviderSafeMessage(code: SecretProviderClientErrorCode): string {
|
|
switch (code) {
|
|
case "access_denied":
|
|
return "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.";
|
|
case "throttled":
|
|
return "AWS Secrets Manager throttled the request. Wait and try again.";
|
|
case "not_found":
|
|
return "AWS Secrets Manager could not find the requested secret.";
|
|
case "conflict":
|
|
return "AWS Secrets Manager reported that the requested secret already exists.";
|
|
case "invalid_request":
|
|
return "AWS Secrets Manager rejected the request.";
|
|
case "provider_unavailable":
|
|
return "AWS Secrets Manager is unavailable right now.";
|
|
case "provider_error":
|
|
default:
|
|
return "AWS Secrets Manager request failed.";
|
|
}
|
|
}
|
|
|
|
function normalizeAwsError(operation: string, error: unknown): never {
|
|
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
const code = classifyAwsProviderError(rawMessage);
|
|
throw new SecretProviderClientError({
|
|
code,
|
|
provider: "aws_secrets_manager",
|
|
operation,
|
|
message: awsProviderSafeMessage(code),
|
|
rawMessage,
|
|
cause: error,
|
|
});
|
|
}
|
|
|
|
class AwsSecretsManagerJsonGateway implements AwsSecretsManagerGateway {
|
|
private readonly endpoint: URL;
|
|
|
|
constructor(private readonly config: AwsSecretsManagerConfig) {
|
|
this.endpoint = new URL(config.endpoint);
|
|
}
|
|
|
|
createSecret(input: {
|
|
Name: string;
|
|
SecretString: string;
|
|
KmsKeyId?: string;
|
|
Description?: string;
|
|
Tags: AwsSecretsManagerTag[];
|
|
}) {
|
|
return this.call<{
|
|
ARN?: string;
|
|
Name?: string;
|
|
VersionId?: string;
|
|
}>("CreateSecret", input);
|
|
}
|
|
|
|
putSecretValue(input: {
|
|
SecretId: string;
|
|
SecretString: string;
|
|
VersionStages?: string[];
|
|
}) {
|
|
return this.call<{
|
|
ARN?: string;
|
|
Name?: string;
|
|
VersionId?: string;
|
|
}>("PutSecretValue", input);
|
|
}
|
|
|
|
getSecretValue(input: {
|
|
SecretId: string;
|
|
VersionId?: string;
|
|
VersionStage?: string;
|
|
}) {
|
|
return this.call<{
|
|
SecretString?: string;
|
|
ARN?: string;
|
|
Name?: string;
|
|
VersionId?: string;
|
|
}>("GetSecretValue", input);
|
|
}
|
|
|
|
deleteSecret(input: {
|
|
SecretId: string;
|
|
RecoveryWindowInDays: number;
|
|
}) {
|
|
return this.call("DeleteSecret", input);
|
|
}
|
|
|
|
updateSecretVersionStage(input: {
|
|
SecretId: string;
|
|
VersionStage: string;
|
|
RemoveFromVersionId?: string;
|
|
MoveToVersionId?: string;
|
|
}) {
|
|
return this.call("UpdateSecretVersionStage", input);
|
|
}
|
|
|
|
listSecrets(input: {
|
|
MaxResults?: number;
|
|
NextToken?: string;
|
|
Filters?: Array<{
|
|
Key: "all" | "name" | "description" | "tag-key" | "tag-value" | "primary-region" | "owning-service";
|
|
Values: string[];
|
|
}>;
|
|
IncludePlannedDeletion?: boolean;
|
|
}) {
|
|
return this.call<{
|
|
SecretList?: AwsSecretsManagerListSecretEntry[];
|
|
NextToken?: string;
|
|
}>("ListSecrets", input);
|
|
}
|
|
|
|
private async call<T>(operation: string, payload: Record<string, unknown>): Promise<T> {
|
|
const body = JSON.stringify(payload);
|
|
const credentials = await loadAwsCredentials(this.config.region);
|
|
const headers = signAwsSecretsManagerRequest({
|
|
endpoint: this.endpoint,
|
|
region: this.config.region,
|
|
operation,
|
|
body,
|
|
credentials,
|
|
});
|
|
const response = await fetch(this.endpoint, {
|
|
method: "POST",
|
|
headers,
|
|
body,
|
|
signal: AbortSignal.timeout(AWS_SECRETS_MANAGER_REQUEST_TIMEOUT_MS),
|
|
});
|
|
const text = await response.text();
|
|
const parsed = text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
|
|
|
if (!response.ok) {
|
|
const code = String(parsed.__type ?? parsed.code ?? parsed.Code ?? response.statusText ?? "UnknownError");
|
|
const message = String(parsed.message ?? parsed.Message ?? code);
|
|
const rawMessage = `${code}: ${message}`;
|
|
const clientCode = classifyAwsProviderError(rawMessage);
|
|
throw new SecretProviderClientError({
|
|
code: clientCode,
|
|
provider: "aws_secrets_manager",
|
|
operation,
|
|
message: awsProviderSafeMessage(clientCode),
|
|
rawMessage,
|
|
});
|
|
}
|
|
|
|
return parsed as T;
|
|
}
|
|
}
|
|
|
|
export function createAwsSecretsManagerProvider(
|
|
options?: {
|
|
config?: AwsSecretsManagerConfig;
|
|
gateway?: AwsSecretsManagerGateway;
|
|
},
|
|
): SecretProviderModule {
|
|
function resolveConfig(providerConfig?: SecretProviderVaultRuntimeConfig | null) {
|
|
if (providerConfig) return readProviderVaultConfig(providerConfig);
|
|
return options?.config ?? loadAwsSecretsManagerConfig();
|
|
}
|
|
|
|
function resolveGateway(config: AwsSecretsManagerConfig) {
|
|
return options?.gateway ?? new AwsSecretsManagerJsonGateway(config);
|
|
}
|
|
|
|
async function validateConfig(
|
|
input?: {
|
|
deploymentMode?: DeploymentMode;
|
|
strictMode?: boolean;
|
|
providerConfig?: SecretProviderVaultRuntimeConfig | null;
|
|
},
|
|
): Promise<SecretProviderValidationResult> {
|
|
const warnings: string[] = [];
|
|
if (input?.deploymentMode === "authenticated" && input.strictMode !== true) {
|
|
warnings.push("Strict secret mode should be enabled for authenticated deployments");
|
|
}
|
|
const config = resolveConfig(input?.providerConfig);
|
|
if (!config.prefix) {
|
|
warnings.push("PAPERCLIP_SECRETS_AWS_PREFIX should be set to a deployment-scoped prefix");
|
|
}
|
|
return { ok: true, warnings };
|
|
}
|
|
|
|
async function healthCheck(
|
|
input?: {
|
|
deploymentMode?: DeploymentMode;
|
|
strictMode?: boolean;
|
|
providerConfig?: SecretProviderVaultRuntimeConfig | null;
|
|
},
|
|
): Promise<SecretProviderHealthCheck> {
|
|
try {
|
|
const validation = await validateConfig(input);
|
|
const config = resolveConfig(input?.providerConfig);
|
|
const readiness = getAwsConfigReadiness();
|
|
const warnings = [...validation.warnings];
|
|
if (
|
|
process.env.AWS_ACCESS_KEY_ID?.trim() &&
|
|
process.env.AWS_SECRET_ACCESS_KEY?.trim()
|
|
) {
|
|
warnings.push(
|
|
"AWS static environment credentials are visible to this process; use only short-lived shell credentials locally and prefer IAM role/workload identity for hosted deployments.",
|
|
);
|
|
}
|
|
return {
|
|
provider: "aws_secrets_manager",
|
|
status: warnings.length > 0 ? "warn" : "ok",
|
|
message:
|
|
"AWS Secrets Manager provider config is present; AWS credentials are resolved by the server runtime through the AWS SDK default credential provider chain.",
|
|
warnings,
|
|
details: {
|
|
region: config.region,
|
|
prefix: config.prefix,
|
|
deploymentId: config.deploymentId,
|
|
kmsKeyConfigured: Boolean(config.kmsKeyId),
|
|
credentialSource: "AWS SDK default credential provider chain",
|
|
detectedCredentialSources: readiness.credentialSources,
|
|
},
|
|
backupGuidance: [
|
|
"Back up Paperclip metadata separately from AWS-managed secrets.",
|
|
"Restoring access requires the Paperclip database plus the same AWS secret namespace and KMS permissions.",
|
|
],
|
|
};
|
|
} catch (error) {
|
|
const readiness = getAwsConfigReadiness();
|
|
const providerConfigMissing = input?.providerConfig && !asOptionalNonEmptyString(input.providerConfig.config.region)
|
|
? ["region"]
|
|
: [];
|
|
const missingConfig = input?.providerConfig ? providerConfigMissing : readiness.missingConfig;
|
|
return {
|
|
provider: "aws_secrets_manager",
|
|
status: "warn",
|
|
message:
|
|
missingConfig.length > 0
|
|
? `AWS Secrets Manager provider is not ready: missing ${missingConfig.join(", ")}.`
|
|
: error instanceof Error
|
|
? error.message
|
|
: String(error),
|
|
warnings: [
|
|
...(missingConfig.length > 0
|
|
? [`Missing required non-secret AWS provider config: ${missingConfig.join(", ")}.`]
|
|
: []),
|
|
AWS_RUNTIME_CREDENTIAL_WARNING,
|
|
AWS_CREDENTIAL_CUSTODY_WARNING,
|
|
"Managed secret create/rotate/resolve calls will fail until AWS provider configuration is complete.",
|
|
],
|
|
details: {
|
|
missingConfig,
|
|
requiredProviderConfig: input?.providerConfig
|
|
? ["region"]
|
|
: [
|
|
"PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION/AWS_DEFAULT_REGION",
|
|
"PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID",
|
|
"PAPERCLIP_SECRETS_AWS_KMS_KEY_ID",
|
|
],
|
|
optionalProviderConfig: [
|
|
"PAPERCLIP_SECRETS_AWS_PREFIX",
|
|
"PAPERCLIP_SECRETS_AWS_ENVIRONMENT",
|
|
"PAPERCLIP_SECRETS_AWS_PROVIDER_OWNER",
|
|
"PAPERCLIP_SECRETS_AWS_ENDPOINT",
|
|
"PAPERCLIP_SECRETS_AWS_DELETE_RECOVERY_DAYS",
|
|
],
|
|
credentialSource: "AWS SDK default credential provider chain",
|
|
detectedCredentialSources: readiness.credentialSources,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: "aws_secrets_manager",
|
|
descriptor() {
|
|
return configuredAwsSecretsManagerDescriptor();
|
|
},
|
|
validateConfig,
|
|
async createSecret(input) {
|
|
const config = resolveConfig(input.providerConfig);
|
|
const gateway = resolveGateway(config);
|
|
const valueSha256 = sha256Hex(input.value);
|
|
const secretId = buildManagedSecretId(config, input.context);
|
|
|
|
try {
|
|
const createInput = {
|
|
Name: secretId,
|
|
SecretString: input.value,
|
|
...(config.kmsKeyId ? { KmsKeyId: config.kmsKeyId } : {}),
|
|
Description: input.context ? `Paperclip secret ${input.context.secretName}` : undefined,
|
|
Tags: buildManagedSecretTags(config, input.context),
|
|
};
|
|
const created = await gateway.createSecret({
|
|
...createInput,
|
|
});
|
|
const normalizedSecretId = created.ARN ?? created.Name ?? secretId;
|
|
return {
|
|
material: createManagedMaterial(normalizedSecretId, created.VersionId ?? null),
|
|
valueSha256,
|
|
fingerprintSha256: valueSha256,
|
|
externalRef: normalizedSecretId,
|
|
providerVersionRef: created.VersionId ?? null,
|
|
};
|
|
} catch (error) {
|
|
normalizeAwsError("createSecret", error);
|
|
}
|
|
},
|
|
async createVersion(input) {
|
|
const config = resolveConfig(input.providerConfig);
|
|
const gateway = resolveGateway(config);
|
|
const valueSha256 = sha256Hex(input.value);
|
|
const secretId = resolveManagedSecretRef({
|
|
config,
|
|
context: input.context,
|
|
externalRefs: [input.externalRef],
|
|
});
|
|
|
|
try {
|
|
const created = await gateway.putSecretValue({
|
|
SecretId: secretId,
|
|
SecretString: input.value,
|
|
VersionStages: [PAPERCLIP_PENDING_VERSION_STAGE],
|
|
});
|
|
const normalizedSecretId = created.ARN ?? created.Name ?? secretId;
|
|
return {
|
|
material: createManagedMaterial(normalizedSecretId, created.VersionId ?? null),
|
|
valueSha256,
|
|
fingerprintSha256: valueSha256,
|
|
externalRef: normalizedSecretId,
|
|
providerVersionRef: created.VersionId ?? null,
|
|
};
|
|
} catch (error) {
|
|
normalizeAwsError("createVersion", error);
|
|
}
|
|
},
|
|
async linkExternalSecret(input) {
|
|
const config = resolveConfig(input.providerConfig);
|
|
assertNotManagedNamespaceExternalRef(config, input.externalRef);
|
|
return createExternalReferenceMaterial(input.externalRef, input.providerVersionRef ?? null);
|
|
},
|
|
async updateExternalSecretValue(input) {
|
|
const config = resolveConfig(input.providerConfig);
|
|
assertNotManagedNamespaceExternalRef(config, input.externalRef);
|
|
const gateway = resolveGateway(config);
|
|
const secretId = input.externalRef.trim();
|
|
|
|
try {
|
|
const previous = await gateway.getSecretValue({
|
|
SecretId: secretId,
|
|
VersionStage: DEFAULT_VERSION_STAGE,
|
|
});
|
|
// No VersionStages: the new version becomes AWSCURRENT so every consumer of the
|
|
// referenced secret (not just Paperclip) picks up the new value.
|
|
const created = await gateway.putSecretValue({
|
|
SecretId: secretId,
|
|
SecretString: input.value,
|
|
});
|
|
const normalizedSecretId = created.ARN ?? created.Name ?? secretId;
|
|
// Material keeps versionId null so resolution keeps tracking AWSCURRENT and
|
|
// out-of-band rotations done directly in AWS still flow through.
|
|
const prepared = createExternalReferenceMaterial(normalizedSecretId, null);
|
|
const valueSha256 = sha256Hex(input.value);
|
|
return {
|
|
...prepared,
|
|
material: {
|
|
...prepared.material,
|
|
lastWrittenVersionId: created.VersionId ?? null,
|
|
previousCurrentVersionId: previous.VersionId ?? null,
|
|
},
|
|
valueSha256,
|
|
fingerprintSha256: valueSha256,
|
|
};
|
|
} catch (error) {
|
|
normalizeAwsError("updateExternalSecretValue", error);
|
|
}
|
|
},
|
|
async listRemoteSecrets(input): Promise<RemoteSecretListResult> {
|
|
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)
|
|
: 50;
|
|
|
|
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 {
|
|
nextToken: listed.NextToken ?? null,
|
|
secrets: (listed.SecretList ?? [])
|
|
.filter((entry) => Boolean(entry.ARN ?? entry.Name))
|
|
.map((entry) => ({
|
|
externalRef: entry.ARN ?? entry.Name ?? "",
|
|
name: entry.Name ?? entry.ARN ?? "",
|
|
providerVersionRef: null,
|
|
metadata: createRemoteSecretMetadata(entry),
|
|
})),
|
|
};
|
|
} catch (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) {
|
|
const config = resolveConfig(input.providerConfig);
|
|
const gateway = resolveGateway(config);
|
|
const material = asAwsSecretsManagerMaterial(input.material);
|
|
const secretId =
|
|
material.source === "managed"
|
|
? resolveManagedSecretRef({
|
|
config,
|
|
context: input.context,
|
|
externalRefs: [input.externalRef, material.secretId],
|
|
})
|
|
: (input.externalRef ?? material.secretId);
|
|
|
|
try {
|
|
const resolved = await gateway.getSecretValue({
|
|
SecretId: secretId,
|
|
VersionId: input.providerVersionRef ?? material.versionId ?? undefined,
|
|
VersionStage:
|
|
input.providerVersionRef || material.versionId ? undefined : DEFAULT_VERSION_STAGE,
|
|
});
|
|
if (typeof resolved.SecretString !== "string") {
|
|
throw new Error("SecretString was empty");
|
|
}
|
|
return resolved.SecretString;
|
|
} catch (error) {
|
|
normalizeAwsError("resolveVersion", error);
|
|
}
|
|
},
|
|
async deleteOrArchive(input) {
|
|
const material =
|
|
input.material && typeof input.material === "object"
|
|
? asAwsSecretsManagerMaterial(input.material)
|
|
: null;
|
|
|
|
const config = resolveConfig(input.providerConfig);
|
|
const gateway = resolveGateway(config);
|
|
|
|
if (material?.source === "external_reference") {
|
|
if (
|
|
input.mode === "archive" &&
|
|
material.lastWrittenVersionId &&
|
|
material.previousCurrentVersionId &&
|
|
gateway.updateSecretVersionStage
|
|
) {
|
|
try {
|
|
await gateway.updateSecretVersionStage({
|
|
SecretId: material.secretId,
|
|
VersionStage: DEFAULT_VERSION_STAGE,
|
|
MoveToVersionId: material.previousCurrentVersionId,
|
|
RemoveFromVersionId: material.lastWrittenVersionId,
|
|
});
|
|
} catch (error) {
|
|
normalizeAwsError("updateSecretVersionStage", error);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (material?.source !== "managed") return;
|
|
|
|
const secretId = resolveManagedSecretRef({
|
|
config,
|
|
context: input.context,
|
|
externalRefs: [input.externalRef, material.secretId],
|
|
});
|
|
|
|
try {
|
|
if (input.mode === "archive") {
|
|
if (material.versionId && gateway.updateSecretVersionStage) {
|
|
await gateway.updateSecretVersionStage({
|
|
SecretId: secretId,
|
|
VersionStage: PAPERCLIP_PENDING_VERSION_STAGE,
|
|
RemoveFromVersionId: material.versionId,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
await gateway.deleteSecret({
|
|
SecretId: secretId,
|
|
RecoveryWindowInDays: config.deleteRecoveryWindowDays,
|
|
});
|
|
} catch (error) {
|
|
normalizeAwsError(input.mode === "archive" ? "updateSecretVersionStage" : "deleteSecret", error);
|
|
}
|
|
},
|
|
healthCheck,
|
|
};
|
|
}
|
|
|
|
export const awsSecretsManagerProvider = createAwsSecretsManagerProvider();
|