diff --git a/backend/src/db/migrations/20260715184927_proxied-service-dynamic-secret-credentials.ts b/backend/src/db/migrations/20260715184927_proxied-service-dynamic-secret-credentials.ts new file mode 100644 index 00000000000..46ae5ea2ec9 --- /dev/null +++ b/backend/src/db/migrations/20260715184927_proxied-service-dynamic-secret-credentials.ts @@ -0,0 +1,32 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasDynamicSecretName = await knex.schema.hasColumn(TableName.ProxiedServiceCredential, "dynamicSecretName"); + + await knex.schema.alterTable(TableName.ProxiedServiceCredential, (t) => { + if (!hasDynamicSecretName) { + t.string("dynamicSecretName"); + t.string("dynamicSecretField"); + t.jsonb("dynamicSecretConfig"); + } + + t.string("secretKey").nullable().alter(); + }); +} + +export async function down(knex: Knex): Promise { + 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(); + }); +} diff --git a/backend/src/db/schemas/proxied-service-credentials.ts b/backend/src/db/schemas/proxied-service-credentials.ts index 09cea4e4256..aa908b38476 100644 --- a/backend/src/db/schemas/proxied-service-credentials.ts +++ b/backend/src/db/schemas/proxied-service-credentials.ts @@ -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(), @@ -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; diff --git a/backend/src/ee/routes/v1/proxied-service-router.ts b/backend/src/ee/routes/v1/proxied-service-router.ts index 87ded535f5a..bad91935c34 100644 --- a/backend/src/ee/routes/v1/proxied-service-router.ts +++ b/backend/src/ee/routes/v1/proxied-service-router.ts @@ -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"; @@ -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 } @@ -75,7 +80,8 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = }), response: { 200: z.object({ - services: ProxiedServiceWithCanProxySchema.array() + projectSlug: z.string(), + services: ProxiedServiceWithCanProxyAndLeaseAccessSchema.array() }) } }, @@ -98,7 +104,7 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = }), response: { 200: z.object({ - service: ProxiedServiceWithCanProxySchema + service: ProxiedServiceWithCanProxyAndLeaseAccessSchema }) } }, @@ -127,7 +133,7 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = }), response: { 200: z.object({ - service: ProxiedServiceWithCanProxySchema + service: ProxiedServiceWithCanProxyAndLeaseAccessSchema }) } }, @@ -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 } diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 44bdc381696..46172169ddf 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -6944,8 +6944,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; }; @@ -6959,6 +6960,7 @@ interface UpdateProxiedServiceEvent { hostPattern: string; updatedFields: string[]; secretKeys: string[]; + dynamicSecretNames: string[]; environment: string; secretPath: string; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/dynamic-secret-provider-outputs.test.ts b/backend/src/ee/services/dynamic-secret/providers/dynamic-secret-provider-outputs.test.ts new file mode 100644 index 00000000000..2c19805de69 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/dynamic-secret-provider-outputs.test.ts @@ -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); + }); +}); diff --git a/backend/src/ee/services/dynamic-secret/providers/dynamic-secret-provider-outputs.ts b/backend/src/ee/services/dynamic-secret/providers/dynamic-secret-provider-outputs.ts new file mode 100644 index 00000000000..890a780b572 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/dynamic-secret-provider-outputs.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +import { DynamicSecretProviders } from "./models"; + +// keep field names in sync with the frontend mirror at frontend/src/hooks/api/dynamicSecret/providerOutputs.ts + +type TDynamicSecretProviderOutputEntry = { + outputFields: string[]; + leaseConfigSchema?: z.ZodTypeAny; +}; + +const DynamicSecretKubernetesLeaseConfigSchema = z.object({ namespace: z.string().trim().min(1).optional() }).strict(); + +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.SqlDatabase]: { outputFields: DB_USER_PASS }, + [DynamicSecretProviders.Clickhouse]: { outputFields: DB_USER_PASS }, + [DynamicSecretProviders.Cassandra]: { outputFields: DB_USER_PASS }, + [DynamicSecretProviders.AwsIam]: { outputFields: ["ACCESS_KEY", "SECRET_ACCESS_KEY", "SESSION_TOKEN", "USERNAME"] }, + [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]: { + 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"] } +}; diff --git a/backend/src/ee/services/proxied-service/proxied-service-brokerable-outputs.test.ts b/backend/src/ee/services/proxied-service/proxied-service-brokerable-outputs.test.ts new file mode 100644 index 00000000000..f8e2e1bb603 --- /dev/null +++ b/backend/src/ee/services/proxied-service/proxied-service-brokerable-outputs.test.ts @@ -0,0 +1,24 @@ +import { DYNAMIC_SECRET_PROVIDER_OUTPUTS } from "../dynamic-secret/providers/dynamic-secret-provider-outputs"; +import { DynamicSecretProviders } from "../dynamic-secret/providers/models"; +import { BROKERABLE_DYNAMIC_SECRET_OUTPUTS } from "./proxied-service-brokerable-outputs"; + +describe("BROKERABLE_DYNAMIC_SECRET_OUTPUTS", () => { + it("allows 12 providers for HTTP brokering", () => { + expect(Object.keys(BROKERABLE_DYNAMIC_SECRET_OUTPUTS)).toHaveLength(12); + }); + + it("only lists fields that the provider actually outputs", () => { + Object.entries(BROKERABLE_DYNAMIC_SECRET_OUTPUTS).forEach(([provider, fields]) => { + const { outputFields } = DYNAMIC_SECRET_PROVIDER_OUTPUTS[provider as DynamicSecretProviders]; + fields?.forEach((field) => expect(outputFields).toContain(field)); + }); + }); + + it("does not broker database/ssh providers or metadata fields", () => { + expect(BROKERABLE_DYNAMIC_SECRET_OUTPUTS[DynamicSecretProviders.SqlDatabase]).toBeUndefined(); + expect(BROKERABLE_DYNAMIC_SECRET_OUTPUTS[DynamicSecretProviders.AwsIam]).toBeUndefined(); + expect(BROKERABLE_DYNAMIC_SECRET_OUTPUTS[DynamicSecretProviders.Ssh]).toBeUndefined(); + expect(BROKERABLE_DYNAMIC_SECRET_OUTPUTS[DynamicSecretProviders.Github]).toEqual(["TOKEN"]); + expect(BROKERABLE_DYNAMIC_SECRET_OUTPUTS[DynamicSecretProviders.Totp]).toEqual(["TOTP"]); + }); +}); diff --git a/backend/src/ee/services/proxied-service/proxied-service-brokerable-outputs.ts b/backend/src/ee/services/proxied-service/proxied-service-brokerable-outputs.ts new file mode 100644 index 00000000000..13b1ae70ecd --- /dev/null +++ b/backend/src/ee/services/proxied-service/proxied-service-brokerable-outputs.ts @@ -0,0 +1,18 @@ +import { DynamicSecretProviders } from "../dynamic-secret/providers/models"; + +// Providers/fields a proxied service can broker over HTTP; anything not listed is blocked. +// keep in sync with frontend/src/components/proxied-services/forms/brokerableDynamicSecrets.ts +export const BROKERABLE_DYNAMIC_SECRET_OUTPUTS: Partial> = { + [DynamicSecretProviders.Kubernetes]: ["TOKEN"], + [DynamicSecretProviders.Github]: ["TOKEN"], + [DynamicSecretProviders.GcpIam]: ["TOKEN"], + [DynamicSecretProviders.IbmApiConnect]: ["CLIENT_ID", "CLIENT_SECRET"], + [DynamicSecretProviders.ElasticSearch]: ["DB_USERNAME", "DB_PASSWORD"], + [DynamicSecretProviders.Clickhouse]: ["DB_USERNAME", "DB_PASSWORD"], + [DynamicSecretProviders.Couchbase]: ["username", "password"], + [DynamicSecretProviders.RabbitMq]: ["DB_USERNAME", "DB_PASSWORD"], + [DynamicSecretProviders.Snowflake]: ["DB_USERNAME", "DB_PASSWORD"], + [DynamicSecretProviders.Milvus]: ["DB_USERNAME", "DB_PASSWORD"], + [DynamicSecretProviders.AzureEntraID]: ["email", "password"], + [DynamicSecretProviders.Totp]: ["TOTP"] +}; diff --git a/backend/src/ee/services/proxied-service/proxied-service-schemas.test.ts b/backend/src/ee/services/proxied-service/proxied-service-schemas.test.ts index 59e8ff1d159..ae530acc83f 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-schemas.test.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-schemas.test.ts @@ -1,4 +1,8 @@ -import { ProxiedServiceCredentialRole, ProxiedServiceHeaderPurpose } from "./proxied-service-enums"; +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); @@ -122,6 +126,87 @@ 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); + }); +}); + describe("CredentialsArraySchema basic auth", () => { const username = { secretKey: "API_KEY", diff --git a/backend/src/ee/services/proxied-service/proxied-service-schemas.ts b/backend/src/ee/services/proxied-service/proxied-service-schemas.ts index 97285345ddd..83d0a3c78fe 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-schemas.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-schemas.ts @@ -73,9 +73,31 @@ export const hostPatternSchema = z }); }); +const DynamicSecretConfigSchema = z + .object({ + namespace: z.string().trim().min(1).optional(), + principals: z.array(z.string().trim().min(1)).optional() + }) + .strict(); + const CredentialInputSchema = z .object({ - secretKey: z.string().trim().min(1).max(255).describe(PROXIED_SERVICES.CREDENTIAL.secretKey), + secretKey: z.string().trim().min(1).max(255).optional().describe(PROXIED_SERVICES.CREDENTIAL.secretKey), + dynamicSecretName: z + .string() + .trim() + .min(1) + .max(255) + .optional() + .describe(PROXIED_SERVICES.CREDENTIAL.dynamicSecretName), + dynamicSecretField: z + .string() + .trim() + .min(1) + .max(255) + .optional() + .describe(PROXIED_SERVICES.CREDENTIAL.dynamicSecretField), + dynamicSecretConfig: DynamicSecretConfigSchema.optional().describe(PROXIED_SERVICES.CREDENTIAL.dynamicSecretConfig), role: z.nativeEnum(ProxiedServiceCredentialRole).describe(PROXIED_SERVICES.CREDENTIAL.role), headerName: z.string().trim().min(1).max(255).optional().describe(PROXIED_SERVICES.CREDENTIAL.headerName), headerPrefix: z.string().trim().max(255).optional().describe(PROXIED_SERVICES.CREDENTIAL.headerPrefix), @@ -98,6 +120,26 @@ const CredentialInputSchema = z .describe(PROXIED_SERVICES.CREDENTIAL.substitutionSurfaces) }) .superRefine((cred, ctx) => { + if (Boolean(cred.secretKey) === Boolean(cred.dynamicSecretName)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Provide exactly one of secretKey or dynamicSecretName" + }); + } + if (cred.dynamicSecretName) { + if (!cred.dynamicSecretField) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "dynamicSecretField is required when dynamicSecretName is set" + }); + } + } else if (cred.dynamicSecretField || cred.dynamicSecretConfig) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "dynamicSecretField and dynamicSecretConfig are only valid with dynamicSecretName" + }); + } + if (cred.role === ProxiedServiceCredentialRole.HeaderRewrite) { if (cred.headerPurpose) { if (cred.headerName || cred.headerPrefix) { @@ -200,7 +242,14 @@ export const SanitizedProxiedServiceCredentialSchema = ProxiedServiceCredentials headerPurpose: true, placeholderKey: true, placeholderValue: true, - substitutionSurfaces: true + substitutionSurfaces: true, + dynamicSecretName: true, + dynamicSecretField: true, + dynamicSecretConfig: true +}); + +export const SanitizedProxiedServiceCredentialWithLeaseAccessSchema = SanitizedProxiedServiceCredentialSchema.extend({ + callerCanLease: z.boolean().optional() }); export const SanitizedProxiedServiceBaseSchema = ProxiedServicesSchema.pick({ @@ -217,6 +266,7 @@ export const ProxiedServiceWithCredentialsSchema = SanitizedProxiedServiceBaseSc credentials: SanitizedProxiedServiceCredentialSchema.array() }); -export const ProxiedServiceWithCanProxySchema = ProxiedServiceWithCredentialsSchema.extend({ +export const ProxiedServiceWithCanProxyAndLeaseAccessSchema = SanitizedProxiedServiceBaseSchema.extend({ + credentials: SanitizedProxiedServiceCredentialWithLeaseAccessSchema.array(), canProxy: z.boolean() }); diff --git a/backend/src/ee/services/proxied-service/proxied-service-service.ts b/backend/src/ee/services/proxied-service/proxied-service-service.ts index bde65525af7..ab33f9db2ba 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-service.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-service.ts @@ -1,19 +1,28 @@ -import { ForbiddenError, subject } from "@casl/ability"; +import { ForbiddenError, MongoAbility, subject } from "@casl/ability"; import { ActionProjectType } from "@app/db/schemas"; import { + ProjectPermissionDynamicSecretActions, ProjectPermissionProxiedServiceActions, + ProjectPermissionSet, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { prefixWithSlash, removeTrailingSlash } from "@app/lib/fn"; -import { OrgServiceActor } from "@app/lib/types"; +import { OrgServiceActor, TDynamicSecretWithMetadata } from "@app/lib/types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; import { PersonalOverridesBehavior, SecretImportReferencesBehavior } from "@app/services/secret/secret-types"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretV2BridgeServiceFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-service"; +import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal"; +import { DYNAMIC_SECRET_PROVIDER_OUTPUTS } from "../dynamic-secret/providers/dynamic-secret-provider-outputs"; +import { DynamicSecretProviders } from "../dynamic-secret/providers/models"; import { TLicenseServiceFactory } from "../license/license-service"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; +import { BROKERABLE_DYNAMIC_SECRET_OUTPUTS } from "./proxied-service-brokerable-outputs"; import { TProxiedServiceCredentialDALFactory } from "./proxied-service-credential-dal"; import { TProxiedServiceDALFactory } from "./proxied-service-dal"; import { @@ -40,27 +49,51 @@ type TProxiedServiceServiceFactoryDep = { secretV2BridgeService: Pick; permissionService: Pick; licenseService: Pick; + dynamicSecretDAL: Pick; + projectDAL: Pick; + kmsService: Pick; }; const toCredentialRow = (serviceId: string, credential: TProxiedServiceCredentialInput) => ({ serviceId, - secretKey: credential.secretKey, + secretKey: credential.secretKey ?? null, role: credential.role, headerName: credential.headerName ?? null, headerPrefix: credential.headerPrefix ?? null, headerPurpose: credential.headerPurpose ?? null, placeholderKey: credential.placeholderKey ?? null, placeholderValue: credential.placeholderValue ?? null, - substitutionSurfaces: credential.substitutionSurfaces ?? null + substitutionSurfaces: credential.substitutionSurfaces ?? null, + dynamicSecretName: credential.dynamicSecretName ?? null, + dynamicSecretField: credential.dynamicSecretField ?? null, + dynamicSecretConfig: credential.dynamicSecretConfig ?? null }); +type TCredentialRef = { + secretKey?: string | null; + dynamicSecretName?: string | null; + dynamicSecretField?: string | null; + dynamicSecretConfig?: unknown; +}; + +const canonicalizeLeaseConfig = (config: unknown): string => { + if (!config || typeof config !== "object") return ""; + const obj = config as Record; + const keys = Object.keys(obj).sort(); + if (!keys.length) return ""; + return JSON.stringify(keys.map((k) => [k, obj[k]])); +}; + export const proxiedServiceServiceFactory = ({ proxiedServiceDAL, proxiedServiceCredentialDAL, folderDAL, secretV2BridgeService, permissionService, - licenseService + licenseService, + dynamicSecretDAL, + projectDAL, + kmsService }: TProxiedServiceServiceFactoryDep) => { const $checkLicense = async (orgId: string) => { const plan = await licenseService.getPlan(orgId); @@ -78,9 +111,9 @@ export const proxiedServiceServiceFactory = ({ projectId: string, environment: string, secretPath: string, - credentials: { secretKey: string }[] + credentials: TCredentialRef[] ) => { - const uniqueKeys = [...new Set(credentials.map((c) => c.secretKey))]; + const uniqueKeys = [...new Set(credentials.map((c) => c.secretKey).filter((k): k is string => Boolean(k)))]; if (!uniqueKeys.length) return; // viewSecretValue must be true so secretValueHidden reflects real ReadValue access; values are discarded. @@ -137,6 +170,152 @@ export const proxiedServiceServiceFactory = ({ } }; + const $findReferencedDynamicSecrets = async (folderId: string, credentials: TCredentialRef[]) => { + const names = [...new Set(credentials.map((c) => c.dynamicSecretName).filter((n): n is string => Boolean(n)))]; + if (!names.length) return { names, byName: new Map() }; + const found = await dynamicSecretDAL.findWithMetadata({ folderId, $in: { name: names } }); + return { names, byName: new Map(found.map((ds) => [ds.name, ds])) }; + }; + + const $assertReferencedDynamicSecretsLeasable = async ( + permission: MongoAbility, + orgId: string, + projectId: string, + environment: string, + secretPath: string, + folderId: string, + credentials: TCredentialRef[] + ) => { + const dynamicCreds = credentials.filter((c) => Boolean(c.dynamicSecretName)); + if (!dynamicCreds.length) return; + + const configByName = new Map(); + dynamicCreds.forEach((cred) => { + const name = cred.dynamicSecretName as string; + const canonical = canonicalizeLeaseConfig(cred.dynamicSecretConfig); + const seen = configByName.get(name); + if (seen !== undefined && seen !== canonical) { + throw new BadRequestError({ + message: `Conflicting lease config for dynamic secret "${name}": all credentials referencing it must use the same config` + }); + } + configByName.set(name, canonical); + }); + + const plan = await licenseService.getPlan(orgId); + if (!plan.dynamicSecret) { + throw new BadRequestError({ + message: + "Failed to reference a dynamic secret due to plan restriction. Upgrade your plan to use dynamic secrets." + }); + } + + const { names, byName } = await $findReferencedDynamicSecrets(folderId, dynamicCreds); + const missing = names.filter((n) => !byName.has(n)); + if (missing.length) { + throw new BadRequestError({ + message: `Referenced dynamic secret(s) not found in this folder: ${missing.join(", ")}` + }); + } + + dynamicCreds.forEach((cred) => { + const ds = byName.get(cred.dynamicSecretName as string) as TDynamicSecretWithMetadata; + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionDynamicSecretActions.Lease, + subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath, metadata: ds.metadata }) + ); + + const registry = DYNAMIC_SECRET_PROVIDER_OUTPUTS[ds.type as DynamicSecretProviders]; + if (!registry) { + throw new BadRequestError({ message: `Dynamic secret "${ds.name}" has an unsupported provider type` }); + } + const brokerableFields = BROKERABLE_DYNAMIC_SECRET_OUTPUTS[ds.type as DynamicSecretProviders]; + if (!brokerableFields) { + throw new BadRequestError({ + message: `Dynamic secret "${ds.name}" (${ds.type}) can't be brokered over HTTP` + }); + } + if (!cred.dynamicSecretField || !brokerableFields.includes(cred.dynamicSecretField)) { + throw new BadRequestError({ + message: `"${cred.dynamicSecretField}" is not a valid output field for dynamic secret "${ds.name}" (${ds.type}). Allowed: ${brokerableFields.join(", ") || "none"}` + }); + } + + if (cred.dynamicSecretConfig && Object.keys(cred.dynamicSecretConfig as object).length) { + if (!registry.leaseConfigSchema) { + throw new BadRequestError({ + message: `Dynamic secret "${ds.name}" (${ds.type}) does not accept a lease config` + }); + } + const parsed = registry.leaseConfigSchema.safeParse(cred.dynamicSecretConfig); + if (!parsed.success) { + throw new BadRequestError({ + message: `Invalid lease config for dynamic secret "${ds.name}": ${parsed.error.issues.map((i) => i.message).join(", ")}` + }); + } + } + }); + + const sshCreds = dynamicCreds.filter( + (c) => byName.get(c.dynamicSecretName as string)?.type === DynamicSecretProviders.Ssh + ); + if (sshCreds.length) { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + const allowedByName = new Map>(); + const allowedPrincipals = (name: string) => { + if (!allowedByName.has(name)) { + const ds = byName.get(name) as TDynamicSecretWithMetadata; + const decrypted = JSON.parse(decryptor({ cipherTextBlob: Buffer.from(ds.encryptedInput) }).toString()) as { + principals?: string[]; + }; + allowedByName.set(name, new Set(decrypted.principals ?? [])); + } + return allowedByName.get(name) as Set; + }; + + sshCreds.forEach((cred) => { + const name = cred.dynamicSecretName as string; + const requested = (cred.dynamicSecretConfig as { principals?: string[] } | undefined)?.principals ?? []; + if (!requested.length) { + throw new BadRequestError({ message: `SSH dynamic secret "${name}" requires at least one principal` }); + } + const allowed = allowedPrincipals(name); + const invalid = requested.filter((p) => !allowed.has(p)); + if (invalid.length) { + throw new BadRequestError({ + message: `Principal(s) not allowed for dynamic secret "${name}": ${invalid.join(", ")}` + }); + } + }); + } + }; + + const $decorateCredentialsWithLeaseAccess = async ( + permission: MongoAbility, + environment: string, + secretPath: string, + folderId: string, + credentials: T[] + ): Promise<(T & { callerCanLease?: boolean })[]> => { + const dynamicCreds = credentials.filter((c) => Boolean(c.dynamicSecretName)); + if (!dynamicCreds.length) return credentials; + + const { byName } = await $findReferencedDynamicSecrets(folderId, dynamicCreds); + return credentials.map((cred) => { + if (!cred.dynamicSecretName) return cred; + const metadata = byName.get(cred.dynamicSecretName)?.metadata ?? []; + const callerCanLease = permission.can( + ProjectPermissionDynamicSecretActions.Lease, + subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath, metadata }) + ); + return { ...cred, callerCanLease }; + }); + }; + const $resolveSecretPath = async (projectId: string, folderId: string) => { const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(projectId, [folderId]); if (!folderWithPath) { @@ -173,6 +352,15 @@ export const proxiedServiceServiceFactory = ({ } await $assertReferencedSecretsReadable(actor, projectId, environment, canonicalPath, credentials); + await $assertReferencedDynamicSecretsLeasable( + permission, + actor.orgId, + projectId, + environment, + canonicalPath, + folder.id, + credentials + ); const existing = await proxiedServiceDAL.findOne({ folderId: folder.id, name }); if (existing) { @@ -217,18 +405,31 @@ export const proxiedServiceServiceFactory = ({ }); } + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ message: `Project with ID "${projectId}" not found` }); + } + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); - if (!folder) return { services: [] }; + if (!folder) return { projectSlug: project.slug, services: [] }; const services = await proxiedServiceDAL.findByFolderIds([folder.id]); const credentials = await proxiedServiceCredentialDAL.findByServiceIds(services.map((s) => s.id)); - const credentialsByService = credentials.reduce>((acc, cred) => { + const decorated = await $decorateCredentialsWithLeaseAccess( + permission, + environment, + canonicalPath, + folder.id, + credentials + ); + const credentialsByService = decorated.reduce>((acc, cred) => { acc[cred.serviceId] = acc[cred.serviceId] || []; acc[cred.serviceId].push(cred); return acc; }, {}); return { + projectSlug: project.slug, services: services.map((svc) => ({ ...svc, canProxy, @@ -265,7 +466,14 @@ export const proxiedServiceServiceFactory = ({ } const credentials = await proxiedServiceCredentialDAL.findByServiceIds([service.id]); - return { ...service, canProxy, credentials }; + const decorated = await $decorateCredentialsWithLeaseAccess( + permission, + service.environmentSlug, + resolvedSecretPath, + service.folderId, + credentials + ); + return { ...service, canProxy, credentials: decorated }; }; const getByName = async ( @@ -305,7 +513,14 @@ export const proxiedServiceServiceFactory = ({ } const credentials = await proxiedServiceCredentialDAL.findByServiceIds([service.id]); - return { ...service, canProxy, credentials }; + const decorated = await $decorateCredentialsWithLeaseAccess( + permission, + environment, + canonicalPath, + folder.id, + credentials + ); + return { ...service, canProxy, credentials: decorated }; }; const updateById = async ( @@ -353,6 +568,15 @@ export const proxiedServiceServiceFactory = ({ resolvedSecretPath, effectiveCredentials ); + await $assertReferencedDynamicSecretsLeasable( + permission, + actor.orgId, + service.projectId, + service.environmentSlug, + resolvedSecretPath, + service.folderId, + effectiveCredentials + ); const serviceUpdate = { ...(name !== undefined ? { name } : {}), diff --git a/backend/src/ee/services/proxied-service/proxied-service-types.ts b/backend/src/ee/services/proxied-service/proxied-service-types.ts index 4232f2e620f..ab5fca436f4 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-types.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-types.ts @@ -1,3 +1,4 @@ +import { TDynamicSecretLeaseConfig } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-types"; import { OrderByDirection } from "@app/lib/types"; import { @@ -7,7 +8,10 @@ import { } from "./proxied-service-enums"; export type TProxiedServiceCredentialInput = { - secretKey: string; + secretKey?: string | null; + dynamicSecretName?: string | null; + dynamicSecretField?: string | null; + dynamicSecretConfig?: TDynamicSecretLeaseConfig | null; role: ProxiedServiceCredentialRole; headerName?: string | null; headerPrefix?: string | null; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index bdab71e86ec..bfb603631b3 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1640,7 +1640,14 @@ export const PROXIED_SERVICES = { serviceId: "The ID of the proxied service to delete." }, CREDENTIAL: { - secretKey: "The key name of the referenced secret. The secret must live in the same folder as the service.", + secretKey: + "The key name of the referenced static secret. The secret must live in the same folder as the service. Provide exactly one of secretKey or dynamicSecretName.", + dynamicSecretName: + "The name of the referenced dynamic secret. The dynamic secret must live in the same folder as the service; the agent proxy mints a lease and injects a field from its output. Provide exactly one of secretKey or dynamicSecretName. Referenced by name (like secretKey), so a deleted-then-recreated dynamic secret with the same name re-links automatically.", + dynamicSecretField: + "For a dynamic secret credential: which lease output field to inject (e.g. 'DB_PASSWORD', 'TOKEN'). Must be a valid output field for the dynamic secret's provider type.", + dynamicSecretConfig: + "For a dynamic secret credential: optional per-lease config passed when minting (e.g. { namespace } for kubernetes, { principals } for ssh).", role: "How the credential is applied: 'header-rewrite' sets an HTTP header on the outbound request; 'credential-substitution' replaces a placeholder value in the request.", headerName: "For header rewriting: the header to set, e.g. 'Authorization' or 'x-api-key'.", headerPrefix: "For header rewriting: an optional prefix joined to the secret value with a space, e.g. 'Bearer'.", diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 33a6adf662b..b05fde8d116 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2961,7 +2961,10 @@ export const registerRoutes = async ( folderDAL, secretV2BridgeService, permissionService, - licenseService + licenseService, + dynamicSecretDAL, + projectDAL, + kmsService }); const agentProxyCaService = agentProxyCaServiceFactory({ diff --git a/docs/documentation/platform/agent-proxy/proxied-services.mdx b/docs/documentation/platform/agent-proxy/proxied-services.mdx index 9b06c28690a..6f977677836 100644 --- a/docs/documentation/platform/agent-proxy/proxied-services.mdx +++ b/docs/documentation/platform/agent-proxy/proxied-services.mdx @@ -126,6 +126,26 @@ A folder-local secret always wins over an imported one with the same key. Read p Either way, the value is fully resolved before the proxy applies it. +## Using Dynamic Secrets + +A credential can reference a [dynamic secret](/documentation/platform/dynamic-secrets/overview) instead of a static secret. Instead of injecting a fixed value, the agent proxy mints a short-lived lease and injects one of its output fields (for example a database password, a GitHub App token, or a Kubernetes service-account token). Because the value rotates on its own at the lease TTL, a credential that leaks is worthless shortly after. + +In the credential form, choose a dynamic secret from the **Dynamic Secrets** group of the secret picker, then pick which **Output Field** of the lease to inject. Each provider exposes different fields (SQL databases give `DB_USERNAME` / `DB_PASSWORD`, GitHub gives a `TOKEN`, and so on); pick the field you want to inject. Basic auth works by pointing the username and password at two fields of the same dynamic secret. Some providers accept a per-lease input, such as a **Namespace** for Kubernetes, which you set on the credential. + +How the agent proxy handles the lease: + +- **Minted lazily, per agent session.** The first request that needs the credential mints a lease; each agent session gets its own lease, and subsequent requests reuse it. +- **Refreshed before expiry.** The proxy mints a fresh lease shortly before the current one expires and swaps it in with no failed requests in between. It does not renew the old lease; the old one is left to expire on its own. +- **Cleaned up.** Leases are revoked when the proxy shuts down; anything missed is reaped by the lease's own TTL on the server. + + + Prefer TTLs of at least a few minutes on dynamic secrets used for brokering. Each agent session on each proxy instance holds its own lease and re-mints it as it nears expiry, so a very short TTL (e.g. one minute) causes constant re-minting. + + + + Only the agent proxy's machine identity mints leases, and only it needs the **Lease** permission on the referenced dynamic secrets. The agent identity must **not** hold Lease on a brokered dynamic secret; if it does, it could mint the credential itself and bypass the proxy, so `infisical secrets agent-proxy connect` refuses to start (pass `--allow-readable-brokered-secrets` to override). Creating or updating a proxied service that references a dynamic secret requires that you hold Lease on it, and requires a plan that includes dynamic secrets. + + ## Example: API Request Proxied services can also be managed via the [API](/api-reference/endpoints/proxied-services/create). For example, creating the Telegram service described above: @@ -153,3 +173,15 @@ curl -X POST https://app.infisical.com/api/v1/proxied-services \ ``` Header-rewrite credentials use `"role": "header-rewrite"` with `headerName` and an optional `headerPrefix`, or `headerPurpose` (`username`/`password`) for basic auth. + +To reference a dynamic secret, replace `secretKey` on a credential with `dynamicSecretName` and `dynamicSecretField` (and an optional `dynamicSecretConfig`, e.g. `{ "namespace": "prod" }` for Kubernetes): + +```json +{ + "dynamicSecretName": "my-postgres", + "dynamicSecretField": "DB_PASSWORD", + "role": "header-rewrite", + "headerName": "Authorization", + "headerPrefix": "Bearer" +} +``` diff --git a/docs/documentation/platform/agent-proxy/security.mdx b/docs/documentation/platform/agent-proxy/security.mdx index f5cca9c1fc9..c38ea9154d5 100644 --- a/docs/documentation/platform/agent-proxy/security.mdx +++ b/docs/documentation/platform/agent-proxy/security.mdx @@ -79,7 +79,7 @@ Grant each identity the minimum permissions it needs with a [custom role](/docum | Identity | Minimum permissions | Notes | | --- | --- | --- | | **Agent** | `Proxy` on **Proxied Services**, scoped to the environments and paths where its services live | This alone lets it route traffic and have credentials applied. It does not need to read any secret. Add `Read Value` on **Secrets** only for values the agent uses directly (not brokered ones). | -| **Agent Proxy** | `Read Value` and `Describe Secret` on **Secrets**, covering every secret the services reference | This is the identity that actually fetches the real values. Both actions are required: `Describe Secret` determines whether a secret is visible to the identity at all, and `Read Value` reveals its value. It needs no proxied-service permission. | +| **Agent Proxy** | `Read Value` and `Describe Secret` on **Secrets**, covering every secret the services reference; plus `Lease` on **Dynamic Secrets** for any dynamic secret a service brokers | This is the identity that actually fetches the real values and mints leases. `Describe Secret` determines whether a secret is visible to the identity at all, `Read Value` reveals its value, and `Lease` lets it mint a brokered dynamic secret. It needs no proxied-service permission. | An agent identity should be scoped to just the proxy permission, with no secret read access. If an agent also needs real values in its environment, grant its identity **Read Value** on those specific secrets. Avoid granting folder-wide read access to an agent identity: that would also expose the brokered secrets and defeat the purpose of brokering them. @@ -87,6 +87,8 @@ Grant each identity the minimum permissions it needs with a [custom role](/docum The key thing to get right for the agent proxy: it needs **Read Value on every secret referenced by every service any of its agents use**, across the relevant environments and paths. If a referenced secret comes from a [secret import](/documentation/platform/agent-proxy/proxied-services#using-secrets), the read permission has to cover the secret's real location (the import source), not just the folder it is imported into. If a grant is missing, that credential is skipped. +For a service that brokers a [dynamic secret](/documentation/platform/agent-proxy/proxied-services#using-dynamic-secrets), the proxy identity needs `Lease` on it instead of `Read Value` — and the agent identity must **not** have `Lease` on it, or `agent-proxy connect` refuses to start. + ### How Many Machine Identities Do You Need? A [machine identity](/documentation/platform/identities/machine-identities) is how Infisical knows who is asking and what they are allowed to access. So the number you need is not a technical limit; it follows from how you want access divided: diff --git a/frontend/src/components/proxied-services/forms/CredentialSourceFields.tsx b/frontend/src/components/proxied-services/forms/CredentialSourceFields.tsx new file mode 100644 index 00000000000..80ae506415f --- /dev/null +++ b/frontend/src/components/proxied-services/forms/CredentialSourceFields.tsx @@ -0,0 +1,382 @@ +import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { subject } from "@casl/ability"; +import { useQuery } from "@tanstack/react-query"; +import { + ChevronDownIcon, + ChevronRightIcon, + FingerprintIcon, + KeyIcon, + SearchIcon +} from "lucide-react"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + Tooltip, + TooltipContent, + TooltipTrigger +} from "@app/components/v3"; +import { useProject, useProjectPermission, useSubscription } from "@app/context"; +import { + ProjectPermissionDynamicSecretActions, + ProjectPermissionSecretActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; +import { DYNAMIC_SECRET_PROVIDER_OUTPUTS } from "@app/hooks/api/dynamicSecret/providerOutputs"; +import { useGetDynamicSecrets } from "@app/hooks/api/dynamicSecret/queries"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; +import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries"; +import { SecretV3RawResponse } from "@app/hooks/api/secrets/types"; +import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; + +import { BROKERABLE_DYNAMIC_SECRET_OUTPUTS } from "./brokerableDynamicSecrets"; + +export type TCredentialSource = { + secretKey?: string; + dynamicSecretName?: string; + dynamicSecretField?: string; +}; + +type Props = { + projectId: string; + environment: string; + secretPath: string; + value: TCredentialSource; + onChange: (value: TCredentialSource) => void; + isSecretError?: boolean; + isFieldError?: boolean; +}; + +type SecretOption = { + name: string; + isSelectable: boolean; + disabledReason?: string; +}; + +type DynamicOption = { + name: string; + provider?: DynamicSecretProviders; + isSelectable: boolean; + disabledReason?: string; +}; + +const fieldLabelFor = (provider: DynamicSecretProviders | undefined, fieldName: string) => { + if (!provider) return fieldName; + return ( + DYNAMIC_SECRET_PROVIDER_OUTPUTS[provider].outputFields.find((f) => f.name === fieldName) + ?.label ?? fieldName + ); +}; + +// not `disabled`: Radix disables pointer events, which would kill the hover tooltip +const GreyedRow = ({ + icon, + label, + reason +}: { + icon: ReactNode; + label: string; + reason?: string; +}) => ( + + + e.preventDefault()} + > + {icon} + {label} + + + {reason && {reason}} + +); + +export const CredentialSourceFields = ({ + projectId, + environment, + secretPath, + value, + onChange, + isSecretError, + isFieldError +}: Props) => { + const { permission } = useProjectPermission(); + const { currentProject } = useProject(); + const { subscription } = useSubscription(); + const hasDynamicSecretPlan = Boolean(subscription?.dynamicSecret); + + const buildSecretOptions = useCallback( + (response: SecretV3RawResponse): SecretOption[] => { + const options: SecretOption[] = []; + const seen = new Set(); + const push = (secretKey: string, env: string, path: string, tags?: { slug: string }[]) => { + if (seen.has(secretKey)) return; + seen.add(secretKey); + const isReadable = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment: env, + secretPath: path, + secretName: secretKey, + secretTags: tags?.map((t) => t.slug) ?? [] + } + ); + options.push({ + name: secretKey, + isSelectable: isReadable, + disabledReason: isReadable + ? undefined + : "You need read access to this secret's value to use it in a proxied service." + }); + }; + + response.secrets.forEach((s) => push(s.secretKey, environment, secretPath, s.tags)); + const imports = response.imports ?? []; + for (let i = imports.length - 1; i >= 0; i -= 1) { + imports[i].secrets.forEach((s) => + push(s.secretKey, imports[i].environment, imports[i].secretPath, s.tags) + ); + } + return options; + }, + [permission, environment, secretPath] + ); + + // isFetching, not isPending: a disabled query stays pending forever and would pin the select loading + const { data: secretOptions = [], isFetching: isSecretsFetching } = useQuery({ + enabled: Boolean(projectId && environment), + staleTime: 0, + queryKey: secretKeys.getProjectSecret({ + projectId, + environment, + secretPath, + viewSecretValue: true + }), + queryFn: () => + fetchProjectSecrets({ projectId, environment, secretPath, viewSecretValue: true }), + select: buildSecretOptions + }); + + const { data: dynamicSecrets = [], isFetching: isDynamicFetching } = useGetDynamicSecrets({ + projectSlug: currentProject.slug, + environmentSlug: environment, + path: secretPath + }); + + const dynamicOptions = useMemo(() => { + return dynamicSecrets.map((ds) => { + const isBrokerable = Boolean(ds.type && BROKERABLE_DYNAMIC_SECRET_OUTPUTS[ds.type]); + const canLease = permission.can( + ProjectPermissionDynamicSecretActions.Lease, + subject(ProjectPermissionSub.DynamicSecrets, { + environment, + secretPath, + metadata: ds.metadata ?? [] + }) + ); + let disabledReason: string | undefined; + if (!isBrokerable) disabledReason = "This dynamic secret type can't be brokered over HTTP."; + else if (!hasDynamicSecretPlan) disabledReason = "Upgrade your plan to use dynamic secrets."; + else if (!canLease) + disabledReason = + "You need permission to lease this dynamic secret to use it in a proxied service."; + return { + name: ds.name, + provider: ds.type, + isSelectable: isBrokerable && hasDynamicSecretPlan && canLease, + disabledReason + }; + }); + }, [dynamicSecrets, permission, environment, secretPath, hasDynamicSecretPlan]); + + const selectedProvider = useMemo(() => { + if (!value.dynamicSecretName) return undefined; + return dynamicSecrets.find((ds) => ds.name === value.dynamicSecretName)?.type; + }, [value.dynamicSecretName, dynamicSecrets]); + + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const searchRef = useRef(null); + const query = search.trim().toLowerCase(); + + // Radix focuses the first menu item on open, not the search box; move focus to search after it settles. + useEffect(() => { + if (!open) return undefined; + const id = window.setTimeout(() => searchRef.current?.focus(), 0); + return () => window.clearTimeout(id); + }, [open]); + const filteredSecrets = useMemo( + () => + query ? secretOptions.filter((s) => s.name.toLowerCase().includes(query)) : secretOptions, + [secretOptions, query] + ); + const filteredDynamic = useMemo( + () => + query ? dynamicOptions.filter((d) => d.name.toLowerCase().includes(query)) : dynamicOptions, + [dynamicOptions, query] + ); + + const isLoading = isSecretsFetching || isDynamicFetching; + const hasSelection = Boolean(value.secretKey || value.dynamicSecretName); + const hasResults = filteredSecrets.length > 0 || filteredDynamic.length > 0; + const commit = (next: TCredentialSource) => onChange(next); + + const renderTriggerContent = () => { + if (value.dynamicSecretName) { + return ( + + + {value.dynamicSecretName} + {value.dynamicSecretField && ( + + + + {fieldLabelFor(selectedProvider, value.dynamicSecretField)} + + + )} + + ); + } + if (value.secretKey) { + return ( + + + {value.secretKey} + + ); + } + return Select a secret; + }; + + return ( +
+ { + setOpen(next); + if (!next) setSearch(""); + }} + > + + + + +
+ + setSearch(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + /> +
+ +
+ {hasSelection && !query && ( + <> + commit({})}> + Clear selection + + + + )} + + {isLoading && Loading…} + + {!isLoading && !hasResults && ( + + {secretOptions.length || dynamicOptions.length + ? "No matches" + : "No secrets available"} + + )} + + {filteredSecrets.length > 0 && Secrets} + {filteredSecrets.map((s) => + s.isSelectable ? ( + commit({ secretKey: s.name })}> + + {s.name} + + ) : ( + } + label={s.name} + reason={s.disabledReason} + /> + ) + )} + + {filteredDynamic.length > 0 && Dynamic Secrets} + {filteredDynamic.map((ds) => { + if (!ds.isSelectable) { + return ( + } + label={ds.name} + reason={ds.disabledReason} + /> + ); + } + + const fieldNames = ds.provider + ? (BROKERABLE_DYNAMIC_SECRET_OUTPUTS[ds.provider] ?? []) + : []; + const fields = fieldNames.map((name) => ({ + name, + label: fieldLabelFor(ds.provider, name) + })); + if (!fields.length) return null; + + return ( + + + + {ds.name} + + + {fields.map((f) => ( + + commit({ dynamicSecretName: ds.name, dynamicSecretField: f.name }) + } + > + {f.label} + + ))} + + + ); + })} +
+
+
+
+ ); +}; diff --git a/frontend/src/components/proxied-services/forms/DynamicSecretLeaseSettings.tsx b/frontend/src/components/proxied-services/forms/DynamicSecretLeaseSettings.tsx new file mode 100644 index 00000000000..3bc4bca7792 --- /dev/null +++ b/frontend/src/components/proxied-services/forms/DynamicSecretLeaseSettings.tsx @@ -0,0 +1,139 @@ +import { FingerprintIcon } from "lucide-react"; + +import { + CreatableSelect, + Field, + FieldContent, + FieldDescription, + FieldLabel, + Input +} from "@app/components/v3"; +import { useProject } from "@app/context"; +import { + DYNAMIC_SECRET_PROVIDER_OUTPUTS, + TProviderLeaseInput +} from "@app/hooks/api/dynamicSecret/providerOutputs"; +import { useGetDynamicSecrets } from "@app/hooks/api/dynamicSecret/queries"; + +import { TLeaseConfig } from "./schema"; + +type Props = { + environment: string; + secretPath: string; + referencedNames: string[]; + configs: Record; + onChange: (name: string, config: TLeaseConfig) => void; +}; + +const LeaseInput = ({ + input, + config, + onChange +}: { + input: TProviderLeaseInput; + config: TLeaseConfig; + onChange: (config: TLeaseConfig) => void; +}) => { + if (input.kind === "string[]") { + const principals = config.principals ?? []; + return ( + + {input.label} + + "Type a name and press Enter to add"} + formatCreateLabel={(raw) => `Add "${raw}"`} + value={principals.map((p) => ({ label: p, value: p }))} + onChange={(next) => { + const values = (Array.isArray(next) ? next : []).map( + (o) => (o as { value: string }).value + ); + onChange({ ...config, principals: values.length ? values : undefined }); + }} + onCreateOption={(raw) => { + const trimmed = raw.trim(); + if (!trimmed || principals.includes(trimmed)) return; + onChange({ ...config, principals: [...principals, trimmed] }); + }} + /> + {input.helperText} + + + ); + } + + return ( + + {input.label} + + onChange({ ...config, namespace: e.target.value || undefined })} + /> + {input.helperText} + + + ); +}; + +export const DynamicSecretLeaseSettings = ({ + environment, + secretPath, + referencedNames, + configs, + onChange +}: Props) => { + const { currentProject } = useProject(); + const { data: dynamicSecrets = [] } = useGetDynamicSecrets({ + projectSlug: currentProject.slug, + environmentSlug: environment, + path: secretPath + }); + + const providerByName = new Map(dynamicSecrets.map((ds) => [ds.name, ds.type])); + + const entries = referencedNames + .map((name) => { + const provider = providerByName.get(name); + return { + name, + leaseInputs: provider ? DYNAMIC_SECRET_PROVIDER_OUTPUTS[provider].leaseInputs : [] + }; + }) + .filter((entry) => entry.leaseInputs.length > 0); + + if (!entries.length) return null; + + return ( +
+
+

Dynamic Secret Settings

+

+ Some of these dynamic secrets need extra details before they can be used. +

+
+
+ {entries.map(({ name, leaseInputs }) => ( +
+
+ + {name} +
+ {leaseInputs.map((input) => ( + onChange(name, config)} + /> + ))} +
+ ))} +
+
+ ); +}; diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx index 405333ff837..fa919581c6b 100644 --- a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -36,11 +36,19 @@ import { } from "@app/hooks/api/proxiedServices/mutations"; import { TDashboardProxiedService, - TProxiedServiceCredentialInput + TProxiedServiceCredentialInput, + TProxiedServiceLeaseConfig } from "@app/hooks/api/proxiedServices/types"; -import { HeaderRewritingMode, proxiedServiceFormSchema, TProxiedServiceForm } from "./schema"; -import { SecretSelect } from "./SecretSelect"; +import { CredentialSourceFields, TCredentialSource } from "./CredentialSourceFields"; +import { DynamicSecretLeaseSettings } from "./DynamicSecretLeaseSettings"; +import { + HeaderRewritingMode, + proxiedServiceFormSchema, + TCredentialSourceForm, + TLeaseConfig, + TProxiedServiceForm +} from "./schema"; import { SurfaceSelect } from "./SurfaceSelect"; type Props = { @@ -55,6 +63,62 @@ type Props = { const genPlaceholder = () => `placeholder_${Array.from({ length: 12 }, () => "abcdefghijklmnopqrstuvwxyz"[Math.floor(Math.random() * 26)]).join("")}`; +const emptySource = (): TCredentialSourceForm => ({ + secretKey: "", + dynamicSecretName: "", + dynamicSecretField: "" +}); + +const toSource = (c: TDashboardProxiedService["credentials"][number]): TCredentialSourceForm => + c.dynamicSecretName + ? { + secretKey: "", + dynamicSecretName: c.dynamicSecretName, + dynamicSecretField: c.dynamicSecretField ?? "" + } + : { + secretKey: c.secretKey ?? "", + dynamicSecretName: "", + dynamicSecretField: "" + }; + +const hasSource = (src: TCredentialSourceForm) => Boolean(src.secretKey || src.dynamicSecretName); + +const toLeaseConfig = (leaseConfig?: TLeaseConfig): TProxiedServiceLeaseConfig | undefined => { + if (!leaseConfig) return undefined; + const cfg: TProxiedServiceLeaseConfig = {}; + if (leaseConfig.namespace) cfg.namespace = leaseConfig.namespace; + if (leaseConfig.principals?.length) cfg.principals = leaseConfig.principals; + return Object.keys(cfg).length ? cfg : undefined; +}; + +const sourceToInput = ( + src: TCredentialSourceForm, + configs: Record +): Pick< + TProxiedServiceCredentialInput, + "secretKey" | "dynamicSecretName" | "dynamicSecretField" | "dynamicSecretConfig" +> => + src.dynamicSecretName + ? { + dynamicSecretName: src.dynamicSecretName, + dynamicSecretField: src.dynamicSecretField, + dynamicSecretConfig: toLeaseConfig(configs[src.dynamicSecretName]) + } + : { secretKey: src.secretKey }; + +const toDynamicSecretConfigs = (svc: TDashboardProxiedService): Record => { + const configs: Record = {}; + svc.credentials.forEach((c) => { + if (!c.dynamicSecretName || configs[c.dynamicSecretName]) return; + const cfg = c.dynamicSecretConfig; + if (cfg?.namespace || cfg?.principals?.length) { + configs[c.dynamicSecretName] = { namespace: cfg.namespace, principals: cfg.principals }; + } + }); + return configs; +}; + const toDefaultValues = (svc?: TDashboardProxiedService): TProxiedServiceForm => { if (!svc) { return { @@ -62,8 +126,10 @@ const toDefaultValues = (svc?: TDashboardProxiedService): TProxiedServiceForm => hostPattern: "", isEnabled: true, headerMode: HeaderRewritingMode.Headers, - headers: [{ secretKey: "", headerName: "Authorization", headerPrefix: "Bearer" }], - substitutions: [] + headers: [{ ...emptySource(), headerName: "Authorization", headerPrefix: "Bearer" }], + basicAuth: { username: emptySource(), password: emptySource() }, + substitutions: [], + dynamicSecretConfigs: {} }; } @@ -89,39 +155,39 @@ const toDefaultValues = (svc?: TDashboardProxiedService): TProxiedServiceForm => headers: isBasicAuth ? [] : headerCreds.map((c) => ({ - secretKey: c.secretKey, + ...toSource(c), headerName: c.headerName ?? "", headerPrefix: c.headerPrefix ?? "" })), - basicAuth: isBasicAuth - ? { - usernameSecretKey: username?.secretKey ?? "", - passwordSecretKey: password?.secretKey ?? "" - } - : undefined, + basicAuth: { + username: username ? toSource(username) : emptySource(), + password: password ? toSource(password) : emptySource() + }, substitutions: subs.map((c) => ({ + ...toSource(c), placeholderKey: c.placeholderKey ?? "", placeholderValue: c.placeholderValue ?? genPlaceholder(), - secretKey: c.secretKey, surfaces: (c.substitutionSurfaces ?? []) as ProxiedServiceSubstitutionSurface[] - })) + })), + dynamicSecretConfigs: toDynamicSecretConfigs(svc) }; }; const toCredentials = (form: TProxiedServiceForm): TProxiedServiceCredentialInput[] => { const credentials: TProxiedServiceCredentialInput[] = []; + const configs = form.dynamicSecretConfigs ?? {}; if (form.headerMode === HeaderRewritingMode.BasicAuth) { - if (form.basicAuth?.usernameSecretKey) { + if (form.basicAuth && hasSource(form.basicAuth.username)) { credentials.push({ - secretKey: form.basicAuth.usernameSecretKey, + ...sourceToInput(form.basicAuth.username, configs), role: ProxiedServiceCredentialRole.HeaderRewrite, headerPurpose: ProxiedServiceHeaderPurpose.Username }); } - if (form.basicAuth?.passwordSecretKey) { + if (form.basicAuth && hasSource(form.basicAuth.password)) { credentials.push({ - secretKey: form.basicAuth.passwordSecretKey, + ...sourceToInput(form.basicAuth.password, configs), role: ProxiedServiceCredentialRole.HeaderRewrite, headerPurpose: ProxiedServiceHeaderPurpose.Password }); @@ -129,7 +195,7 @@ const toCredentials = (form: TProxiedServiceForm): TProxiedServiceCredentialInpu } else { form.headers.forEach((h) => { credentials.push({ - secretKey: h.secretKey, + ...sourceToInput(h, configs), role: ProxiedServiceCredentialRole.HeaderRewrite, headerName: h.headerName, // omit rather than send null: the API field is optional and rejects null @@ -140,7 +206,7 @@ const toCredentials = (form: TProxiedServiceForm): TProxiedServiceCredentialInpu form.substitutions.forEach((s) => { credentials.push({ - secretKey: s.secretKey, + ...sourceToInput(s, configs), role: ProxiedServiceCredentialRole.CredentialSubstitution, placeholderKey: s.placeholderKey, placeholderValue: s.placeholderValue, @@ -176,10 +242,50 @@ export const ProxiedServiceForm = ({ }); const headerMode = watch("headerMode"); + const watchedHeaders = watch("headers"); + const watchedBasicAuth = watch("basicAuth"); + const watchedSubstitutions = watch("substitutions"); + const watchedConfigs = watch("dynamicSecretConfigs"); const headerFields = useFieldArray({ control, name: "headers" }); const substitutionFields = useFieldArray({ control, name: "substitutions" }); + const setHeaderSource = (i: number, v: TCredentialSource) => { + setValue(`headers.${i}.secretKey`, v.secretKey ?? ""); + setValue(`headers.${i}.dynamicSecretName`, v.dynamicSecretName ?? ""); + setValue(`headers.${i}.dynamicSecretField`, v.dynamicSecretField ?? ""); + }; + const setSubstitutionSource = (i: number, v: TCredentialSource) => { + setValue(`substitutions.${i}.secretKey`, v.secretKey ?? ""); + setValue(`substitutions.${i}.dynamicSecretName`, v.dynamicSecretName ?? ""); + setValue(`substitutions.${i}.dynamicSecretField`, v.dynamicSecretField ?? ""); + }; + const setBasicAuthSource = (which: "username" | "password", v: TCredentialSource) => { + setValue(`basicAuth.${which}.secretKey`, v.secretKey ?? ""); + setValue(`basicAuth.${which}.dynamicSecretName`, v.dynamicSecretName ?? ""); + setValue(`basicAuth.${which}.dynamicSecretField`, v.dynamicSecretField ?? ""); + }; + + // computed inline, not memoized: react-hook-form mutates watched objects in place so a useMemo would never recompute + const referencedDynamicSecretNames = (() => { + const names = new Set(); + const add = (name?: string) => { + if (name) names.add(name); + }; + if (headerMode === HeaderRewritingMode.BasicAuth) { + add(watchedBasicAuth?.username?.dynamicSecretName); + add(watchedBasicAuth?.password?.dynamicSecretName); + } else { + (watchedHeaders ?? []).forEach((h) => add(h?.dynamicSecretName)); + } + (watchedSubstitutions ?? []).forEach((s) => add(s?.dynamicSecretName)); + return [...names]; + })(); + + const setDynamicSecretConfig = (name: string, config: TLeaseConfig) => { + setValue("dynamicSecretConfigs", { ...(watchedConfigs ?? {}), [name]: config }); + }; + // The "at least one credential" issue is attached to the headers array node by the schema. const headersArrayError = errors.headers as | { message?: string; root?: { message?: string } } @@ -335,21 +441,25 @@ export const ProxiedServiceForm = ({ {i === 0 && Value} - ( - - )} + setHeaderSource(i, v)} + isSecretError={Boolean(errors.headers?.[i]?.secretKey)} + isFieldError={Boolean(errors.headers?.[i]?.dynamicSecretField)} + /> + - - headerFields.append({ secretKey: "", headerName: "", headerPrefix: "" }) + headerFields.append({ ...emptySource(), headerName: "", headerPrefix: "" }) } > @@ -389,21 +499,25 @@ export const ProxiedServiceForm = ({ Username - ( - - )} + setBasicAuthSource("username", v)} + isSecretError={Boolean(errors.basicAuth?.username?.secretKey)} + isFieldError={Boolean(errors.basicAuth?.username?.dynamicSecretField)} + /> + - @@ -412,21 +526,25 @@ export const ProxiedServiceForm = ({ (optional) - ( - - )} + setBasicAuthSource("password", v)} + isSecretError={Boolean(errors.basicAuth?.password?.secretKey)} + isFieldError={Boolean(errors.basicAuth?.password?.dynamicSecretField)} + /> + - @@ -527,21 +645,25 @@ export const ProxiedServiceForm = ({
with value of
- ( - - )} + setSubstitutionSource(i, v)} + isSecretError={Boolean(errors.substitutions?.[i]?.secretKey)} + isFieldError={Boolean(errors.substitutions?.[i]?.dynamicSecretField)} + /> + -
@@ -554,9 +676,9 @@ export const ProxiedServiceForm = ({ type="button" onClick={() => substitutionFields.append({ + ...emptySource(), placeholderKey: "", placeholderValue: genPlaceholder(), - secretKey: "", surfaces: [] }) } @@ -566,6 +688,14 @@ export const ProxiedServiceForm = ({ + + diff --git a/frontend/src/components/proxied-services/forms/SecretSelect.tsx b/frontend/src/components/proxied-services/forms/SecretSelect.tsx deleted file mode 100644 index c8a48412f8a..00000000000 --- a/frontend/src/components/proxied-services/forms/SecretSelect.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useCallback } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { KeyIcon, LockIcon } from "lucide-react"; - -import { FilterableSelect, Tooltip, TooltipContent, TooltipTrigger } from "@app/components/v3"; -import { useProjectPermission } from "@app/context"; -import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; -import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries"; -import { SecretV3RawResponse } from "@app/hooks/api/secrets/types"; -import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; - -type Props = { - projectId: string; - environment: string; - secretPath: string; - value?: string; - onChange: (value: string) => void; - isDisabled?: boolean; - isError?: boolean; - placeholder?: string; -}; - -// Attaching a secret requires ReadValue on the backend; describe-only secrets show disabled. -// Imported secrets are selectable too (the backend resolves them through the folder's imports). -type SecretOption = { label: string; value: string; isReadable: boolean }; - -const formatSecretOption = (option: SecretOption) => { - const row = ( -
- {option.isReadable ? ( - - ) : ( - - )} - {option.label} -
- ); - - if (option.isReadable) return row; - return ( - - {row} - - You need read access to this secret's value to use it in a proxied service. - - - ); -}; - -export const SecretSelect = ({ - projectId, - environment, - secretPath, - value, - onChange, - isDisabled, - isError, - placeholder = "Select a secret" -}: Props) => { - const { permission } = useProjectPermission(); - - const buildOptions = useCallback( - (response: SecretV3RawResponse) => { - const options: SecretOption[] = []; - const seen = new Set(); - - response.secrets.forEach((secret) => { - if (seen.has(secret.secretKey)) return; - seen.add(secret.secretKey); - options.push({ - label: secret.secretKey, - value: secret.secretKey, - isReadable: hasSecretReadValueOrDescribePermission( - permission, - ProjectPermissionSecretActions.ReadValue, - { - environment, - secretPath, - secretName: secret.secretKey, - secretTags: secret.tags?.map((tag) => tag.slug) ?? [] - } - ) - }); - }); - - // folder-local keys win over imported ones; among imports, later imports take priority - const imports = response.imports ?? []; - for (let i = imports.length - 1; i >= 0; i -= 1) { - const group = imports[i]; - group.secrets.forEach((secret) => { - if (seen.has(secret.secretKey)) return; - seen.add(secret.secretKey); - options.push({ - label: secret.secretKey, - value: secret.secretKey, - isReadable: hasSecretReadValueOrDescribePermission( - permission, - ProjectPermissionSecretActions.ReadValue, - { - environment: group.environment, - secretPath: group.secretPath, - secretName: secret.secretKey, - secretTags: secret.tags?.map((tag) => tag.slug) ?? [] - } - ) - }); - }); - } - - return options; - }, - [permission, environment, secretPath] - ); - - const { data: options = [], isPending } = useQuery({ - enabled: Boolean(projectId && environment), - staleTime: 0, - queryKey: secretKeys.getProjectSecret({ - projectId, - environment, - secretPath, - viewSecretValue: false - }), - queryFn: () => - fetchProjectSecrets({ projectId, environment, secretPath, viewSecretValue: false }), - select: buildOptions - }); - - // a stale/selected key not in the fetched list stays visible and selectable so the reference persists - const selected = - options.find((option) => option.value === value) ?? - (value ? { label: value, value, isReadable: true } : null); - - return ( - !(option as SecretOption).isReadable} - onChange={(newValue) => onChange((newValue as SecretOption | null)?.value ?? "")} - getOptionLabel={(option) => option.label} - getOptionValue={(option) => option.value} - formatOptionLabel={formatSecretOption} - /> - ); -}; diff --git a/frontend/src/components/proxied-services/forms/brokerableDynamicSecrets.ts b/frontend/src/components/proxied-services/forms/brokerableDynamicSecrets.ts new file mode 100644 index 00000000000..4525df040b4 --- /dev/null +++ b/frontend/src/components/proxied-services/forms/brokerableDynamicSecrets.ts @@ -0,0 +1,20 @@ +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; + +// Dynamic-secret providers a proxied service can broker over HTTP, mapped to the eligible output fields. +// A provider not listed can't be brokered; a field not listed is hidden (e.g. metadata like EXPIRES_AT). +// keep in sync with backend/src/ee/services/proxied-service/proxied-service-brokerable-outputs.ts +export const BROKERABLE_DYNAMIC_SECRET_OUTPUTS: Partial> = + { + [DynamicSecretProviders.Kubernetes]: ["TOKEN"], + [DynamicSecretProviders.Github]: ["TOKEN"], + [DynamicSecretProviders.GcpIam]: ["TOKEN"], + [DynamicSecretProviders.IbmApiConnect]: ["CLIENT_ID", "CLIENT_SECRET"], + [DynamicSecretProviders.ElasticSearch]: ["DB_USERNAME", "DB_PASSWORD"], + [DynamicSecretProviders.Clickhouse]: ["DB_USERNAME", "DB_PASSWORD"], + [DynamicSecretProviders.Couchbase]: ["username", "password"], + [DynamicSecretProviders.RabbitMq]: ["DB_USERNAME", "DB_PASSWORD"], + [DynamicSecretProviders.Snowflake]: ["DB_USERNAME", "DB_PASSWORD"], + [DynamicSecretProviders.Milvus]: ["DB_USERNAME", "DB_PASSWORD"], + [DynamicSecretProviders.AzureEntraId]: ["email", "password"], + [DynamicSecretProviders.Totp]: ["TOTP"] + }; diff --git a/frontend/src/components/proxied-services/forms/schema.ts b/frontend/src/components/proxied-services/forms/schema.ts index d8fbae94a6a..e847e97b062 100644 --- a/frontend/src/components/proxied-services/forms/schema.ts +++ b/frontend/src/components/proxied-services/forms/schema.ts @@ -52,29 +52,65 @@ const hostPatternField = z }); }); -// Required-ness is enforced conditionally in the top-level superRefine so fields of the inactive header mode don't block submission. -const headerCredentialSchema = z.object({ - secretKey: z.string().trim(), +const leaseConfigSchema = z.object({ + namespace: z.string().trim().optional(), + principals: z.array(z.string().trim().min(1)).optional() +}); + +export type TLeaseConfig = z.infer; + +const credentialSourceSchema = z.object({ + secretKey: z.string().trim().default(""), + dynamicSecretName: z.string().trim().default(""), + dynamicSecretField: z.string().trim().default("") +}); + +export type TCredentialSourceForm = z.infer; + +const headerCredentialSchema = credentialSourceSchema.extend({ headerName: z.string().trim(), headerPrefix: z.string().trim().optional() }); const basicAuthSchema = z.object({ - usernameSecretKey: z.string().trim(), - passwordSecretKey: z.string().trim().optional() + username: credentialSourceSchema, + password: credentialSourceSchema }); -const substitutionSchema = z.object({ +const substitutionSchema = credentialSourceSchema.extend({ placeholderKey: z .string() .trim() .min(1, "Environment variable name is required") .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "Must be a valid environment variable name"), placeholderValue: z.string().trim().min(1), - secretKey: z.string().trim().min(1, "Select a secret"), surfaces: z.array(z.nativeEnum(ProxiedServiceSubstitutionSurface)).min(1, "Select at least one") }); +const refineCredentialSource = ( + row: TCredentialSourceForm, + ctx: z.RefinementCtx, + path: (string | number)[] +) => { + const hasStatic = Boolean(row.secretKey); + const hasDynamic = Boolean(row.dynamicSecretName); + if (hasStatic === hasDynamic) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Select a secret", + path: [...path, "secretKey"] + }); + return; + } + if (hasDynamic && !row.dynamicSecretField) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Select an output field", + path: [...path, "dynamicSecretField"] + }); + } +}; + export enum HeaderRewritingMode { Headers = "headers", BasicAuth = "basic-auth" @@ -93,19 +129,14 @@ export const proxiedServiceFormSchema = z headerMode: z.nativeEnum(HeaderRewritingMode).default(HeaderRewritingMode.Headers), headers: z.array(headerCredentialSchema).default([]), basicAuth: basicAuthSchema.optional(), - substitutions: z.array(substitutionSchema).default([]) + substitutions: z.array(substitutionSchema).default([]), + dynamicSecretConfigs: z.record(z.string(), leaseConfigSchema).default({}) }) .superRefine((form, ctx) => { if (form.headerMode === HeaderRewritingMode.Headers) { const seenHeaderNames = new Map(); form.headers.forEach((row, i) => { - if (!row.secretKey) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Select a secret", - path: ["headers", i, "secretKey"] - }); - } + refineCredentialSource(row, ctx, ["headers", i]); if (!row.headerName) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -131,14 +162,17 @@ export const proxiedServiceFormSchema = z path: ["headers"] }); } - } else if (!form.basicAuth?.usernameSecretKey) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Select a secret", - path: ["basicAuth", "usernameSecretKey"] - }); + } else if (form.basicAuth) { + refineCredentialSource(form.basicAuth.username, ctx, ["basicAuth", "username"]); + if (form.basicAuth.password.secretKey || form.basicAuth.password.dynamicSecretName) { + refineCredentialSource(form.basicAuth.password, ctx, ["basicAuth", "password"]); + } } + form.substitutions.forEach((row, i) => { + refineCredentialSource(row, ctx, ["substitutions", i]); + }); + const seenPlaceholderKeys = new Map(); form.substitutions.forEach((row, i) => { const key = row.placeholderKey.trim(); diff --git a/frontend/src/hooks/api/dynamicSecret/providerOutputs.ts b/frontend/src/hooks/api/dynamicSecret/providerOutputs.ts new file mode 100644 index 00000000000..e3ff77faf72 --- /dev/null +++ b/frontend/src/hooks/api/dynamicSecret/providerOutputs.ts @@ -0,0 +1,158 @@ +import { DynamicSecretProviders } from "./types"; + +// keep field names in sync with the backend registry at backend/src/ee/services/dynamic-secret/providers/dynamic-secret-provider-outputs.ts + +export type TProviderOutputField = { + name: string; + label: string; +}; + +export type TProviderLeaseInput = + | { name: "namespace"; kind: "string"; label: string; helperText: string } + | { name: "principals"; kind: "string[]"; label: string; helperText: string }; + +export type TProviderOutputEntry = { + outputFields: TProviderOutputField[]; + leaseInputs: TProviderLeaseInput[]; + extraNote?: string; +}; + +const dbUserPass: TProviderOutputField[] = [ + { name: "DB_USERNAME", label: "Database User" }, + { name: "DB_PASSWORD", label: "Database Password" } +]; + +const usernamePassword: TProviderOutputField[] = [ + { name: "DB_USERNAME", label: "Username" }, + { name: "DB_PASSWORD", label: "Password" } +]; + +const kubernetesLeaseInputs: TProviderLeaseInput[] = [ + { + name: "namespace", + kind: "string", + label: "Namespace", + helperText: "Kubernetes namespace to use. Optional." + } +]; + +const sshLeaseInputs: TProviderLeaseInput[] = [ + { + name: "principals", + kind: "string[]", + label: "Principals", + helperText: "Usernames to include in the certificate." + } +]; + +export const DYNAMIC_SECRET_PROVIDER_OUTPUTS: Record = + { + [DynamicSecretProviders.SqlDatabase]: { outputFields: dbUserPass, leaseInputs: [] }, + [DynamicSecretProviders.Cassandra]: { outputFields: dbUserPass, leaseInputs: [] }, + [DynamicSecretProviders.AwsIam]: { + outputFields: [ + { name: "ACCESS_KEY", label: "AWS IAM Access Key" }, + { name: "SECRET_ACCESS_KEY", label: "AWS IAM Secret Key" }, + { name: "SESSION_TOKEN", label: "AWS IAM Session Token" }, + { name: "USERNAME", label: "AWS IAM Username" } + ], + leaseInputs: [] + }, + [DynamicSecretProviders.Redis]: { + outputFields: [ + { name: "DB_USERNAME", label: "Redis Username" }, + { name: "DB_PASSWORD", label: "Redis Password" } + ], + leaseInputs: [] + }, + [DynamicSecretProviders.AwsElastiCache]: { + outputFields: [ + { name: "DB_USERNAME", label: "Cluster Username" }, + { name: "DB_PASSWORD", label: "Cluster Password" } + ], + leaseInputs: [], + extraNote: "It may take a few minutes before the credentials are available for use." + }, + [DynamicSecretProviders.AwsMemoryDb]: { + outputFields: [ + { name: "DB_USERNAME", label: "Cluster Username" }, + { name: "DB_PASSWORD", label: "Cluster Password" } + ], + leaseInputs: [], + extraNote: "It may take a few minutes before the credentials are available for use." + }, + [DynamicSecretProviders.MongoAtlas]: { outputFields: dbUserPass, leaseInputs: [] }, + [DynamicSecretProviders.ElasticSearch]: { outputFields: usernamePassword, leaseInputs: [] }, + [DynamicSecretProviders.MongoDB]: { outputFields: dbUserPass, leaseInputs: [] }, + [DynamicSecretProviders.RabbitMq]: { outputFields: usernamePassword, leaseInputs: [] }, + [DynamicSecretProviders.AzureEntraId]: { + outputFields: [ + { name: "email", label: "Email" }, + { name: "password", label: "Password" } + ], + leaseInputs: [] + }, + [DynamicSecretProviders.AzureSqlDatabase]: { outputFields: dbUserPass, leaseInputs: [] }, + [DynamicSecretProviders.Ldap]: { + outputFields: [ + { name: "USERNAME", label: "Username" }, + { name: "PASSWORD", label: "Password" }, + { name: "DN_ARRAY", label: "DNs" } + ], + leaseInputs: [] + }, + [DynamicSecretProviders.SapHana]: { outputFields: usernamePassword, leaseInputs: [] }, + [DynamicSecretProviders.Snowflake]: { outputFields: usernamePassword, leaseInputs: [] }, + [DynamicSecretProviders.Totp]: { + outputFields: [ + { name: "TOTP", label: "Time-based one-time password" }, + { name: "TIME_REMAINING", label: "Time Remaining" } + ], + leaseInputs: [] + }, + [DynamicSecretProviders.SapAse]: { outputFields: dbUserPass, leaseInputs: [] }, + [DynamicSecretProviders.Kubernetes]: { + outputFields: [{ name: "TOKEN", label: "Service Account JWT" }], + leaseInputs: kubernetesLeaseInputs + }, + [DynamicSecretProviders.Vertica]: { outputFields: dbUserPass, leaseInputs: [] }, + [DynamicSecretProviders.GcpIam]: { + outputFields: [ + { name: "TOKEN", label: "Token" }, + { name: "SERVICE_ACCOUNT_EMAIL", label: "Service Account Email" } + ], + leaseInputs: [] + }, + [DynamicSecretProviders.Github]: { + outputFields: [ + { name: "TOKEN", label: "Token" }, + { name: "EXPIRES_AT", label: "Expires At" }, + { name: "PERMISSIONS", label: "Permissions" }, + { name: "REPOSITORY_SELECTION", label: "Repository Selection" } + ], + leaseInputs: [] + }, + [DynamicSecretProviders.Couchbase]: { + outputFields: [ + { name: "username", label: "Username" }, + { name: "password", label: "Password" } + ], + leaseInputs: [] + }, + [DynamicSecretProviders.Clickhouse]: { outputFields: dbUserPass, leaseInputs: [] }, + [DynamicSecretProviders.Milvus]: { outputFields: dbUserPass, leaseInputs: [] }, + [DynamicSecretProviders.Ssh]: { + outputFields: [ + { name: "PRIVATE_KEY", label: "Private Key" }, + { name: "SIGNED_KEY", label: "Signed Certificate" } + ], + leaseInputs: sshLeaseInputs + }, + [DynamicSecretProviders.IbmApiConnect]: { + outputFields: [ + { name: "CLIENT_ID", label: "Client ID" }, + { name: "CLIENT_SECRET", label: "Client Secret" } + ], + leaseInputs: [] + } + }; diff --git a/frontend/src/hooks/api/proxiedServices/types.ts b/frontend/src/hooks/api/proxiedServices/types.ts index be26cdddc7a..2e94715f25e 100644 --- a/frontend/src/hooks/api/proxiedServices/types.ts +++ b/frontend/src/hooks/api/proxiedServices/types.ts @@ -4,10 +4,15 @@ import { ProxiedServiceSubstitutionSurface } from "./enums"; +export type TProxiedServiceLeaseConfig = { + namespace?: string; + principals?: string[]; +}; + export type TProxiedServiceCredential = { id: string; serviceId: string; - secretKey: string; + secretKey?: string | null; role: ProxiedServiceCredentialRole; headerName?: string | null; headerPrefix?: string | null; @@ -15,6 +20,10 @@ export type TProxiedServiceCredential = { placeholderKey?: string | null; placeholderValue?: string | null; substitutionSurfaces?: ProxiedServiceSubstitutionSurface[] | null; + dynamicSecretName?: string | null; + dynamicSecretField?: string | null; + dynamicSecretConfig?: TProxiedServiceLeaseConfig | null; + callerCanLease?: boolean; }; export type TProxiedServiceBase = { @@ -43,7 +52,7 @@ export type TDashboardProxiedService = TProxiedService & { }; export type TProxiedServiceCredentialInput = { - secretKey: string; + secretKey?: string; role: ProxiedServiceCredentialRole; headerName?: string | null; headerPrefix?: string | null; @@ -51,6 +60,9 @@ export type TProxiedServiceCredentialInput = { placeholderKey?: string | null; placeholderValue?: string | null; substitutionSurfaces?: ProxiedServiceSubstitutionSurface[] | null; + dynamicSecretName?: string; + dynamicSecretField?: string; + dynamicSecretConfig?: TProxiedServiceLeaseConfig; }; export type TCreateProxiedServiceDTO = { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx index 363006963cf..e551ebd41ed 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx @@ -23,6 +23,7 @@ import { } from "@app/components/v2"; import { useTimedReset, useToggle } from "@app/hooks"; import { useCreateDynamicSecretLease } from "@app/hooks/api"; +import { DYNAMIC_SECRET_PROVIDER_OUTPUTS } from "@app/hooks/api/dynamicSecret/providerOutputs"; import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; const OutputDisplay = ({ @@ -130,228 +131,16 @@ const TotpOutputDisplay = ({ ); }; +const COPY_NOTE = + "Important: Copy these credentials now. You will not be able to see them again after you close the modal."; + const renderOutputForm = ( provider: DynamicSecretProviders, data: unknown, triggerLeaseRegeneration: (details: { ttl?: string }) => Promise ) => { - if ( - provider === DynamicSecretProviders.SqlDatabase || - provider === DynamicSecretProviders.Cassandra || - provider === DynamicSecretProviders.MongoAtlas || - provider === DynamicSecretProviders.MongoDB || - provider === DynamicSecretProviders.Vertica || - provider === DynamicSecretProviders.SapAse || - provider === DynamicSecretProviders.AzureSqlDatabase || - provider === DynamicSecretProviders.Clickhouse || - provider === DynamicSecretProviders.Milvus - ) { - const { DB_PASSWORD, DB_USERNAME } = data as { DB_USERNAME: string; DB_PASSWORD: string }; - return ( -
- - -
- ); - } - - if (provider === DynamicSecretProviders.AwsIam) { - const { USERNAME, ACCESS_KEY, SECRET_ACCESS_KEY, SESSION_TOKEN } = data as { - ACCESS_KEY: string; - SECRET_ACCESS_KEY: string; - USERNAME?: string; - SESSION_TOKEN?: string; - }; - return ( -
- {USERNAME && } - - - {SESSION_TOKEN && } -
- Important: Copy these credentials now. You will not be able to see them again after you - close the modal. -
-
- ); - } - - if (provider === DynamicSecretProviders.Redis) { - const { DB_USERNAME, DB_PASSWORD } = data as { - DB_USERNAME: string; - DB_PASSWORD: string; - }; - - return ( -
- - -
- ); - } - - if ( - provider === DynamicSecretProviders.AwsElastiCache || - provider === DynamicSecretProviders.AwsMemoryDb - ) { - const { DB_USERNAME, DB_PASSWORD } = data as { - DB_USERNAME: string; - DB_PASSWORD: string; - }; - - return ( -
- - -

- Important: Copy these credentials now. You will not be able to see them again after - you close the modal. -

-

- Please note that it may take a few minutes before the credentials are available for - use. -

-
- } - /> - - ); - } - - if (provider === DynamicSecretProviders.RabbitMq) { - const { DB_USERNAME, DB_PASSWORD } = data as { - DB_USERNAME: string; - DB_PASSWORD: string; - }; - - return ( -
- - -
- ); - } - - if (provider === DynamicSecretProviders.ElasticSearch) { - const { DB_USERNAME, DB_PASSWORD } = data as { - DB_USERNAME: string; - DB_PASSWORD: string; - }; - - return ( -
- - -
- ); - } - - if (provider === DynamicSecretProviders.AzureEntraId) { - const { email, password } = data as { - email: string; - password: string; - }; - - return ( -
- - -
- ); - } - - if (provider === DynamicSecretProviders.Ldap) { - const { USERNAME, PASSWORD, DN_ARRAY } = data as { - USERNAME: string; - PASSWORD: string; - DN_ARRAY: string[]; - }; - - return ( -
- - - - - -
- ); - } - - if ( - provider === DynamicSecretProviders.SapHana || - provider === DynamicSecretProviders.Snowflake - ) { - const { DB_USERNAME, DB_PASSWORD } = data as { - DB_USERNAME: string; - DB_PASSWORD: string; - }; - - return ( -
- - -
- ); - } - - if (provider === DynamicSecretProviders.Kubernetes) { - const { TOKEN } = data as { TOKEN: string }; - - return ( -
- -
- ); - } - if (provider === DynamicSecretProviders.Totp) { - const { TOTP, TIME_REMAINING } = data as { - TOTP: string; - TIME_REMAINING: number; - }; - + const { TOTP, TIME_REMAINING } = data as { TOTP: string; TIME_REMAINING: number }; return ( - - - - ); - } - - if (provider === DynamicSecretProviders.Github) { - const { TOKEN } = data as { - TOKEN: string; - }; - - return ( -
- -
- ); - } - - if (provider === DynamicSecretProviders.Couchbase) { - const { username, password } = data as { - username: string; - password: string; - }; - - return ( -
- - -
- ); - } - - if (provider === DynamicSecretProviders.IbmApiConnect) { - const { CLIENT_ID, CLIENT_SECRET } = data as { - CLIENT_ID: string; - CLIENT_SECRET: string; - }; + const entry = DYNAMIC_SECRET_PROVIDER_OUTPUTS[provider]; + const record = (data ?? {}) as Record; + const presentFields = entry.outputFields.filter( + (f) => record[f.name] !== undefined && record[f.name] !== null + ); - return ( -
- - -
- ); - } + if (!presentFields.length) return null; - return null; + return ( +
+ {presentFields.map((f, idx) => { + const isLast = idx === presentFields.length - 1; + let helperText: ReactNode; + if (isLast) { + helperText = entry.extraNote ? ( +
+

{COPY_NOTE}

+

{entry.extraNote}

+
+ ) : ( + COPY_NOTE + ); + } + const raw = record[f.name]; + const value = typeof raw === "string" ? raw : JSON.stringify(raw); + return ; + })} +
+ ); }; const kubernetesFormSchema = z.object({