Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
f448445
feat(secret-manager): dynamic secret support for proxied services
saifsmailbox98 Jul 14, 2026
751cdf7
fix(secret-manager): address review for dynamic secret proxied services
saifsmailbox98 Jul 14, 2026
8a62091
fix(secret-manager): collect dynamic secret lease inputs once per secret
saifsmailbox98 Jul 14, 2026
1a78315
chore(secret-manager): keep dynamic secret field choice generic
saifsmailbox98 Jul 15, 2026
6546fde
chore(secret-manager): trim explanatory comments to match codebase style
saifsmailbox98 Jul 15, 2026
d2908cd
chore(secret-manager): drop unused schema + unexport in-file-only hel…
saifsmailbox98 Jul 15, 2026
742daaf
docs(agent-proxy): fix stale dynamic-secret notes (generic fields, co…
saifsmailbox98 Jul 15, 2026
f21aa43
chore(secret-manager): use a real timestamp for the dynamic-secret mi…
saifsmailbox98 Jul 15, 2026
d5ca9f3
docs(agent-proxy): document Lease permission for dynamic-secret broke…
saifsmailbox98 Jul 15, 2026
d0b1be4
Merge remote-tracking branch 'origin/main' into saif/age2-42-dynamic-…
saifsmailbox98 Jul 16, 2026
e30f1b5
chore(secret-manager): cap dynamic secret credential columns with var…
saifsmailbox98 Jul 16, 2026
d6a6dc7
fix(secret-manager): don't disclose full principal allowlist in valid…
saifsmailbox98 Jul 16, 2026
dc60d35
improvement(secret-manager): submenu-based credential source picker f…
saifsmailbox98 Jul 16, 2026
4d267c5
improvement(secret-manager): restrict proxied-service credentials to …
saifsmailbox98 Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Knex } from "knex";

import { TableName } from "../schemas";

export async function up(knex: Knex): Promise<void> {
const hasDynamicSecretName = await knex.schema.hasColumn(TableName.ProxiedServiceCredential, "dynamicSecretName");

await knex.schema.alterTable(TableName.ProxiedServiceCredential, (t) => {
if (!hasDynamicSecretName) {
// Name-based reference to a dynamic secret in the credential's own folder.
// Intentionally NO FK to dynamic_secrets: matches secretKey's self-heal semantics
// (a deleted-then-recreated dynamic secret with the same name re-links automatically).
t.text("dynamicSecretName");
t.text("dynamicSecretField");
t.jsonb("dynamicSecretConfig");
}

// A credential now references either a static secret (secretKey) or a dynamic secret
// (dynamicSecretName), so secretKey can no longer be NOT NULL. The "exactly one of" rule
// is enforced at the Zod/application layer.
t.string("secretKey").nullable().alter();
});
}

export async function down(knex: Knex): Promise<void> {
// Dynamic-only rows have a null secretKey and would violate the restored NOT NULL constraint.
await knex(TableName.ProxiedServiceCredential).whereNull("secretKey").delete();

const hasDynamicSecretName = await knex.schema.hasColumn(TableName.ProxiedServiceCredential, "dynamicSecretName");

await knex.schema.alterTable(TableName.ProxiedServiceCredential, (t) => {
if (hasDynamicSecretName) {
t.dropColumn("dynamicSecretName");
t.dropColumn("dynamicSecretField");
t.dropColumn("dynamicSecretConfig");
}
t.string("secretKey").notNullable().alter();
});
}
7 changes: 5 additions & 2 deletions backend/src/db/schemas/proxied-service-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models";
export const ProxiedServiceCredentialsSchema = z.object({
id: z.string().uuid(),
serviceId: z.string().uuid(),
secretKey: z.string(),
secretKey: z.string().nullable().optional(),
role: z.string(),
headerName: z.string().nullable().optional(),
headerPrefix: z.string().nullable().optional(),
Expand All @@ -19,7 +19,10 @@ export const ProxiedServiceCredentialsSchema = z.object({
placeholderValue: z.string().nullable().optional(),
substitutionSurfaces: z.string().array().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
updatedAt: z.date(),
dynamicSecretName: z.string().nullable().optional(),
dynamicSecretField: z.string().nullable().optional(),
dynamicSecretConfig: z.unknown().nullable().optional()
});

export type TProxiedServiceCredentials = z.infer<typeof ProxiedServiceCredentialsSchema>;
Expand Down
23 changes: 17 additions & 6 deletions backend/src/ee/routes/v1/proxied-service-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import {
CredentialsArraySchema,
hostPatternSchema,
ProxiedServiceWithCanProxySchema,
ProxiedServiceWithCanProxyAndLeaseAccessSchema,
ProxiedServiceWithCredentialsSchema,
SanitizedProxiedServiceBaseSchema
} from "@app/ee/services/proxied-service/proxied-service-schemas";
Expand Down Expand Up @@ -48,7 +48,12 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) =
proxiedServiceId: service.id,
name: service.name,
hostPattern: service.hostPattern,
secretKeys: [...new Set(req.body.credentials.map((c) => c.secretKey))],
secretKeys: [
...new Set(req.body.credentials.map((c) => c.secretKey).filter((k): k is string => Boolean(k)))
],
dynamicSecretNames: [
...new Set(req.body.credentials.map((c) => c.dynamicSecretName).filter((n): n is string => Boolean(n)))
],
environment: req.body.environment,
secretPath: req.body.secretPath
}
Expand All @@ -75,7 +80,8 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) =
}),
response: {
200: z.object({
services: ProxiedServiceWithCanProxySchema.array()
projectSlug: z.string(),
services: ProxiedServiceWithCanProxyAndLeaseAccessSchema.array()
})
}
},
Expand All @@ -98,7 +104,7 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) =
}),
response: {
200: z.object({
service: ProxiedServiceWithCanProxySchema
service: ProxiedServiceWithCanProxyAndLeaseAccessSchema
})
}
},
Expand Down Expand Up @@ -127,7 +133,7 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) =
}),
response: {
200: z.object({
service: ProxiedServiceWithCanProxySchema
service: ProxiedServiceWithCanProxyAndLeaseAccessSchema
})
}
},
Expand Down Expand Up @@ -180,7 +186,12 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) =
name: service.name,
hostPattern: service.hostPattern,
updatedFields: Object.keys(req.body),
secretKeys: [...new Set(service.credentials.map((c) => c.secretKey))],
secretKeys: [
...new Set(service.credentials.map((c) => c.secretKey).filter((k): k is string => Boolean(k)))
],
dynamicSecretNames: [
...new Set(service.credentials.map((c) => c.dynamicSecretName).filter((n): n is string => Boolean(n)))
],
environment: service.environment,
secretPath: service.secretPath
}
Expand Down
4 changes: 3 additions & 1 deletion backend/src/ee/services/audit-log/audit-log-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6942,8 +6942,9 @@ interface CreateProxiedServiceEvent {
proxiedServiceId: string;
name: string;
hostPattern: string;
// secret key names only; never placeholder/secret values
// secret / dynamic secret names only; never placeholder/secret/lease values
secretKeys: string[];
dynamicSecretNames: string[];
environment: string;
secretPath: string;
};
Expand All @@ -6957,6 +6958,7 @@ interface UpdateProxiedServiceEvent {
hostPattern: string;
updatedFields: string[];
secretKeys: string[];
dynamicSecretNames: string[];
environment: string;
secretPath: string;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { DYNAMIC_SECRET_PROVIDER_OUTPUTS } from "./dynamic-secret-provider-outputs";
import { DynamicSecretProviders } from "./models";

describe("DYNAMIC_SECRET_PROVIDER_OUTPUTS", () => {
it("has an entry for every provider", () => {
Object.values(DynamicSecretProviders).forEach((provider) => {
expect(DYNAMIC_SECRET_PROVIDER_OUTPUTS[provider]).toBeDefined();
});
});

it("lists at least one output field for every provider (nothing is force-hidden)", () => {
Object.values(DynamicSecretProviders).forEach((provider) => {
expect(DYNAMIC_SECRET_PROVIDER_OUTPUTS[provider].outputFields.length).toBeGreaterThan(0);
});
});

it("exposes ssh key/cert fields for injection (e.g. into a request body)", () => {
expect(DYNAMIC_SECRET_PROVIDER_OUTPUTS[DynamicSecretProviders.Ssh].outputFields).toEqual([
"PRIVATE_KEY",
"SIGNED_KEY"
]);
});

it("keeps the lowercase output-field oddballs verbatim", () => {
expect(DYNAMIC_SECRET_PROVIDER_OUTPUTS[DynamicSecretProviders.AzureEntraID].outputFields).toEqual([
"email",
"password"
]);
expect(DYNAMIC_SECRET_PROVIDER_OUTPUTS[DynamicSecretProviders.Couchbase].outputFields).toEqual([
"username",
"password"
]);
});

it("only attaches a lease config schema to kubernetes and ssh", () => {
Object.values(DynamicSecretProviders).forEach((provider) => {
const { leaseConfigSchema } = DYNAMIC_SECRET_PROVIDER_OUTPUTS[provider];
if (provider === DynamicSecretProviders.Kubernetes || provider === DynamicSecretProviders.Ssh) {
expect(leaseConfigSchema).toBeDefined();
} else {
expect(leaseConfigSchema).toBeUndefined();
}
});
});

it("validates the kubernetes namespace lease config", () => {
const schema = DYNAMIC_SECRET_PROVIDER_OUTPUTS[DynamicSecretProviders.Kubernetes].leaseConfigSchema!;
expect(schema.safeParse({ namespace: "prod" }).success).toBe(true);
expect(schema.safeParse({}).success).toBe(true);
expect(schema.safeParse({ unknown: "x" }).success).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { z } from "zod";

import { DynamicSecretProviders } from "./models";

// Canonical map of dynamic secret provider -> every field its lease output can contain, plus the
// per-lease config a proxied service may set. This drives proxied-service field validation: any listed
// field may be injected. The frontend mirror additionally marks which fields are "recommended" (the
// credential-bearing ones) versus lease metadata, but the backend accepts any listed field.
//
// KEEP IN SYNC with the frontend mirror at
// frontend/src/hooks/api/dynamicSecret/providerOutputs.ts
// (same convention as the host-pattern grammar shared with the CLI's match.go).
//
// Field names match each provider's create() return verbatim, including the lowercase oddballs
// (azure-entra-id -> email/password, couchbase -> username/password).

export type TDynamicSecretProviderOutputEntry = {
outputFields: string[];
// undefined = the provider accepts no per-lease config; a non-empty config is then rejected.
leaseConfigSchema?: z.ZodTypeAny;
};

export const DynamicSecretKubernetesLeaseConfigSchema = z
.object({ namespace: z.string().trim().min(1).optional() })
.strict();

export const DynamicSecretSshLeaseConfigSchema = z
.object({ principals: z.array(z.string().trim().min(1)).optional() })
.strict();

const DB_USER_PASS = ["DB_USERNAME", "DB_PASSWORD"];

export const DYNAMIC_SECRET_PROVIDER_OUTPUTS: Record<DynamicSecretProviders, TDynamicSecretProviderOutputEntry> = {
[DynamicSecretProviders.SqlDatabase]: { outputFields: DB_USER_PASS },
[DynamicSecretProviders.Clickhouse]: { outputFields: DB_USER_PASS },
[DynamicSecretProviders.Cassandra]: { outputFields: DB_USER_PASS },
// aws-iam returns SESSION_TOKEN for the STS-based methods (assume-role / access-key / IRSA) and
// USERNAME for the IAM-user method; the shape depends on encrypted provider config, so both are listed.
[DynamicSecretProviders.AwsIam]: { outputFields: ["ACCESS_KEY", "SECRET_ACCESS_KEY", "SESSION_TOKEN", "USERNAME"] },
Comment thread
saifsmailbox98 marked this conversation as resolved.
[DynamicSecretProviders.Redis]: { outputFields: DB_USER_PASS },
[DynamicSecretProviders.AwsElastiCache]: { outputFields: DB_USER_PASS },
[DynamicSecretProviders.AwsMemoryDb]: { outputFields: DB_USER_PASS },
[DynamicSecretProviders.MongoAtlas]: { outputFields: DB_USER_PASS },
[DynamicSecretProviders.ElasticSearch]: { outputFields: DB_USER_PASS },
[DynamicSecretProviders.MongoDB]: { outputFields: DB_USER_PASS },
[DynamicSecretProviders.RabbitMq]: { outputFields: DB_USER_PASS },
[DynamicSecretProviders.AzureEntraID]: { outputFields: ["email", "password"] },
[DynamicSecretProviders.AzureSqlDatabase]: { outputFields: DB_USER_PASS },
[DynamicSecretProviders.Ldap]: { outputFields: ["USERNAME", "PASSWORD", "DN_ARRAY"] },
[DynamicSecretProviders.SapHana]: { outputFields: DB_USER_PASS },
[DynamicSecretProviders.Snowflake]: { outputFields: DB_USER_PASS },
[DynamicSecretProviders.Totp]: { outputFields: ["TOTP", "TIME_REMAINING"] },
[DynamicSecretProviders.SapAse]: { outputFields: DB_USER_PASS },
[DynamicSecretProviders.Kubernetes]: {
outputFields: ["TOKEN"],
leaseConfigSchema: DynamicSecretKubernetesLeaseConfigSchema
},
[DynamicSecretProviders.Vertica]: { outputFields: DB_USER_PASS },
[DynamicSecretProviders.GcpIam]: { outputFields: ["SERVICE_ACCOUNT_EMAIL", "TOKEN"] },
[DynamicSecretProviders.Github]: {
Comment thread
saifsmailbox98 marked this conversation as resolved.
outputFields: ["TOKEN", "EXPIRES_AT", "PERMISSIONS", "REPOSITORY_SELECTION"]
},
[DynamicSecretProviders.Couchbase]: { outputFields: ["username", "password"] },
[DynamicSecretProviders.Milvus]: { outputFields: DB_USER_PASS },
[DynamicSecretProviders.Ssh]: {
outputFields: ["PRIVATE_KEY", "SIGNED_KEY"],
leaseConfigSchema: DynamicSecretSshLeaseConfigSchema
},
[DynamicSecretProviders.IbmApiConnect]: { outputFields: ["CLIENT_ID", "CLIENT_SECRET"] }
};
3 changes: 3 additions & 0 deletions backend/src/ee/services/permission/default-roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,9 @@ const buildAgentProxyPermissionRules = () => {
ProjectPermissionSub.Secrets
);

// so the agent proxy can mint leases for dynamic-secret-backed proxied service credentials
can(ProjectPermissionDynamicSecretActions.Lease, ProjectPermissionSub.DynamicSecrets);

return rules;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { hostPatternSchema } from "./proxied-service-schemas";
import {
ProxiedServiceCredentialRole,
ProxiedServiceHeaderPurpose,
ProxiedServiceSubstitutionSurface
} from "./proxied-service-enums";
import { CredentialsArraySchema, hostPatternSchema } from "./proxied-service-schemas";

// The host-pattern grammar mirrors the agent-proxy CLI matcher (packages/agentproxy/match.go);
// keep these cases in sync with that matcher's expectations.
Expand Down Expand Up @@ -120,3 +125,84 @@ describe("hostPatternSchema", () => {
});
});
});

describe("CredentialsArraySchema credential source (static vs dynamic)", () => {
const bearerHeader = {
role: ProxiedServiceCredentialRole.HeaderRewrite,
headerName: "Authorization",
headerPrefix: "Bearer"
};
const parseArray = (creds: unknown[]) => CredentialsArraySchema.safeParse(creds);
const errorsOf = (creds: unknown[]) => {
const result = parseArray(creds);
return result.success ? [] : result.error.issues.map((i) => i.message);
};

it("accepts a static secretKey credential", () => {
expect(parseArray([{ ...bearerHeader, secretKey: "STRIPE_API_KEY" }]).success).toBe(true);
});

it("accepts a dynamic credential with a field", () => {
expect(
parseArray([{ ...bearerHeader, dynamicSecretName: "my-postgres", dynamicSecretField: "DB_PASSWORD" }]).success
).toBe(true);
});

it("rejects a credential with neither secretKey nor dynamicSecretName", () => {
expect(errorsOf([bearerHeader])).toContain("Provide exactly one of secretKey or dynamicSecretName");
});

it("rejects a credential with both secretKey and dynamicSecretName", () => {
expect(
errorsOf([
{ ...bearerHeader, secretKey: "K", dynamicSecretName: "my-postgres", dynamicSecretField: "DB_PASSWORD" }
])
).toContain("Provide exactly one of secretKey or dynamicSecretName");
});

it("rejects a dynamic credential missing dynamicSecretField", () => {
expect(errorsOf([{ ...bearerHeader, dynamicSecretName: "my-postgres" }])).toContain(
"dynamicSecretField is required when dynamicSecretName is set"
);
});

it("rejects a static credential that also sets a dynamic field/config", () => {
expect(errorsOf([{ ...bearerHeader, secretKey: "K", dynamicSecretField: "DB_PASSWORD" }])).toContain(
"dynamicSecretField and dynamicSecretConfig are only valid with dynamicSecretName"
);
});

it("allows the same dynamic secret referenced twice with different fields (basic auth)", () => {
expect(
parseArray([
{
role: ProxiedServiceCredentialRole.HeaderRewrite,
headerPurpose: ProxiedServiceHeaderPurpose.Username,
dynamicSecretName: "my-postgres",
dynamicSecretField: "DB_USERNAME"
},
{
role: ProxiedServiceCredentialRole.HeaderRewrite,
headerPurpose: ProxiedServiceHeaderPurpose.Password,
dynamicSecretName: "my-postgres",
dynamicSecretField: "DB_PASSWORD"
}
]).success
).toBe(true);
});

it("accepts a dynamic substitution credential", () => {
expect(
parseArray([
{
role: ProxiedServiceCredentialRole.CredentialSubstitution,
placeholderKey: "TOKEN",
placeholderValue: "placeholder_token",
substitutionSurfaces: [ProxiedServiceSubstitutionSurface.Path],
dynamicSecretName: "gh-app",
dynamicSecretField: "TOKEN"
}
]).success
).toBe(true);
});
});
Loading
Loading