427 lines
16 KiB
TypeScript
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>;
|