paperclip/packages/shared/src/validators/secret.ts

427 lines
16 KiB
TypeScript

import { z } from "zod";
import {
SECRET_BINDING_TARGET_TYPES,
SECRET_MANAGED_MODES,
SECRET_PROJECTION_CLASSES,
SECRET_PROVIDER_CONFIG_STATUSES,
SECRET_PROVIDERS,
SECRET_STATUSES,
} from "../constants.js";
const secretKeySchema = z.string().trim().min(1).max(120).regex(/^[a-zA-Z0-9_.-]+$/);
const secretVersionSelectorSchema = z.union([z.literal("latest"), z.number().int().positive()]);
const creatableSecretStatusSchema = z.enum(["active", "disabled", "archived"]);
export const envBindingPlainSchema = z.object({
type: z.literal("plain"),
value: z.string(),
});
export const envBindingSecretRefSchema = z.object({
type: z.literal("secret_ref"),
secretId: z.string().guid(),
version: secretVersionSelectorSchema.optional(),
projectionClass: z.enum(SECRET_PROJECTION_CLASSES).optional(),
projectionAllowlistKey: z.string().trim().min(1).max(160).optional().nullable(),
});
export const envBindingUserSecretRefSchema = z.object({
type: z.literal("user_secret_ref"),
key: secretKeySchema,
version: secretVersionSelectorSchema.optional(),
required: z.boolean().optional().default(true),
allowMissingOverride: z.boolean().optional().default(false),
});
// Backward-compatible union that accepts legacy inline values.
export const envBindingSchema = z.union([
z.string(),
envBindingPlainSchema,
envBindingSecretRefSchema,
envBindingUserSecretRefSchema,
]);
export const envConfigSchema = z.record(z.string(), envBindingSchema);
export const createSecretSchema = z.object({
name: z.string().min(1),
key: secretKeySchema.optional(),
provider: z.enum(SECRET_PROVIDERS).optional(),
providerConfigId: z.string().guid().optional().nullable(),
managedMode: z.enum(SECRET_MANAGED_MODES).optional(),
value: z.string().min(1).optional().nullable(),
description: z.string().optional().nullable(),
externalRef: z.string().optional().nullable(),
providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(),
providerVersionRef: z.string().optional().nullable(),
}).superRefine((value, ctx) => {
if ((value.managedMode ?? "paperclip_managed") === "external_reference") {
if (!value.externalRef?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["externalRef"],
message: "External reference secrets require externalRef",
});
}
return;
}
if (value.externalRef?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["externalRef"],
message: "Managed secrets cannot set externalRef",
});
}
if (!value.value?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["value"],
message: "Managed secrets require value",
});
}
});
export type CreateSecret = z.infer<typeof createSecretSchema>;
function requireSecretRotationInput(
value: {
value?: string | null;
externalRef?: string | null;
providerVersionRef?: string | null;
providerConfigId?: string | null;
},
ctx: z.RefinementCtx,
) {
if (
!value.value?.trim() &&
!value.externalRef?.trim() &&
value.providerVersionRef == null &&
value.providerConfigId == null
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["value"],
message: "Secret rotation requires value, externalRef, providerVersionRef, or providerConfigId",
});
}
}
export const rotateSecretSchema = z.object({
value: z.string().min(1).optional().nullable(),
externalRef: z.string().optional().nullable(),
providerVersionRef: z.string().optional().nullable(),
providerConfigId: z.string().guid().optional().nullable(),
}).superRefine(requireSecretRotationInput);
export type RotateSecret = z.infer<typeof rotateSecretSchema>;
export const updateSecretSchema = z.object({
name: z.string().min(1).optional(),
key: secretKeySchema.optional(),
status: z.enum(SECRET_STATUSES).optional(),
providerConfigId: z.string().guid().optional().nullable(),
description: z.string().optional().nullable(),
externalRef: z.string().optional().nullable(),
providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(),
});
export type UpdateSecret = z.infer<typeof updateSecretSchema>;
export const secretBindingTargetSchema = z.object({
targetType: z.enum(SECRET_BINDING_TARGET_TYPES),
targetId: z.string().min(1),
configPath: z.string().min(1),
});
export const createSecretBindingSchema = secretBindingTargetSchema.extend({
secretId: z.string().guid(),
versionSelector: secretVersionSelectorSchema.default("latest"),
required: z.boolean().default(true),
label: z.string().optional().nullable(),
projectionClass: z.enum(SECRET_PROJECTION_CLASSES).optional(),
projectionAllowlistKey: z.string().trim().min(1).max(160).optional().nullable(),
});
export type CreateSecretBinding = z.infer<typeof createSecretBindingSchema>;
export const createUserSecretDefinitionSchema = z.object({
key: secretKeySchema,
name: z.string().trim().min(1).max(160),
description: z.string().trim().max(500).optional().nullable(),
status: creatableSecretStatusSchema.optional(),
provider: z.enum(SECRET_PROVIDERS).optional(),
providerConfigId: z.string().guid().optional().nullable(),
managedMode: z.enum(SECRET_MANAGED_MODES).optional(),
providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(),
usageGuidance: z.string().trim().max(1000).optional().nullable(),
});
export type CreateUserSecretDefinition = z.infer<typeof createUserSecretDefinitionSchema>;
export const updateUserSecretDefinitionSchema = z.object({
name: z.string().trim().min(1).max(160).optional(),
description: z.string().trim().max(500).optional().nullable(),
status: z.enum(SECRET_STATUSES).optional(),
providerConfigId: z.string().guid().optional().nullable(),
providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(),
usageGuidance: z.string().trim().max(1000).optional().nullable(),
});
export type UpdateUserSecretDefinition = z.infer<typeof updateUserSecretDefinitionSchema>;
export const createUserSecretValueSchema = z.object({
definitionKey: secretKeySchema.optional(),
definitionId: z.string().guid().optional(),
value: z.string().min(1).optional().nullable(),
externalRef: z.string().optional().nullable(),
providerVersionRef: z.string().optional().nullable(),
providerConfigId: z.string().guid().optional().nullable(),
}).superRefine((value, ctx) => {
if (!value.definitionKey && !value.definitionId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["definitionId"],
message: "User secret value requires definitionId or definitionKey",
});
}
if (!value.value?.trim() && !value.externalRef?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["value"],
message: "User secret value requires value or externalRef",
});
}
});
export type CreateUserSecretValue = z.infer<typeof createUserSecretValueSchema>;
export const updateUserSecretValueSchema = z.object({
status: z.enum(SECRET_STATUSES).optional(),
value: z.string().min(1).optional().nullable(),
externalRef: z.string().min(1).optional().nullable(),
providerVersionRef: z.string().min(1).optional().nullable(),
providerConfigId: z.string().guid().optional().nullable(),
});
export type UpdateUserSecretValue = z.infer<typeof updateUserSecretValueSchema>;
export const rotateUserSecretValueSchema = z.object({
value: z.string().min(1).optional().nullable(),
externalRef: z.string().min(1).optional().nullable(),
providerVersionRef: z.string().min(1).optional().nullable(),
providerConfigId: z.string().guid().optional().nullable(),
}).superRefine(requireSecretRotationInput);
export type RotateUserSecretValue = z.infer<typeof rotateUserSecretValueSchema>;
export const createUserSecretDeclarationSchema = secretBindingTargetSchema.extend({
definitionKey: secretKeySchema,
envKey: z.string().trim().min(1),
versionSelector: secretVersionSelectorSchema.default("latest"),
required: z.boolean().default(true),
allowMissingOverride: z.boolean().default(false),
label: z.string().optional().nullable(),
});
export type CreateUserSecretDeclaration = z.infer<typeof createUserSecretDeclarationSchema>;
const safeShortText = z.string().trim().min(1).max(160);
const optionalSafeShortText = safeShortText.optional().nullable();
const deniedProviderConfigKeyPattern =
/^(access[-_]?key([-_]?id)?|secret[-_]?access[-_]?key|secret[-_]?key|token|password|passwd|credential|credentials|private[-_]?key|pem|jwt|session[-_]?token|service[-_]?account([-_]?json)?|client[-_]?secret|secret[-_]?id|unseal[-_]?key|recovery[-_]?key|key[-_]?file([-_]?path)?|token[-_]?file([-_]?path)?)$/i;
function rejectSensitiveProviderConfigKeys(value: unknown, ctx: z.RefinementCtx) {
if (!value || typeof value !== "object" || Array.isArray(value)) return;
for (const key of Object.keys(value)) {
if (!deniedProviderConfigKeyPattern.test(key)) continue;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["config", key],
message: `Provider vault config cannot persist sensitive field: ${key}`,
});
}
}
export const localEncryptedProviderConfigSchema = z.object({
backupReminderAcknowledged: z.boolean().optional(),
}).strict();
export const awsSecretsManagerProviderConfigSchema = z.object({
region: z.string().trim().regex(/^[a-z]{2}(?:-gov)?-[a-z]+-\d+$/, "Invalid AWS region"),
namespace: optionalSafeShortText,
secretNamePrefix: optionalSafeShortText,
kmsKeyId: z.string().trim().min(1).max(512).optional().nullable(),
ownerTag: optionalSafeShortText,
environmentTag: optionalSafeShortText,
}).strict();
export const gcpSecretManagerProviderConfigSchema = z.object({
projectId: z.string().trim().min(1).max(128).regex(/^[a-z][a-z0-9-]{4,127}$/).optional().nullable(),
location: optionalSafeShortText,
namespace: optionalSafeShortText,
secretNamePrefix: optionalSafeShortText,
}).strict();
const vaultAddressSchema = z.preprocess(
(value) => typeof value === "string" ? value.trim() : value,
z.string().url().superRefine((value, ctx) => {
let url: URL;
try {
url = new URL(value);
} catch {
return;
}
const hasPath = url.pathname !== "" && url.pathname !== "/";
if (
(url.protocol !== "http:" && url.protocol !== "https:") ||
url.username ||
url.password ||
url.search ||
url.hash ||
hasPath
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Vault address must be an origin-only HTTP(S) URL without credentials, path, query, or fragment",
});
}
}).transform((value) => new URL(value).origin),
);
function rejectUnsafeVaultAddress(value: unknown, ctx: z.RefinementCtx) {
if (value === undefined || value === null) return;
const parsed = vaultAddressSchema.safeParse(value);
if (parsed.success) return;
for (const issue of parsed.error.issues) {
ctx.addIssue({
...issue,
path: ["config", "address", ...issue.path],
});
}
}
export const vaultProviderConfigSchema = z.object({
address: vaultAddressSchema.optional().nullable(),
namespace: optionalSafeShortText,
mountPath: optionalSafeShortText,
secretPathPrefix: optionalSafeShortText,
}).strict();
export const secretProviderConfigPayloadSchema = z.discriminatedUnion("provider", [
z.object({ provider: z.literal("local_encrypted"), config: localEncryptedProviderConfigSchema }),
z.object({ provider: z.literal("aws_secrets_manager"), config: awsSecretsManagerProviderConfigSchema }),
z.object({ provider: z.literal("gcp_secret_manager"), config: gcpSecretManagerProviderConfigSchema }),
z.object({ provider: z.literal("vault"), config: vaultProviderConfigSchema }),
]);
export const createSecretProviderConfigSchema = z.object({
provider: z.enum(SECRET_PROVIDERS),
displayName: z.string().trim().min(1).max(120),
status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(),
isDefault: z.boolean().optional(),
config: z.record(z.string(), z.unknown()).default({}),
}).superRefine((value, ctx) => {
rejectSensitiveProviderConfigKeys(value.config, ctx);
const parsed = secretProviderConfigPayloadSchema.safeParse({
provider: value.provider,
config: value.config,
});
if (!parsed.success) {
for (const issue of parsed.error.issues) {
ctx.addIssue({
...issue,
path: issue.path[0] === "config" ? issue.path : ["config", ...issue.path],
});
}
}
const status = value.status ?? (["gcp_secret_manager", "vault"].includes(value.provider) ? "coming_soon" : "ready");
if ((value.provider === "gcp_secret_manager" || value.provider === "vault") && status !== "coming_soon" && status !== "disabled") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["status"],
message: `${value.provider} provider vaults are locked while coming soon`,
});
}
if ((status === "coming_soon" || status === "disabled") && value.isDefault) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["isDefault"],
message: "Only ready or warning provider vaults can be default",
});
}
});
export type CreateSecretProviderConfig = z.infer<typeof createSecretProviderConfigSchema>;
export const updateSecretProviderConfigSchema = z.object({
displayName: z.string().trim().min(1).max(120).optional(),
status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(),
isDefault: z.boolean().optional(),
config: z.record(z.string(), z.unknown()).optional(),
}).superRefine((value, ctx) => {
if (value.config !== undefined) {
rejectSensitiveProviderConfigKeys(value.config, ctx);
rejectUnsafeVaultAddress(value.config.address, ctx);
}
if ((value.status === "coming_soon" || value.status === "disabled") && value.isDefault) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["isDefault"],
message: "Only ready or warning provider vaults can be default",
});
}
});
export type UpdateSecretProviderConfig = z.infer<typeof updateSecretProviderConfigSchema>;
export const remoteSecretImportPreviewSchema = z.object({
providerConfigId: z.string().guid(),
query: z.string().trim().max(200).optional().nullable(),
nextToken: z.string().trim().min(1).max(4096).optional().nullable(),
pageSize: z.number().int().min(1).max(100).optional(),
});
export type RemoteSecretImportPreview = z.infer<typeof remoteSecretImportPreviewSchema>;
export const secretProviderConfigDiscoveryPreviewSchema = z.object({
provider: z.enum(SECRET_PROVIDERS),
config: z.record(z.string(), z.unknown()).default({}),
query: z.string().trim().max(200).optional().nullable(),
nextToken: z.string().trim().min(1).max(4096).optional().nullable(),
pageSize: z.number().int().min(1).max(100).optional(),
}).superRefine((value, ctx) => {
rejectSensitiveProviderConfigKeys(value.config, ctx);
const parsed = secretProviderConfigPayloadSchema.safeParse({
provider: value.provider,
config: value.config,
});
if (!parsed.success) {
for (const issue of parsed.error.issues) {
ctx.addIssue({
...issue,
path: issue.path[0] === "config" ? issue.path : ["config", ...issue.path],
});
}
}
});
export type SecretProviderConfigDiscoveryPreview = z.infer<typeof secretProviderConfigDiscoveryPreviewSchema>;
export const remoteSecretImportSelectionSchema = z.object({
externalRef: z.string().trim().min(1).max(2048),
name: z.string().trim().min(1).max(160).optional().nullable(),
key: z.string().trim().min(1).max(120).regex(/^[a-zA-Z0-9_.-]+$/).optional().nullable(),
description: z.string().trim().max(500).optional().nullable(),
providerVersionRef: z.string().trim().min(1).max(512).optional().nullable(),
providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(),
});
export const remoteSecretImportSchema = z.object({
providerConfigId: z.string().guid(),
secrets: z.array(remoteSecretImportSelectionSchema).min(1).max(100),
});
export type RemoteSecretImportSelection = z.infer<typeof remoteSecretImportSelectionSchema>;
export type RemoteSecretImport = z.infer<typeof remoteSecretImportSchema>;