mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Fix dynamic secret gating and encrypted payloads
Enforce the dynamic secrets experimental gate on create, dry-run, binding persistence, and runtime resolution. Store dynamic command config and binding argv as encrypted local secret material with redacted metadata summaries. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
6 files changed
+384
-33
No files matched your search
@@ -0,0 +1,21 @@
|
||||
ALTER TABLE "company_secret_bindings"
|
||||
ADD COLUMN IF NOT EXISTS "static_argv_material" jsonb;
|
||||
|
||||
-- The previous experimental implementation stored host command paths and
|
||||
-- operator-fixed argv in plaintext JSONB. Existing payloads cannot be safely
|
||||
-- re-encrypted in SQL because the local encryption key is intentionally outside
|
||||
-- the database, so remove plaintext-at-rest values. New writes store encrypted
|
||||
-- material and leave only redacted summaries in these metadata columns.
|
||||
UPDATE "company_secrets"
|
||||
SET "dynamic_command" = jsonb_build_object(
|
||||
'provider', COALESCE("dynamic_command"->>'provider', 'host-command'),
|
||||
'command', '***REDACTED***',
|
||||
'ttlSeconds', COALESCE(NULLIF("dynamic_command"->>'ttlSeconds', '')::int, 300)
|
||||
)
|
||||
WHERE "managed_mode" = 'dynamic_command'
|
||||
AND "dynamic_command" IS NOT NULL;
|
||||
|
||||
UPDATE "company_secret_bindings"
|
||||
SET "static_argv" = '[]'::jsonb
|
||||
WHERE jsonb_typeof("static_argv") = 'array'
|
||||
AND jsonb_array_length("static_argv") > 0;
|
||||
@@ -799,6 +799,13 @@
|
||||
"when": 1782417600000,
|
||||
"tag": "0113_dynamic_secret_command_config",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 114,
|
||||
"version": "7",
|
||||
"when": 1782417900000,
|
||||
"tag": "0114_dynamic_secret_encrypted_payloads",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export const companySecretBindings = pgTable(
|
||||
required: boolean("required").notNull().default(true),
|
||||
label: text("label"),
|
||||
staticArgv: jsonb("static_argv").$type<string[]>().notNull().default([]),
|
||||
staticArgvMaterial: jsonb("static_argv_material").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } fro
|
||||
import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
|
||||
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
|
||||
import { SecretProviderClientError } from "../secrets/types.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
@@ -48,6 +49,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await instanceSettingsService(db).updateExperimental({ enableDynamicSecrets: false });
|
||||
await db.delete(activityLog);
|
||||
await db.delete(secretAccessEvents);
|
||||
await db.delete(companySecretBindings);
|
||||
@@ -119,6 +121,10 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function enableDynamicSecrets() {
|
||||
await instanceSettingsService(db).updateExperimental({ enableDynamicSecrets: true });
|
||||
}
|
||||
|
||||
it("rejects cross-company secret references during env normalization", async () => {
|
||||
const companyA = await seedCompany("A");
|
||||
const companyB = await seedCompany("B");
|
||||
@@ -169,9 +175,28 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
).rejects.toThrow(/already exists/i);
|
||||
});
|
||||
|
||||
it("persists dynamic command secrets and operator static argv bindings without secret material", async () => {
|
||||
it("rejects dynamic command secret creation when the experimental flag is disabled", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
|
||||
await expect(
|
||||
svc.create(companyId, {
|
||||
name: `dynamic-disabled-${randomUUID()}`,
|
||||
provider: "host_command",
|
||||
managedMode: "dynamic_command",
|
||||
dynamicCommand: {
|
||||
provider: "host-command",
|
||||
command: "/usr/local/bin/mint-github-token",
|
||||
ttlSeconds: 3600,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/not enabled/i);
|
||||
});
|
||||
|
||||
it("persists dynamic command secrets and operator static argv bindings as encrypted material", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await enableDynamicSecrets();
|
||||
const svc = secretService(db);
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `dynamic-${randomUUID()}`,
|
||||
provider: "host_command",
|
||||
@@ -187,10 +212,10 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
managedMode: "dynamic_command",
|
||||
provider: "host_command",
|
||||
externalRef: null,
|
||||
latestVersion: 0,
|
||||
latestVersion: 1,
|
||||
dynamicCommand: {
|
||||
provider: "host-command",
|
||||
command: "/usr/local/bin/mint-github-token",
|
||||
command: "***REDACTED***",
|
||||
ttlSeconds: 3600,
|
||||
},
|
||||
});
|
||||
@@ -199,7 +224,8 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
.select()
|
||||
.from(companySecretVersions)
|
||||
.where(eq(companySecretVersions.secretId, secret.id));
|
||||
expect(versions).toHaveLength(0);
|
||||
expect(versions).toHaveLength(1);
|
||||
expect(JSON.stringify(versions[0]?.material)).not.toContain("mint-github-token");
|
||||
|
||||
const binding = await svc.createBinding({
|
||||
companyId,
|
||||
@@ -210,11 +236,14 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
staticArgv: ["--installation", "12345"],
|
||||
});
|
||||
|
||||
expect(binding.staticArgv).toEqual(["--installation", "12345"]);
|
||||
expect(binding.staticArgv).toEqual(["***REDACTED***", "***REDACTED***"]);
|
||||
expect(JSON.stringify(binding.staticArgvMaterial)).not.toContain("--installation");
|
||||
expect(JSON.stringify(binding.staticArgvMaterial)).not.toContain("12345");
|
||||
});
|
||||
|
||||
it("resolves dynamic command secrets with a TTL cache and metadata-only task activity", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await enableDynamicSecrets();
|
||||
const issue = await seedIssue(companyId);
|
||||
const countFile = path.join(secretsTmpDir, `count-${randomUUID()}.txt`);
|
||||
const scriptPath = writeGeneratorScript("dynamic-success", `
|
||||
@@ -279,8 +308,54 @@ console.log("minted-" + count + ":" + process.argv.slice(2).join("|"));
|
||||
expect(activityJson).not.toContain(scriptPath);
|
||||
});
|
||||
|
||||
it("fails closed at runtime when dynamic secrets are disabled after creation", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await enableDynamicSecrets();
|
||||
const countFile = path.join(secretsTmpDir, `disabled-count-${randomUUID()}.txt`);
|
||||
const scriptPath = writeGeneratorScript("dynamic-disabled-runtime", `
|
||||
const { writeFileSync } = require("node:fs");
|
||||
writeFileSync(${JSON.stringify(countFile)}, "invoked");
|
||||
console.log("should-not-run");
|
||||
`);
|
||||
const svc = secretService(db);
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `dynamic-runtime-disabled-${randomUUID()}`,
|
||||
provider: "host_command",
|
||||
managedMode: "dynamic_command",
|
||||
dynamicCommand: {
|
||||
provider: "host-command",
|
||||
command: process.execPath,
|
||||
ttlSeconds: 60,
|
||||
},
|
||||
});
|
||||
await svc.createBinding({
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
targetType: "agent",
|
||||
targetId: "agent-runtime-disabled",
|
||||
configPath: "env.RUNTIME_TOKEN",
|
||||
staticArgv: [scriptPath],
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableDynamicSecrets: false });
|
||||
|
||||
await expect(
|
||||
svc.resolveAdapterConfigForRuntime(
|
||||
companyId,
|
||||
{ env: { RUNTIME_TOKEN: { type: "secret_ref", secretId: secret.id } } },
|
||||
{
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-runtime-disabled",
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/not enabled/i);
|
||||
expect(() => readFileSync(countFile, "utf8")).toThrow();
|
||||
});
|
||||
|
||||
it("fails closed for dynamic command error, timeout, and empty output", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await enableDynamicSecrets();
|
||||
const svc = secretService(db);
|
||||
const previousTimeout = process.env.PAPERCLIP_DYNAMIC_SECRET_COMMAND_TIMEOUT_MS;
|
||||
process.env.PAPERCLIP_DYNAMIC_SECRET_COMMAND_TIMEOUT_MS = "50";
|
||||
@@ -341,7 +416,8 @@ console.log("minted-" + count + ":" + process.argv.slice(2).join("|"));
|
||||
});
|
||||
|
||||
it("dry-runs a dynamic generator without returning the value", async () => {
|
||||
await seedCompany();
|
||||
const companyId = await seedCompany();
|
||||
await enableDynamicSecrets();
|
||||
const svc = secretService(db);
|
||||
const scriptPath = writeGeneratorScript(
|
||||
"dynamic-test-ok",
|
||||
@@ -349,6 +425,7 @@ console.log("minted-" + count + ":" + process.argv.slice(2).join("|"));
|
||||
);
|
||||
|
||||
const ok = await svc.testDynamicCommand({
|
||||
companyId,
|
||||
command: process.execPath,
|
||||
staticArgv: [scriptPath, "--installation", "999"],
|
||||
});
|
||||
@@ -362,6 +439,7 @@ console.log("minted-" + count + ":" + process.argv.slice(2).join("|"));
|
||||
"process.stderr.write('boom'); process.exit(2);",
|
||||
);
|
||||
const failure = await svc.testDynamicCommand({
|
||||
companyId,
|
||||
command: process.execPath,
|
||||
staticArgv: [failing],
|
||||
});
|
||||
@@ -373,6 +451,7 @@ console.log("minted-" + count + ":" + process.argv.slice(2).join("|"));
|
||||
|
||||
it("threads operator static argv from env bindings into binding rows", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await enableDynamicSecrets();
|
||||
const svc = secretService(db);
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `dynamic-binding-${randomUUID()}`,
|
||||
@@ -403,7 +482,9 @@ console.log("minted-" + count + ":" + process.argv.slice(2).join("|"));
|
||||
.from(companySecretBindings)
|
||||
.where(eq(companySecretBindings.targetId, "agent-argv"));
|
||||
expect(bindings).toHaveLength(1);
|
||||
expect(bindings[0]?.staticArgv).toEqual(["--installation", "12345"]);
|
||||
expect(bindings[0]?.staticArgv).toEqual(["***REDACTED***", "***REDACTED***"]);
|
||||
expect(JSON.stringify(bindings[0]?.staticArgvMaterial)).not.toContain("--installation");
|
||||
expect(JSON.stringify(bindings[0]?.staticArgvMaterial)).not.toContain("12345");
|
||||
});
|
||||
|
||||
it("syncs top-level secret refs idempotently", async () => {
|
||||
|
||||
@@ -322,6 +322,7 @@ export function secretRoutes(db: Db) {
|
||||
throw forbidden("Dynamic secrets are not enabled for this instance.");
|
||||
}
|
||||
const result = await svc.testDynamicCommand({
|
||||
companyId,
|
||||
command: req.body.command,
|
||||
staticArgv: req.body.staticArgv,
|
||||
});
|
||||
|
||||
+266
-26
@@ -34,11 +34,13 @@ import type {
|
||||
import {
|
||||
createSecretProviderConfigSchema,
|
||||
deriveProjectUrlKey,
|
||||
dynamicSecretCommandSchema,
|
||||
envBindingSchema,
|
||||
isUuidLike,
|
||||
normalizeAgentUrlKey,
|
||||
secretProviderConfigPayloadSchema,
|
||||
secretProviderConfigDiscoveryPreviewSchema,
|
||||
staticArgvSchema,
|
||||
updateSecretProviderConfigSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
|
||||
@@ -53,12 +55,14 @@ import type {
|
||||
RemoteSecretListResult,
|
||||
SecretProviderHealthCheck,
|
||||
SecretProviderModule,
|
||||
SecretProviderRuntimeContext,
|
||||
SecretProviderVaultRuntimeConfig,
|
||||
SecretProviderWriteContext,
|
||||
} from "../secrets/types.js";
|
||||
import { isSecretProviderClientError } from "../secrets/types.js";
|
||||
import { authorizationService } from "./authorization.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
|
||||
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const SENSITIVE_ENV_KEY_RE =
|
||||
@@ -70,6 +74,8 @@ const COMING_SOON_SECRET_PROVIDERS: ReadonlySet<SecretProvider> = new Set([
|
||||
]);
|
||||
const EMPTY_STATIC_ARGV: string[] = [];
|
||||
const DEFAULT_DYNAMIC_SECRET_COMMAND_TIMEOUT_MS = 30_000;
|
||||
const REDACTED_DYNAMIC_SECRET_FIELD = "***REDACTED***";
|
||||
const DYNAMIC_SECRET_CONFIG_VERSION = 1;
|
||||
const dynamicSecretCache = new Map<string, { value: string; expiresAt: number }>();
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type SecretBindingDb = Pick<Db | DbTransaction, "select" | "delete" | "insert">;
|
||||
@@ -276,8 +282,21 @@ type SecretResolutionErrorCode =
|
||||
| "dynamic_secret_command_failed"
|
||||
| "dynamic_secret_command_timeout"
|
||||
| "dynamic_secret_command_empty"
|
||||
| "dynamic_secrets_disabled"
|
||||
| "provider_error";
|
||||
|
||||
type DynamicSecretConfigPayload = {
|
||||
kind: "dynamic_command_config";
|
||||
version: 1;
|
||||
dynamicCommand: DynamicSecretCommandConfig;
|
||||
};
|
||||
|
||||
type DynamicSecretStaticArgvPayload = {
|
||||
kind: "dynamic_binding_static_argv";
|
||||
version: 1;
|
||||
staticArgv: string[];
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
@@ -313,6 +332,57 @@ function normalizeStaticArgv(value: unknown): string[] {
|
||||
return value;
|
||||
}
|
||||
|
||||
function dynamicCommandSummary(config: DynamicSecretCommandConfig): DynamicSecretCommandConfig {
|
||||
return {
|
||||
provider: config.provider,
|
||||
command: REDACTED_DYNAMIC_SECRET_FIELD,
|
||||
ttlSeconds: config.ttlSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function staticArgvSummary(staticArgv: string[]) {
|
||||
return staticArgv.map(() => REDACTED_DYNAMIC_SECRET_FIELD);
|
||||
}
|
||||
|
||||
async function prepareEncryptedPayload(
|
||||
value: DynamicSecretConfigPayload | DynamicSecretStaticArgvPayload,
|
||||
context: SecretProviderWriteContext,
|
||||
) {
|
||||
return getSecretProvider("local_encrypted").createSecret({
|
||||
value: JSON.stringify(value),
|
||||
externalRef: null,
|
||||
providerConfig: null,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveEncryptedPayload<T>(
|
||||
material: Record<string, unknown> | null | undefined,
|
||||
context: SecretProviderRuntimeContext,
|
||||
parse: (value: unknown) => T,
|
||||
): Promise<T> {
|
||||
if (!material) {
|
||||
throw unprocessable("Encrypted dynamic secret material is missing", {
|
||||
code: "dynamic_secret_command_failed",
|
||||
});
|
||||
}
|
||||
const raw = await getSecretProvider("local_encrypted").resolveVersion({
|
||||
material,
|
||||
externalRef: null,
|
||||
providerConfig: null,
|
||||
context,
|
||||
});
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw unprocessable("Encrypted dynamic secret material is invalid", {
|
||||
code: "dynamic_secret_command_failed",
|
||||
});
|
||||
}
|
||||
return parse(parsed);
|
||||
}
|
||||
|
||||
function dynamicSecretHttpError(code: SecretResolutionErrorCode, message: string) {
|
||||
return new HttpError(422, message, { code });
|
||||
}
|
||||
@@ -432,6 +502,7 @@ function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode {
|
||||
case "dynamic_secret_command_failed":
|
||||
case "dynamic_secret_command_timeout":
|
||||
case "dynamic_secret_command_empty":
|
||||
case "dynamic_secrets_disabled":
|
||||
case "provider_error":
|
||||
return details.code;
|
||||
}
|
||||
@@ -466,6 +537,7 @@ function assertSelectableProviderConfig(config: {
|
||||
|
||||
export function secretService(db: Db) {
|
||||
const authorization = authorizationService(db);
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
|
||||
type NormalizeEnvOptions = {
|
||||
strictMode?: boolean;
|
||||
@@ -505,6 +577,15 @@ export function secretService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function assertDynamicSecretsEnabled() {
|
||||
const experimental = await instanceSettings.getExperimental();
|
||||
if (experimental.enableDynamicSecrets !== true) {
|
||||
throw new HttpError(403, "Dynamic secrets are not enabled for this instance.", {
|
||||
code: "dynamic_secrets_disabled",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getBinding(input: {
|
||||
companyId: string;
|
||||
secretId: string;
|
||||
@@ -561,6 +642,96 @@ export function secretService(db: Db) {
|
||||
return binding;
|
||||
}
|
||||
|
||||
async function resolveDynamicCommandConfig(secret: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
key: string;
|
||||
latestVersion: number;
|
||||
}): Promise<DynamicSecretCommandConfig> {
|
||||
const versionRow = await getSecretVersion(secret.id, secret.latestVersion);
|
||||
const payload = await resolveEncryptedPayload(
|
||||
versionRow?.material as Record<string, unknown> | undefined,
|
||||
{
|
||||
companyId: secret.companyId,
|
||||
secretId: secret.id,
|
||||
secretKey: secret.key,
|
||||
version: secret.latestVersion,
|
||||
},
|
||||
(value) => {
|
||||
const record = asRecord(value);
|
||||
if (record?.kind !== "dynamic_command_config" || record.version !== DYNAMIC_SECRET_CONFIG_VERSION) {
|
||||
throw unprocessable("Encrypted dynamic secret config is invalid", {
|
||||
code: "dynamic_secret_command_failed",
|
||||
});
|
||||
}
|
||||
const parsed = dynamicSecretCommandSchema.safeParse(record.dynamicCommand);
|
||||
if (!parsed.success) {
|
||||
throw unprocessable("Encrypted dynamic secret config is invalid", {
|
||||
code: "dynamic_secret_command_failed",
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
},
|
||||
);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function resolveBindingStaticArgv(binding: SecretBindingRow): Promise<string[]> {
|
||||
if (!binding.staticArgvMaterial) {
|
||||
return normalizeStaticArgv(binding.staticArgv);
|
||||
}
|
||||
return resolveEncryptedPayload(
|
||||
binding.staticArgvMaterial as Record<string, unknown>,
|
||||
{
|
||||
companyId: binding.companyId,
|
||||
secretId: binding.secretId,
|
||||
secretKey: "binding-static-argv",
|
||||
version: 1,
|
||||
},
|
||||
(value) => {
|
||||
const record = asRecord(value);
|
||||
if (record?.kind !== "dynamic_binding_static_argv" || record.version !== DYNAMIC_SECRET_CONFIG_VERSION) {
|
||||
throw unprocessable("Encrypted dynamic secret binding argv is invalid", {
|
||||
code: "dynamic_secret_command_failed",
|
||||
});
|
||||
}
|
||||
const parsed = staticArgvSchema.safeParse(record.staticArgv);
|
||||
if (!parsed.success) {
|
||||
throw unprocessable("Encrypted dynamic secret binding argv is invalid", {
|
||||
code: "dynamic_secret_command_failed",
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function prepareStaticArgvForBinding(input: {
|
||||
companyId: string;
|
||||
secretId: string;
|
||||
staticArgv: string[] | undefined;
|
||||
}): Promise<{ summary: string[]; material: Record<string, unknown> | null }> {
|
||||
const staticArgv = normalizeStaticArgv(input.staticArgv ?? EMPTY_STATIC_ARGV);
|
||||
if (staticArgv.length === 0) return { summary: EMPTY_STATIC_ARGV, material: null };
|
||||
const prepared = await prepareEncryptedPayload(
|
||||
{
|
||||
kind: "dynamic_binding_static_argv",
|
||||
version: DYNAMIC_SECRET_CONFIG_VERSION,
|
||||
staticArgv,
|
||||
},
|
||||
{
|
||||
companyId: input.companyId,
|
||||
secretKey: `binding-static-argv-${input.secretId}`,
|
||||
secretName: "Dynamic secret binding static argv",
|
||||
version: 1,
|
||||
},
|
||||
);
|
||||
return {
|
||||
summary: staticArgvSummary(staticArgv),
|
||||
material: prepared.material,
|
||||
};
|
||||
}
|
||||
|
||||
async function recordAccessEvent(input: {
|
||||
companyId: string;
|
||||
secretId: string;
|
||||
@@ -753,7 +924,8 @@ export function secretService(db: Db) {
|
||||
}
|
||||
const binding = await assertBindingContext(companyId, secret.id, bindingContext);
|
||||
if (secret.managedMode === "dynamic_command") {
|
||||
if (providerId !== "host_command" || !secret.dynamicCommand) {
|
||||
await assertDynamicSecretsEnabled();
|
||||
if (providerId !== "host_command") {
|
||||
throw unprocessable("Dynamic secret is missing generator command config", {
|
||||
code: "dynamic_secret_command_failed",
|
||||
});
|
||||
@@ -761,13 +933,19 @@ export function secretService(db: Db) {
|
||||
if (!binding) {
|
||||
throw unprocessable("Dynamic secret resolution requires a runtime binding", { code: "binding_missing" });
|
||||
}
|
||||
const dynamicCommand = await resolveDynamicCommandConfig({
|
||||
id: secret.id,
|
||||
companyId: secret.companyId,
|
||||
key: secret.key,
|
||||
latestVersion: secret.latestVersion,
|
||||
});
|
||||
const resolution = await resolveDynamicSecretValue({
|
||||
companyId,
|
||||
secret: {
|
||||
id: secret.id,
|
||||
key: secret.key,
|
||||
provider: providerId,
|
||||
dynamicCommand: secret.dynamicCommand,
|
||||
dynamicCommand,
|
||||
},
|
||||
binding,
|
||||
configPath,
|
||||
@@ -854,7 +1032,7 @@ export function secretService(db: Db) {
|
||||
configPath: string | null;
|
||||
accessContext: SecretConsumerContext | undefined;
|
||||
}): Promise<RuntimeSecretResolution> {
|
||||
const staticArgv = normalizeStaticArgv(input.binding.staticArgv);
|
||||
const staticArgv = await resolveBindingStaticArgv(input.binding);
|
||||
const cacheKey = dynamicSecretCacheKey({ bindingId: input.binding.id, staticArgv });
|
||||
const now = Date.now();
|
||||
const cached = dynamicSecretCache.get(cacheKey);
|
||||
@@ -1996,38 +2174,67 @@ export function secretService(db: Db) {
|
||||
throw unprocessable("Managed secrets require value");
|
||||
}
|
||||
if (managedMode === "dynamic_command") {
|
||||
await assertDynamicSecretsEnabled();
|
||||
if (input.provider !== "host_command") {
|
||||
throw unprocessable("Dynamic command secrets require host_command provider");
|
||||
}
|
||||
if (!input.dynamicCommand) {
|
||||
throw unprocessable("Dynamic command secrets require generator command config");
|
||||
}
|
||||
const dynamicCommand = input.dynamicCommand;
|
||||
if (input.value?.trim()) {
|
||||
throw unprocessable("Dynamic command secrets cannot set value");
|
||||
}
|
||||
if (input.externalRef?.trim()) {
|
||||
throw unprocessable("Dynamic command secrets cannot set externalRef");
|
||||
}
|
||||
return db
|
||||
.insert(companySecrets)
|
||||
.values({
|
||||
const prepared = await prepareEncryptedPayload(
|
||||
{
|
||||
kind: "dynamic_command_config",
|
||||
version: DYNAMIC_SECRET_CONFIG_VERSION,
|
||||
dynamicCommand,
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
key,
|
||||
name: input.name,
|
||||
provider: input.provider,
|
||||
providerConfigId: input.providerConfigId ?? null,
|
||||
status: "active",
|
||||
managedMode,
|
||||
externalRef: null,
|
||||
dynamicCommand: input.dynamicCommand,
|
||||
providerMetadata: input.providerMetadata ?? null,
|
||||
latestVersion: 0,
|
||||
description: input.description ?? null,
|
||||
secretKey: key,
|
||||
secretName: input.name,
|
||||
version: 1,
|
||||
},
|
||||
);
|
||||
return db.transaction(async (tx) => {
|
||||
const inserted = await tx
|
||||
.insert(companySecrets)
|
||||
.values({
|
||||
companyId,
|
||||
key,
|
||||
name: input.name,
|
||||
provider: input.provider,
|
||||
providerConfigId: input.providerConfigId ?? null,
|
||||
status: "active",
|
||||
managedMode,
|
||||
externalRef: null,
|
||||
dynamicCommand: dynamicCommandSummary(dynamicCommand),
|
||||
providerMetadata: input.providerMetadata ?? null,
|
||||
latestVersion: 1,
|
||||
description: input.description ?? null,
|
||||
createdByAgentId: actor?.agentId ?? null,
|
||||
createdByUserId: actor?.userId ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
await tx.insert(companySecretVersions).values({
|
||||
secretId: inserted.id,
|
||||
version: 1,
|
||||
material: prepared.material,
|
||||
valueSha256: prepared.valueSha256,
|
||||
fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
|
||||
providerVersionRef: prepared.providerVersionRef ?? null,
|
||||
status: "current",
|
||||
createdByAgentId: actor?.agentId ?? null,
|
||||
createdByUserId: actor?.userId ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
});
|
||||
return inserted;
|
||||
});
|
||||
}
|
||||
const provider = getSecretProvider(input.provider);
|
||||
const providerConfig = await getSelectableRuntimeProviderConfig({
|
||||
@@ -2419,6 +2626,7 @@ export function secretService(db: Db) {
|
||||
// an unbound generator is not yet attached to any hard-isolation
|
||||
// environment; the runtime resolver labels the bound posture per Stage 5.
|
||||
testDynamicCommand: async (input: {
|
||||
companyId: string;
|
||||
command: string;
|
||||
staticArgv?: string[];
|
||||
}): Promise<{
|
||||
@@ -2429,6 +2637,7 @@ export function secretService(db: Db) {
|
||||
message?: string;
|
||||
}> => {
|
||||
try {
|
||||
await assertDynamicSecretsEnabled();
|
||||
const value = await runDynamicSecretCommand({
|
||||
command: input.command,
|
||||
staticArgv: normalizeStaticArgv(input.staticArgv ?? EMPTY_STATIC_ARGV),
|
||||
@@ -2455,7 +2664,15 @@ export function secretService(db: Db) {
|
||||
label?: string | null;
|
||||
staticArgv?: string[];
|
||||
}) => {
|
||||
await assertSecretInCompany(input.companyId, input.secretId);
|
||||
const secret = await assertSecretInCompany(input.companyId, input.secretId);
|
||||
if (secret.managedMode === "dynamic_command") {
|
||||
await assertDynamicSecretsEnabled();
|
||||
}
|
||||
const staticArgvStorage = await prepareStaticArgvForBinding({
|
||||
companyId: input.companyId,
|
||||
secretId: input.secretId,
|
||||
staticArgv: input.staticArgv,
|
||||
});
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(companySecretBindings)
|
||||
@@ -2480,7 +2697,8 @@ export function secretService(db: Db) {
|
||||
versionSelector: String(input.versionSelector ?? "latest"),
|
||||
required: input.required ?? true,
|
||||
label: input.label ?? null,
|
||||
staticArgv: input.staticArgv ?? EMPTY_STATIC_ARGV,
|
||||
staticArgv: staticArgvStorage.summary,
|
||||
staticArgvMaterial: staticArgvStorage.material,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
@@ -2505,16 +2723,26 @@ export function secretService(db: Db) {
|
||||
required: boolean;
|
||||
label: string | null;
|
||||
staticArgv: string[];
|
||||
staticArgvMaterial: Record<string, unknown> | null;
|
||||
}> = [];
|
||||
for (const ref of refs) {
|
||||
await assertSecretInCompany(companyId, ref.secretId);
|
||||
const secret = await assertSecretInCompany(companyId, ref.secretId);
|
||||
if (secret.managedMode === "dynamic_command") {
|
||||
await assertDynamicSecretsEnabled();
|
||||
}
|
||||
const staticArgvStorage = await prepareStaticArgvForBinding({
|
||||
companyId,
|
||||
secretId: ref.secretId,
|
||||
staticArgv: ref.staticArgv,
|
||||
});
|
||||
normalizedRefs.push({
|
||||
secretId: ref.secretId,
|
||||
configPath: ref.configPath,
|
||||
versionSelector: ref.versionSelector ?? "latest",
|
||||
required: ref.required ?? true,
|
||||
label: ref.label ?? null,
|
||||
staticArgv: ref.staticArgv ?? EMPTY_STATIC_ARGV,
|
||||
staticArgv: staticArgvStorage.summary,
|
||||
staticArgvMaterial: staticArgvStorage.material,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2560,6 +2788,7 @@ export function secretService(db: Db) {
|
||||
required: ref.required,
|
||||
label: ref.label,
|
||||
staticArgv: ref.staticArgv,
|
||||
staticArgvMaterial: ref.staticArgvMaterial,
|
||||
})),
|
||||
);
|
||||
});
|
||||
@@ -2592,6 +2821,7 @@ export function secretService(db: Db) {
|
||||
configPath: string;
|
||||
versionSelector: SecretVersionSelector;
|
||||
staticArgv: string[];
|
||||
staticArgvMaterial: Record<string, unknown> | null;
|
||||
}> = [];
|
||||
const pathPrefix = target.pathPrefix ?? "env";
|
||||
const bindingDb = options?.db ?? db;
|
||||
@@ -2600,12 +2830,21 @@ export function secretService(db: Db) {
|
||||
if (!parsed.success) continue;
|
||||
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
||||
if (binding.type !== "secret_ref") continue;
|
||||
await assertSecretInCompany(companyId, binding.secretId, bindingDb);
|
||||
const secret = await assertSecretInCompany(companyId, binding.secretId, bindingDb);
|
||||
if (secret.managedMode === "dynamic_command") {
|
||||
await assertDynamicSecretsEnabled();
|
||||
}
|
||||
const staticArgvStorage = await prepareStaticArgvForBinding({
|
||||
companyId,
|
||||
secretId: binding.secretId,
|
||||
staticArgv: binding.staticArgv,
|
||||
});
|
||||
refs.push({
|
||||
secretId: binding.secretId,
|
||||
configPath: `${pathPrefix}.${key}`,
|
||||
versionSelector: binding.version,
|
||||
staticArgv: binding.staticArgv ?? EMPTY_STATIC_ARGV,
|
||||
staticArgv: staticArgvStorage.summary,
|
||||
staticArgvMaterial: staticArgvStorage.material,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2631,6 +2870,7 @@ export function secretService(db: Db) {
|
||||
versionSelector: String(ref.versionSelector),
|
||||
required: true,
|
||||
staticArgv: ref.staticArgv,
|
||||
staticArgvMaterial: ref.staticArgvMaterial,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user