Files
PaperClipAI/server/src/secrets/aws-secrets-manager-provider.ts
T
585d37d4f2 [codex] Preserve AWS secret provider error classification (#10946)
## Thinking Path

> - Paperclip is the open source control plane operators use to manage
AI-agent companies.
> - Operators can store managed secret values in AWS Secrets Manager
provider vaults.
> - The AWS JSON gateway already classifies safe provider failures such
as access denial, expired credentials, throttling, and invalid requests.
> - The outer provider wrapper was re-normalizing those typed failures,
which collapsed an AWS access denial into a generic provider error
before the service could produce actionable guidance.
> - Missing runtime credentials also need to be recognized as an access
problem so operators receive the correct recovery path.
> - This pull request preserves typed provider errors, expands
credential-related classification, and clarifies the safe credential/IAM
remediation text.
> - The benefit is that failed AWS secret creation tells operators
whether to restore runtime credentials or grant the required capability
without exposing AWS account or principal details.

## Linked Issues or Issue Description

No public issue was found for this exact remaining server-side
classification gap.

Bug report:
- What happened: an AWS Secrets Manager access-denied response could be
classified correctly by the JSON gateway and then collapsed to
`provider_error` by the outer provider wrapper; missing runtime
credentials were also reported generically.
- Expected behavior: safe typed AWS failures should retain their
classification, and credential resolution failures should surface as
access-denied guidance.
- Steps to reproduce: configure an AWS provider vault with invalid or
missing runtime credentials, or without `secretsmanager:CreateSecret`,
then create a Paperclip-managed secret.
- Paperclip version/commit: current `master` before this PR.
- Deployment mode: Paperclip server with an AWS Secrets Manager provider
vault.

Related prior work:
- Refs #9161
- Refs #9645

## What Changed

- Preserve `SecretProviderClientError` instances returned by the AWS
JSON gateway instead of wrapping them as generic provider failures.
- Classify missing, expired, invalid, or incorrectly signed AWS runtime
credentials as access-denied failures.
- Clarify sanitized remediation guidance to cover both credential
resolution and `secretsmanager:CreateSecret` authorization.
- Add regression coverage for gateway access-denied responses, missing
runtime credentials, and the service-level actionable message.

## Verification

- `pnpm exec vitest run
server/src/__tests__/aws-secrets-manager-provider.test.ts
server/src/__tests__/secrets-service.test.ts` — changed provider tests
passed; the service file was skipped in the first no-DB focused run.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `pnpm -r typecheck` — passed after a frozen-lockfile dependency
refresh.
- `pnpm build` — passed.
- `pnpm test:run` — 314 files / 3,443 tests passed; one unrelated
existing test failed in `plugin-orchestration-apis.test.ts` because it
observed `heartbeat.scheduling_suppressed` instead of `issue_commented`.
The same failure reproduced in isolation and none of this PR's four
changed files participate in that test.
- `git diff --check public-gh/master...HEAD` — passed.

## Risks

Low risk. The change affects only error classification and sanitized
failure guidance for AWS Secrets Manager operations. Successful secret
writes, schemas, migrations, API shapes, and stored secret material are
unchanged. The regression tests assert safe classifications while
keeping raw AWS account and principal details out of service responses.

> 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 family (exact runtime build and context-window size
were not exposed), with reasoning, repository editing, shell execution,
Git, GitHub, and test execution enabled.

## 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 (e.g. `docs/...`, `fix/...`)
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 (not
applicable: no documented contract changed)
- [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: Paperclip <noreply@paperclip.ing>
Co-authored-by: commitperclip[bot] <284812584+commitperclip[bot]@users.noreply.github.com>
2026-10-26 20:54:18 -07:00

1381 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|CredentialsProviderError|Could not load credentials|credential provider chain|UnrecognizedClientException|InvalidClientTokenId|ExpiredTokenException|InvalidSignatureException|SignatureDoesNotMatch|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 {
if (error instanceof SecretProviderClientError) {
throw error;
}
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();