From bb1c601c7e03b4f0070da72ab147ef23d81fe9d6 Mon Sep 17 00:00:00 2001 From: Saif Date: Thu, 9 Jul 2026 23:30:51 +0530 Subject: [PATCH 01/43] feat: secrets brokering (proxied services + agent proxy CA) Backend + frontend for the Secrets Brokering feature: proxied service CRUD, org-level agent proxy root CA, ProxiedService CASL subject with Agent/Agent Proxy built-in roles, license gating, and dashboard/overview integration. --- backend/src/@types/fastify.d.ts | 4 + backend/src/@types/knex.d.ts | 24 + .../20260709120000_create-proxied-services.ts | 87 ++++ backend/src/db/schemas/index.ts | 3 + backend/src/db/schemas/models.ts | 10 +- .../src/db/schemas/org-agent-proxy-config.ts | 27 + .../db/schemas/proxied-service-credentials.ts | 29 ++ backend/src/db/schemas/proxied-services.ts | 22 + .../src/ee/routes/v1/agent-proxy-ca-router.ts | 51 ++ backend/src/ee/routes/v1/index.ts | 5 + .../ee/routes/v1/proxied-service-router.ts | 202 ++++++++ .../agent-proxy-ca/agent-proxy-ca-service.ts | 213 ++++++++ .../org-agent-proxy-config-dal.ts | 10 + .../src/ee/services/license/licence-enums.ts | 3 +- .../src/ee/services/license/license-fns.ts | 3 +- .../src/ee/services/license/license-types.ts | 1 + .../ee/services/permission/default-roles.ts | 42 ++ .../services/permission/permission-service.ts | 6 + .../services/permission/project-permission.ts | 48 ++ .../proxied-service-credential-dal.ts | 29 ++ .../proxied-service/proxied-service-dal.ts | 141 +++++ .../proxied-service/proxied-service-enums.ts | 16 + .../proxied-service-service.ts | 481 ++++++++++++++++++ .../proxied-service/proxied-service-types.ts | 75 +++ backend/src/keystore/keystore.ts | 3 +- backend/src/lib/api-docs/constants.ts | 4 +- backend/src/server/routes/index.ts | 27 + backend/src/server/routes/sanitizedSchemas.ts | 33 ++ .../src/server/routes/v1/dashboard-router.ts | 112 +++- .../dual-read/feature-mapping/pki-security.ts | 5 + .../services/project-role/project-role-fns.ts | 24 + .../role/project/project-role-factory.ts | 24 + .../CreateProxiedServiceModal.tsx | 41 ++ .../DeleteProxiedServiceModal.tsx | 72 +++ .../EditProxiedServiceModal.tsx | 41 ++ .../forms/ProxiedServiceForm.tsx | 458 +++++++++++++++++ .../proxied-services/forms/SecretSelect.tsx | 49 ++ .../proxied-services/forms/SurfaceSelect.tsx | 74 +++ .../proxied-services/forms/index.ts | 1 + .../proxied-services/forms/schema.ts | 94 ++++ .../src/components/proxied-services/index.ts | 3 + .../ProjectPermissionContext/index.tsx | 1 + .../context/ProjectPermissionContext/types.ts | 22 + frontend/src/hooks/api/dashboard/queries.tsx | 6 + frontend/src/hooks/api/dashboard/types.ts | 8 + .../src/hooks/api/proxiedServices/enums.ts | 16 + .../src/hooks/api/proxiedServices/index.ts | 4 + .../hooks/api/proxiedServices/mutations.tsx | 62 +++ .../src/hooks/api/proxiedServices/queries.tsx | 48 ++ .../src/hooks/api/proxiedServices/types.ts | 83 +++ frontend/src/hooks/api/roles/types.ts | 4 +- frontend/src/hooks/api/subscriptions/types.ts | 1 + frontend/src/hooks/utils/secrets-overview.tsx | 34 ++ frontend/src/lib/fn/permission.ts | 1 + .../GeneralPermissionConditions.tsx | 1 + .../ProjectRoleModifySection.utils.tsx | 85 ++++ .../OverviewPage/OverviewPage.tsx | 71 ++- .../AddResourceButtons/AddResourceButtons.tsx | 35 ++ .../ProxiedServiceTableRow.tsx | 234 +++++++++ .../ProxiedServiceTableRow/index.tsx | 1 + .../OverviewPage/components/index.ts | 1 + .../SecretDashboardPage.tsx | 9 +- .../components/ActionBar/ActionBar.tsx | 42 +- .../ProxiedServiceItem.tsx | 133 +++++ .../ProxiedServiceListView.tsx | 50 ++ .../ProxiedServiceListView/index.tsx | 1 + 66 files changed, 3533 insertions(+), 17 deletions(-) create mode 100644 backend/src/db/migrations/20260709120000_create-proxied-services.ts create mode 100644 backend/src/db/schemas/org-agent-proxy-config.ts create mode 100644 backend/src/db/schemas/proxied-service-credentials.ts create mode 100644 backend/src/db/schemas/proxied-services.ts create mode 100644 backend/src/ee/routes/v1/agent-proxy-ca-router.ts create mode 100644 backend/src/ee/routes/v1/proxied-service-router.ts create mode 100644 backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts create mode 100644 backend/src/ee/services/agent-proxy-ca/org-agent-proxy-config-dal.ts create mode 100644 backend/src/ee/services/proxied-service/proxied-service-credential-dal.ts create mode 100644 backend/src/ee/services/proxied-service/proxied-service-dal.ts create mode 100644 backend/src/ee/services/proxied-service/proxied-service-enums.ts create mode 100644 backend/src/ee/services/proxied-service/proxied-service-service.ts create mode 100644 backend/src/ee/services/proxied-service/proxied-service-types.ts create mode 100644 frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx create mode 100644 frontend/src/components/proxied-services/DeleteProxiedServiceModal.tsx create mode 100644 frontend/src/components/proxied-services/EditProxiedServiceModal.tsx create mode 100644 frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx create mode 100644 frontend/src/components/proxied-services/forms/SecretSelect.tsx create mode 100644 frontend/src/components/proxied-services/forms/SurfaceSelect.tsx create mode 100644 frontend/src/components/proxied-services/forms/index.ts create mode 100644 frontend/src/components/proxied-services/forms/schema.ts create mode 100644 frontend/src/components/proxied-services/index.ts create mode 100644 frontend/src/hooks/api/proxiedServices/enums.ts create mode 100644 frontend/src/hooks/api/proxiedServices/index.ts create mode 100644 frontend/src/hooks/api/proxiedServices/mutations.tsx create mode 100644 frontend/src/hooks/api/proxiedServices/queries.tsx create mode 100644 frontend/src/hooks/api/proxiedServices/types.ts create mode 100644 frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx create mode 100644 frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/index.tsx create mode 100644 frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceItem.tsx create mode 100644 frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceListView.tsx create mode 100644 frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/index.tsx diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index cdd6f5b2870..efd282179a4 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -5,6 +5,7 @@ import { Cluster, Redis } from "ioredis"; import { TUsers } from "@app/db/schemas"; import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-types"; +import { TAgentProxyCaServiceFactory } from "@app/ee/services/agent-proxy-ca/agent-proxy-ca-service"; import { TAiMcpActivityLogServiceFactory } from "@app/ee/services/ai-mcp-activity-log/ai-mcp-activity-log-service"; import { TAiMcpEndpointServiceFactory } from "@app/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-service"; import { TAiMcpServerServiceFactory } from "@app/ee/services/ai-mcp-server/ai-mcp-server-service"; @@ -56,6 +57,7 @@ import { TPkiScepServiceFactory } from "@app/ee/services/pki-scep/pki-scep-servi import { TProjectEventsService } from "@app/ee/services/project-events/project-events-service"; import { TProjectEventsSSEService } from "@app/ee/services/project-events/project-events-sse-service"; import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-types"; +import { TProxiedServiceServiceFactory } from "@app/ee/services/proxied-service/proxied-service-service"; import { RateLimitConfiguration, TRateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-types"; import { TRelayServiceFactory } from "@app/ee/services/relay/relay-service"; import { TResourceAuthMethodServiceFactory } from "@app/ee/services/resource-auth-method/resource-auth-method-service"; @@ -418,6 +420,8 @@ declare module "fastify" { gitHubApp: TGitHubAppServiceFactory; honeyTokenConfig: THoneyTokenConfigServiceFactory; honeyToken: THoneyTokenServiceFactory; + proxiedService: TProxiedServiceServiceFactory; + agentProxyCa: TAgentProxyCaServiceFactory; folderCommit: TFolderCommitServiceFactory; pit: TPitServiceFactory; secretScanningV2: TSecretScanningV2ServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index f21bee750ac..3dfc81fe0cf 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -326,6 +326,9 @@ import { TOidcConfigs, TOidcConfigsInsert, TOidcConfigsUpdate, + TOrgAgentProxyConfig, + TOrgAgentProxyConfigInsert, + TOrgAgentProxyConfigUpdate, TOrganizationAssets, TOrganizationAssetsInsert, TOrganizationAssetsUpdate, @@ -485,6 +488,12 @@ import { TProjectTemplateUserMemberships, TProjectTemplateUserMembershipsInsert, TProjectTemplateUserMembershipsUpdate, + TProxiedServiceCredentials, + TProxiedServiceCredentialsInsert, + TProxiedServiceCredentialsUpdate, + TProxiedServices, + TProxiedServicesInsert, + TProxiedServicesUpdate, TRateLimit, TRateLimitInsert, TRateLimitUpdate, @@ -1573,6 +1582,21 @@ declare module "knex/types/tables" { TOrgGatewayConfigInsert, TOrgGatewayConfigUpdate >; + [TableName.OrgAgentProxyConfig]: KnexOriginal.CompositeTableType< + TOrgAgentProxyConfig, + TOrgAgentProxyConfigInsert, + TOrgAgentProxyConfigUpdate + >; + [TableName.ProxiedService]: KnexOriginal.CompositeTableType< + TProxiedServices, + TProxiedServicesInsert, + TProxiedServicesUpdate + >; + [TableName.ProxiedServiceCredential]: KnexOriginal.CompositeTableType< + TProxiedServiceCredentials, + TProxiedServiceCredentialsInsert, + TProxiedServiceCredentialsUpdate + >; [TableName.SecretRotationV2]: KnexOriginal.CompositeTableType< TSecretRotationsV2, TSecretRotationsV2Insert, diff --git a/backend/src/db/migrations/20260709120000_create-proxied-services.ts b/backend/src/db/migrations/20260709120000_create-proxied-services.ts new file mode 100644 index 00000000000..3d9c5d27f5a --- /dev/null +++ b/backend/src/db/migrations/20260709120000_create-proxied-services.ts @@ -0,0 +1,87 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.OrgAgentProxyConfig))) { + await knex.schema.createTable(TableName.OrgAgentProxyConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.string("rootCaKeyAlgorithm").notNullable(); + t.datetime("rootCaIssuedAt").notNullable(); + t.datetime("rootCaExpiration").notNullable(); + t.string("rootCaSerialNumber").notNullable(); + t.binary("encryptedRootCaCertificate").notNullable(); + t.binary("encryptedRootCaPrivateKey").notNullable(); + + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.unique("orgId"); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.OrgAgentProxyConfig); + } + + if (!(await knex.schema.hasTable(TableName.ProxiedService))) { + await knex.schema.createTable(TableName.ProxiedService, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.string("name").notNullable(); + t.string("hostPattern").notNullable(); + t.boolean("isEnabled").notNullable().defaultTo(true); + + t.uuid("folderId").notNullable(); + t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE"); + + // project is derived via folder -> environment -> project chain; no projectId column + t.unique(["folderId", "name"]); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.ProxiedService); + } + + if (!(await knex.schema.hasTable(TableName.ProxiedServiceCredential))) { + await knex.schema.createTable(TableName.ProxiedServiceCredential, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.uuid("serviceId").notNullable(); + t.foreign("serviceId").references("id").inTable(TableName.ProxiedService).onDelete("CASCADE"); + t.index("serviceId"); + + // Reference to a secret by key name only. Location is implicitly the parent + // service's folder in V1, so no folder/env/path columns are stored here. + t.string("secretKey").notNullable(); + t.string("role").notNullable(); // "header-rewrite" | "credential-substitution" + + // Header rewriting fields (nullable) + t.string("headerName"); + t.string("headerPrefix"); + t.string("headerPurpose"); // basic auth: "username" | "password" + + // Credential substitution fields (nullable) + t.string("placeholderKey"); + t.string("placeholderValue"); + t.specificType("substitutionSurfaces", "text[]"); // subset of header|path|query|body + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.ProxiedServiceCredential); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.ProxiedServiceCredential); + await dropOnUpdateTrigger(knex, TableName.ProxiedServiceCredential); + + await knex.schema.dropTableIfExists(TableName.ProxiedService); + await dropOnUpdateTrigger(knex, TableName.ProxiedService); + + await knex.schema.dropTableIfExists(TableName.OrgAgentProxyConfig); + await dropOnUpdateTrigger(knex, TableName.OrgAgentProxyConfig); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index abfcb629a29..34007de10b7 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -119,6 +119,7 @@ export * from "./microsoft-teams-integrations"; export * from "./models"; export * from "./oauth-clients"; export * from "./oidc-configs"; +export * from "./org-agent-proxy-config"; export * from "./org-bots"; export * from "./org-gateway-config"; export * from "./org-gateway-config-v2"; @@ -194,6 +195,8 @@ export * from "./project-templates"; export * from "./project-user-additional-privilege"; export * from "./project-user-membership-roles"; export * from "./projects"; +export * from "./proxied-service-credentials"; +export * from "./proxied-services"; export * from "./rate-limit"; export * from "./relays"; export * from "./resource-auth-methods"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 1524fe53a69..b7691a405f0 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -319,6 +319,11 @@ export enum TableName { // Audit Reports (exportable compliance reports) AuditReport = "audit_reports", + // Secrets Brokering (Agent Proxy) + OrgAgentProxyConfig = "org_agent_proxy_config", + ProxiedService = "proxied_services", + ProxiedServiceCredential = "proxied_service_credentials", + // Deprecated - Not used anymore now that Redis is persistent DeprecatedDurableQueueJobs = "queue_jobs", DeprecatedSecretRotationV1 = "secret_rotations", @@ -366,7 +371,10 @@ export enum ProjectMembershipRole { // ssh SshHostBootstrapper = "ssh-host-bootstrapper", // kms - KmsCryptographicOperator = "cryptographic-operator" + KmsCryptographicOperator = "cryptographic-operator", + // secrets brokering (agent proxy) + Agent = "agent", + AgentProxy = "agent-proxy" } export enum ResourceMembershipRole { diff --git a/backend/src/db/schemas/org-agent-proxy-config.ts b/backend/src/db/schemas/org-agent-proxy-config.ts new file mode 100644 index 00000000000..142a89e7840 --- /dev/null +++ b/backend/src/db/schemas/org-agent-proxy-config.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const OrgAgentProxyConfigSchema = z.object({ + id: z.string().uuid(), + rootCaKeyAlgorithm: z.string(), + rootCaIssuedAt: z.date(), + rootCaExpiration: z.date(), + rootCaSerialNumber: z.string(), + encryptedRootCaCertificate: zodBuffer, + encryptedRootCaPrivateKey: zodBuffer, + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TOrgAgentProxyConfig = z.infer; +export type TOrgAgentProxyConfigInsert = Omit, TImmutableDBKeys>; +export type TOrgAgentProxyConfigUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/proxied-service-credentials.ts b/backend/src/db/schemas/proxied-service-credentials.ts new file mode 100644 index 00000000000..09cea4e4256 --- /dev/null +++ b/backend/src/db/schemas/proxied-service-credentials.ts @@ -0,0 +1,29 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ProxiedServiceCredentialsSchema = z.object({ + id: z.string().uuid(), + serviceId: z.string().uuid(), + secretKey: z.string(), + role: z.string(), + headerName: z.string().nullable().optional(), + headerPrefix: z.string().nullable().optional(), + headerPurpose: z.string().nullable().optional(), + placeholderKey: z.string().nullable().optional(), + placeholderValue: z.string().nullable().optional(), + substitutionSurfaces: z.string().array().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TProxiedServiceCredentials = z.infer; +export type TProxiedServiceCredentialsInsert = Omit, TImmutableDBKeys>; +export type TProxiedServiceCredentialsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/proxied-services.ts b/backend/src/db/schemas/proxied-services.ts new file mode 100644 index 00000000000..efe86abbc4e --- /dev/null +++ b/backend/src/db/schemas/proxied-services.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ProxiedServicesSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + hostPattern: z.string(), + isEnabled: z.boolean().default(true), + folderId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TProxiedServices = z.infer; +export type TProxiedServicesInsert = Omit, TImmutableDBKeys>; +export type TProxiedServicesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/agent-proxy-ca-router.ts b/backend/src/ee/routes/v1/agent-proxy-ca-router.ts new file mode 100644 index 00000000000..567270cb448 --- /dev/null +++ b/backend/src/ee/routes/v1/agent-proxy-ca-router.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAgentProxyCaRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + config: { rateLimit: readLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + response: { + 200: z.object({ + certificate: z.string(), + keyAlgorithm: z.string(), + issuedAt: z.date(), + expiration: z.date(), + serialNumber: z.string() + }) + } + }, + handler: async (req) => { + return server.services.agentProxyCa.getRootCa(req.permission); + } + }); + + server.route({ + method: "POST", + url: "/sign", + config: { rateLimit: writeLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + body: z.object({ + publicKey: z.string().trim().min(1) + }), + response: { + 200: z.object({ + certificate: z.string(), + issuedAt: z.date(), + expiration: z.date(), + serialNumber: z.string() + }) + } + }, + handler: async (req) => { + return server.services.agentProxyCa.signIntermediate(req.permission, req.body.publicKey); + } + }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 6e2e072d62a..d17fa59990f 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -4,6 +4,7 @@ import { injectCertManagerProjectId } from "@app/server/plugins/inject-cert-mana import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; +import { registerAgentProxyCaRouter } from "./agent-proxy-ca-router"; import { registerAiMcpActivityLogRouter } from "./ai-mcp-activity-log-router"; import { registerAiMcpEndpointRouter } from "./ai-mcp-endpoint-router"; import { registerAiMcpServerRouter } from "./ai-mcp-server-router"; @@ -42,6 +43,7 @@ import { registerPkiDiscoveryRouter } from "./pki-discovery-router"; import { registerPkiInstallationRouter } from "./pki-installation-router"; import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRouter } from "./project-router"; +import { registerProxiedServiceRouter } from "./proxied-service-router"; import { registerRateLimitRouter } from "./rate-limit-router"; import { registerRelayRouter } from "./relay-router"; import { registerSamlRouter } from "./saml-router"; @@ -113,6 +115,9 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register(registerGithubOrgSyncRouter, { prefix: "/github-org-sync-config" }); await server.register(registerHoneyTokenRouter, { prefix: "/honey-tokens" }); + await server.register(registerProxiedServiceRouter, { prefix: "/proxied-services" }); + await server.register(registerAgentProxyCaRouter, { prefix: "/organization/agent-proxy-ca" }); + await server.register(registerInsightsRouter, { prefix: "/insights" }); await server.register( diff --git a/backend/src/ee/routes/v1/proxied-service-router.ts b/backend/src/ee/routes/v1/proxied-service-router.ts new file mode 100644 index 00000000000..30894802b93 --- /dev/null +++ b/backend/src/ee/routes/v1/proxied-service-router.ts @@ -0,0 +1,202 @@ +import { z } from "zod"; + +import { ProxiedServiceCredentialsSchema, ProxiedServicesSchema } from "@app/db/schemas"; +import { + ProxiedServiceCredentialRole, + ProxiedServiceHeaderPurpose, + ProxiedServiceSubstitutionSurface +} from "@app/ee/services/proxied-service/proxied-service-enums"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const CredentialInputSchema = z + .object({ + secretKey: z.string().trim().min(1), + role: z.nativeEnum(ProxiedServiceCredentialRole), + headerName: z.string().trim().min(1).optional(), + headerPrefix: z.string().trim().optional(), + headerPurpose: z.nativeEnum(ProxiedServiceHeaderPurpose).optional(), + placeholderKey: z.string().trim().min(1).optional(), + placeholderValue: z.string().trim().min(1).optional(), + substitutionSurfaces: z.array(z.nativeEnum(ProxiedServiceSubstitutionSurface)).nonempty().optional() + }) + .superRefine((cred, ctx) => { + if (cred.role === ProxiedServiceCredentialRole.HeaderRewrite) { + // either a named header (optionally with a prefix) or a basic-auth purpose, not both + if (cred.headerPurpose) { + if (cred.headerName || cred.headerPrefix) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "headerPurpose cannot be combined with headerName or headerPrefix" + }); + } + } else if (!cred.headerName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Header rewriting requires either headerName or headerPurpose" + }); + } + } else if (!cred.placeholderKey || !cred.placeholderValue || !cred.substitutionSurfaces) { + // credential substitution + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Credential substitution requires placeholderKey, placeholderValue, and substitutionSurfaces" + }); + } + }); + +const SanitizedCredentialSchema = ProxiedServiceCredentialsSchema.pick({ + id: true, + serviceId: true, + secretKey: true, + role: true, + headerName: true, + headerPrefix: true, + headerPurpose: true, + placeholderKey: true, + placeholderValue: true, + substitutionSurfaces: true +}); + +const SanitizedServiceSchema = ProxiedServicesSchema.pick({ + id: true, + name: true, + hostPattern: true, + isEnabled: true, + folderId: true, + createdAt: true, + updatedAt: true +}); + +const ServiceWithCredentialsSchema = SanitizedServiceSchema.extend({ + credentials: SanitizedCredentialSchema.array() +}); + +const ScopeQuerySchema = z.object({ + projectId: z.string().trim().min(1), + environment: z.string().trim().min(1), + secretPath: z.string().trim().default("/") +}); + +export const registerProxiedServiceRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { rateLimit: writeLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + body: z.object({ + projectId: z.string().trim().min(1), + environment: z.string().trim().min(1), + secretPath: z.string().trim().default("/"), + name: slugSchema({ field: "name" }), + hostPattern: z.string().trim().min(1), + isEnabled: z.boolean().optional(), + credentials: CredentialInputSchema.array() + }), + response: { + 200: z.object({ service: ServiceWithCredentialsSchema }) + } + }, + handler: async (req) => { + const service = await server.services.proxiedService.create(req.body, req.permission); + return { service }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { rateLimit: readLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + querystring: ScopeQuerySchema, + response: { + 200: z.object({ + services: ServiceWithCredentialsSchema.extend({ canProxy: z.boolean() }).array() + }) + } + }, + handler: async (req) => { + return server.services.proxiedService.list(req.query, req.permission); + } + }); + + // Distinguishes slug vs id by the presence of scope query params (projectId + environment). + server.route({ + method: "GET", + url: "/:serviceIdOrName", + config: { rateLimit: readLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + params: z.object({ serviceIdOrName: z.string().trim().min(1) }), + querystring: ScopeQuerySchema.partial(), + response: { + 200: z.object({ + service: ServiceWithCredentialsSchema.extend({ canProxy: z.boolean() }) + }) + } + }, + handler: async (req) => { + const { serviceIdOrName } = req.params; + const { projectId, environment, secretPath } = req.query; + if (projectId && environment) { + const service = await server.services.proxiedService.getByName( + { projectId, environment, secretPath: secretPath ?? "/", name: serviceIdOrName }, + req.permission + ); + return { service }; + } + const service = await server.services.proxiedService.getById({ serviceId: serviceIdOrName }, req.permission); + return { service }; + } + }); + + server.route({ + method: "PATCH", + url: "/:serviceId", + config: { rateLimit: writeLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + params: z.object({ serviceId: z.string().uuid() }), + body: z.object({ + name: slugSchema({ field: "name" }).optional(), + hostPattern: z.string().trim().min(1).optional(), + isEnabled: z.boolean().optional(), + credentials: CredentialInputSchema.array().optional() + }), + response: { + 200: z.object({ service: ServiceWithCredentialsSchema }) + } + }, + handler: async (req) => { + const service = await server.services.proxiedService.updateById( + { serviceId: req.params.serviceId, ...req.body }, + req.permission + ); + return { service }; + } + }); + + server.route({ + method: "DELETE", + url: "/:serviceId", + config: { rateLimit: writeLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + params: z.object({ serviceId: z.string().uuid() }), + response: { + 200: z.object({ service: SanitizedServiceSchema }) + } + }, + handler: async (req) => { + const service = await server.services.proxiedService.deleteById( + { serviceId: req.params.serviceId }, + req.permission + ); + return { service }; + } + }); +}; diff --git a/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts b/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts new file mode 100644 index 00000000000..0a5f01a8108 --- /dev/null +++ b/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts @@ -0,0 +1,213 @@ +import * as x509 from "@peculiar/x509"; + +import { OrganizationActionScope } from "@app/db/schemas"; +import { PgSqlLock } from "@app/keystore/keystore"; +import { crypto } from "@app/lib/crypto"; +import { BadRequestError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { + createSerialNumber, + keyAlgorithmToAlgCfg +} from "@app/services/certificate-authority/certificate-authority-fns"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { TPermissionServiceFactory } from "../permission/permission-service-types"; +import { TOrgAgentProxyConfigDALFactory } from "./org-agent-proxy-config-dal"; + +export type TAgentProxyCaServiceFactory = ReturnType; + +type TAgentProxyCaServiceFactoryDep = { + orgAgentProxyConfigDAL: TOrgAgentProxyConfigDALFactory; + kmsService: Pick; + licenseService: Pick; + permissionService: Pick; +}; + +const ROOT_CA_ALGORITHM = CertKeyAlgorithm.ECDSA_P256; +const INTERMEDIATE_CA_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +export const agentProxyCaServiceFactory = ({ + orgAgentProxyConfigDAL, + kmsService, + licenseService, + permissionService +}: TAgentProxyCaServiceFactoryDep) => { + const $checkLicense = async (orgId: string) => { + const plan = await licenseService.getPlan(orgId); + if (!plan.secretsBrokering) { + throw new BadRequestError({ + message: "Failed to use secrets brokering due to plan restriction. Upgrade your plan to use the agent proxy." + }); + } + }; + + // Validates the actor still belongs to the org. Any org member may call the CA endpoints (no dedicated subject). + const $assertOrgMembership = async (actor: OrgServiceActor) => { + await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); + }; + + // Lazily generates (once per org) and returns the org's root CA, decrypted for use. + const $getOrgRootCa = async (orgId: string) => { + const { encryptor: orgKmsEncryptor, decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const config = await orgAgentProxyConfigDAL.transaction(async (tx) => { + const existing = await orgAgentProxyConfigDAL.findOne({ orgId }, tx); + if (existing) return existing; + + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.OrgAgentProxyConfigInit(orgId)]); + // re-check after acquiring the lock in case another instance created it + const afterLock = await orgAgentProxyConfigDAL.findOne({ orgId }, tx); + if (afterLock) return afterLock; + + const alg = keyAlgorithmToAlgCfg(ROOT_CA_ALGORITHM); + const rootCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const rootCaSkObj = crypto.nativeCrypto.KeyObject.from(rootCaKeys.privateKey); + + const rootCaSerialNumber = createSerialNumber(); + const rootCaIssuedAt = new Date(); + const rootCaExpiration = new Date(new Date().setFullYear(new Date().getFullYear() + 2)); + + const rootCaCert = await x509.X509CertificateGenerator.createSelfSigned({ + name: `O=${orgId},CN=Infisical Agent Proxy Root CA`, + serialNumber: rootCaSerialNumber, + notBefore: rootCaIssuedAt, + notAfter: rootCaExpiration, + signingAlgorithm: alg, + keys: rootCaKeys, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + new x509.BasicConstraintsExtension(true, undefined, true), + await x509.SubjectKeyIdentifierExtension.create(rootCaKeys.publicKey) + ] + }); + + const encryptedRootCaPrivateKey = orgKmsEncryptor({ + plainText: Buffer.from(rootCaSkObj.export({ type: "pkcs8", format: "der" })) + }).cipherTextBlob; + const encryptedRootCaCertificate = orgKmsEncryptor({ + plainText: Buffer.from(rootCaCert.rawData) + }).cipherTextBlob; + + return orgAgentProxyConfigDAL.create( + { + orgId, + rootCaKeyAlgorithm: ROOT_CA_ALGORITHM, + rootCaIssuedAt, + rootCaExpiration, + rootCaSerialNumber, + encryptedRootCaCertificate, + encryptedRootCaPrivateKey + }, + tx + ); + }); + + const rootCaCertBuffer = orgKmsDecryptor({ cipherTextBlob: config.encryptedRootCaCertificate }); + const rootCaPrivateKeyBuffer = orgKmsDecryptor({ cipherTextBlob: config.encryptedRootCaPrivateKey }); + const rootCaCert = new x509.X509Certificate(rootCaCertBuffer); + + return { config, rootCaCert, rootCaPrivateKeyBuffer }; + }; + + // Returns the org's public root CA certificate (auto-creating it on first access). + const getRootCa = async (actor: OrgServiceActor) => { + await $checkLicense(actor.orgId); + await $assertOrgMembership(actor); + + const { config, rootCaCert } = await $getOrgRootCa(actor.orgId); + return { + certificate: rootCaCert.toString("pem"), + keyAlgorithm: config.rootCaKeyAlgorithm, + issuedAt: config.rootCaIssuedAt, + expiration: config.rootCaExpiration, + serialNumber: config.rootCaSerialNumber + }; + }; + + // Signs a caller-provided public key as a short-lived intermediate CA chained to the org root CA. + const signIntermediate = async (actor: OrgServiceActor, publicKeyPem: string) => { + await $checkLicense(actor.orgId); + await $assertOrgMembership(actor); + + const { rootCaCert, rootCaPrivateKeyBuffer } = await $getOrgRootCa(actor.orgId); + const alg = keyAlgorithmToAlgCfg(ROOT_CA_ALGORITHM); + + let intermediatePublicKey: CryptoKey; + try { + const publicKeyObj = crypto.nativeCrypto.createPublicKey({ key: publicKeyPem, format: "pem" }); + intermediatePublicKey = await crypto.nativeCrypto.subtle.importKey( + "spki", + publicKeyObj.export({ format: "der", type: "spki" }), + alg, + true, + [] + ); + } catch { + throw new BadRequestError({ message: "Invalid intermediate CA public key" }); + } + + const rootCaSkObj = crypto.nativeCrypto.createPrivateKey({ + key: rootCaPrivateKeyBuffer, + format: "der", + type: "pkcs8" + }); + const importedRootCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( + "pkcs8", + rootCaSkObj.export({ format: "der", type: "pkcs8" }), + alg, + true, + ["sign"] + ); + + const serialNumber = createSerialNumber(); + const issuedAt = new Date(); + const expiration = new Date(Date.now() + INTERMEDIATE_CA_TTL_MS); + + const intermediateCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: `O=${actor.orgId},CN=Infisical Agent Proxy Intermediate CA`, + issuer: rootCaCert.subject, + notBefore: issuedAt, + notAfter: expiration, + signingKey: importedRootCaPrivateKey, + publicKey: intermediatePublicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags[CertKeyUsage.KEY_CERT_SIGN] | x509.KeyUsageFlags[CertKeyUsage.CRL_SIGN], + true + ), + new x509.BasicConstraintsExtension(true, 0, true), + await x509.AuthorityKeyIdentifierExtension.create(rootCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(intermediatePublicKey) + ] + }); + + return { + certificate: intermediateCert.toString("pem"), + issuedAt, + expiration, + serialNumber + }; + }; + + return { + getRootCa, + signIntermediate + }; +}; diff --git a/backend/src/ee/services/agent-proxy-ca/org-agent-proxy-config-dal.ts b/backend/src/ee/services/agent-proxy-ca/org-agent-proxy-config-dal.ts new file mode 100644 index 00000000000..ec6a826bf02 --- /dev/null +++ b/backend/src/ee/services/agent-proxy-ca/org-agent-proxy-config-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TOrgAgentProxyConfigDALFactory = ReturnType; + +export const orgAgentProxyConfigDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.OrgAgentProxyConfig); + return orm; +}; diff --git a/backend/src/ee/services/license/licence-enums.ts b/backend/src/ee/services/license/licence-enums.ts index c4aa73f9927..64a08667f5a 100644 --- a/backend/src/ee/services/license/licence-enums.ts +++ b/backend/src/ee/services/license/licence-enums.ts @@ -16,7 +16,8 @@ export const BillingPlanRows = { SecretRotation: { name: "Secret rotation", field: "secretRotation" }, InstanceUserManagement: { name: "Instance User Management", field: "instanceUserManagement" }, ExternalKms: { name: "External KMS", field: "externalKms" }, - HoneyTokens: { name: "Honey Tokens", field: "honeyTokens" } + HoneyTokens: { name: "Honey Tokens", field: "honeyTokens" }, + SecretsBrokering: { name: "Secrets Brokering", field: "secretsBrokering" } } as const; export const BillingPlanTableHead = { diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 2082c05baf7..fce9c9cd9b8 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -129,7 +129,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ pkiLegacyTemplates: false, secretShareExternalBranding: false, honeyTokens: false, - honeyTokenLimit: 0 + honeyTokenLimit: 0, + secretsBrokering: true }); export const setupLicenseRequestWithStore = ( diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index ebc5c30c978..c40ee3adf00 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -102,6 +102,7 @@ export type TFeatureSet = { secretShareExternalBranding: false; honeyTokens: false; honeyTokenLimit: 0; + secretsBrokering: true; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 1a276ec9376..79914beae6a 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -29,6 +29,7 @@ import { ProjectPermissionPkiSyncActions, ProjectPermissionPkiTemplateActions, ProjectPermissionProjectFolderGrantActions, + ProjectPermissionProxiedServiceActions, ProjectPermissionSecretActions, ProjectPermissionSecretApprovalRequestActions, ProjectPermissionSecretEventActions, @@ -467,6 +468,17 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.Insights ); + can( + [ + ProjectPermissionProxiedServiceActions.Read, + ProjectPermissionProxiedServiceActions.Create, + ProjectPermissionProxiedServiceActions.Edit, + ProjectPermissionProxiedServiceActions.Delete, + ProjectPermissionProxiedServiceActions.Proxy + ], + ProjectPermissionSub.ProxiedServices + ); + return rules; }; @@ -569,6 +581,8 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionHoneyTokenActions.Read], ProjectPermissionSub.HoneyTokens); + can([ProjectPermissionProxiedServiceActions.Read], ProjectPermissionSub.ProxiedServices); + can( [ ProjectPermissionActions.Read, @@ -711,6 +725,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionIdentityActions.Read, ProjectPermissionSub.Identity); can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); can(ProjectPermissionHoneyTokenActions.Read, ProjectPermissionSub.HoneyTokens); + can(ProjectPermissionProxiedServiceActions.Read, ProjectPermissionSub.ProxiedServices); can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); @@ -798,6 +813,29 @@ const buildCryptographicOperatorPermissionRules = () => { return rules; }; +// Grants Proxy on all proxied services (all environments, all paths). No secret read access. +// Intended for the agent machine identity. +const buildAgentPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); + + can(ProjectPermissionProxiedServiceActions.Proxy, ProjectPermissionSub.ProxiedServices); + + return rules; +}; + +// Grants read access to all secret values (all environments, all paths). No proxied service permissions. +// Intended for the agent proxy machine identity, which fetches real credentials for header rewriting/substitution. +const buildAgentProxyPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); + + can( + [ProjectPermissionSecretActions.DescribeSecret, ProjectPermissionSecretActions.ReadValue], + ProjectPermissionSub.Secrets + ); + + return rules; +}; + // General export const projectAdminPermissions = buildAdminPermissionRules(); export const projectMemberPermissions = buildMemberPermissionRules(); @@ -810,6 +848,10 @@ export const sshHostBootstrapPermissions = buildSshHostBootstrapPermissionRules( // KMS export const cryptographicOperatorPermissions = buildCryptographicOperatorPermissionRules(); +// Secrets Brokering (Agent Proxy) +export const agentPermissions = buildAgentPermissionRules(); +export const agentProxyPermissions = buildAgentProxyPermissionRules(); + const buildApplicationAdminPermissionRules = () => { const { can, rules } = new AbilityBuilder>(createMongoAbility); diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 6a8031a8676..e59a17541e5 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -16,6 +16,8 @@ import { import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { PamResourceRole } from "@app/ee/services/pam/pam-enums"; import { + agentPermissions, + agentProxyPermissions, applicationAdminPermissions, applicationAuditorPermissions, applicationOperatorPermissions, @@ -137,6 +139,10 @@ const buildProjectPermissionRules = (projectUserRoles: TBuildProjectPermissionDT return sshHostBootstrapPermissions; case ProjectMembershipRole.KmsCryptographicOperator: return cryptographicOperatorPermissions; + case ProjectMembershipRole.Agent: + return agentPermissions; + case ProjectMembershipRole.AgentProxy: + return agentProxyPermissions; case ProjectMembershipRole.Custom: { return unpackRules>>( permissions as PackRule>>[] diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index bce922a4cec..a72e8660e2b 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -288,6 +288,14 @@ export enum ProjectPermissionHoneyTokenActions { Revoke = "revoke" } +export enum ProjectPermissionProxiedServiceActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + Proxy = "proxy" +} + export enum ProjectPermissionApprovalRequestActions { Read = "read", Create = "create" @@ -370,6 +378,7 @@ export enum ProjectPermissionSub { McpServers = "mcp-servers", McpActivityLogs = "mcp-activity-logs", HoneyTokens = "honey-tokens", + ProxiedServices = "proxied-services", Insights = "insights" } @@ -543,6 +552,11 @@ export type HoneyTokenSubjectFields = { secretPath: string; }; +export type ProxiedServiceSubjectFields = { + environment: string; + secretPath: string; +}; + export type ProjectFolderGrantSubjectFields = { environment: string; secretPath: string; @@ -687,6 +701,13 @@ export type ProjectPermissionSet = ProjectPermissionHoneyTokenActions, ProjectPermissionSub.HoneyTokens | (ForcedSubject & HoneyTokenSubjectFields) ] + | [ + ProjectPermissionProxiedServiceActions, + ( + | ProjectPermissionSub.ProxiedServices + | (ForcedSubject & ProxiedServiceSubjectFields) + ) + ] | [ProjectPermissionActions, ProjectPermissionSub.McpServers] | [ProjectPermissionActions, ProjectPermissionSub.McpActivityLogs] | [ @@ -915,6 +936,23 @@ const HoneyTokenConditionSchema = z }) .partial(); +const ProxiedServiceConditionSchema = z + .object({ + environment: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]), + secretPath: SECRET_PATH_PERMISSION_OPERATOR_SCHEMA + }) + .partial(); + const ProjectFolderGrantConditionSchema = z .object({ environment: z.union([ @@ -1668,6 +1706,16 @@ const GeneralPermissionSchema = [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.ProxiedServices).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionProxiedServiceActions).describe( + "Describe what action an entity can take." + ), + conditions: ProxiedServiceConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), z.object({ subject: z.literal(ProjectPermissionSub.ApprovalRequests).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionApprovalRequestActions).describe( diff --git a/backend/src/ee/services/proxied-service/proxied-service-credential-dal.ts b/backend/src/ee/services/proxied-service/proxied-service-credential-dal.ts new file mode 100644 index 00000000000..ffeb9f9d1fe --- /dev/null +++ b/backend/src/ee/services/proxied-service/proxied-service-credential-dal.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { TProxiedServiceCredentials } from "@app/db/schemas/proxied-service-credentials"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TProxiedServiceCredentialDALFactory = ReturnType; + +export const proxiedServiceCredentialDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ProxiedServiceCredential); + + const findByServiceIds = async (serviceIds: string[], tx?: Knex): Promise => { + if (!serviceIds.length) return []; + return (tx || db.replicaNode())(TableName.ProxiedServiceCredential) + .whereIn("serviceId", serviceIds) + .select(selectAllTableCols(TableName.ProxiedServiceCredential)); + }; + + const deleteByServiceId = async (serviceId: string, tx?: Knex) => { + await (tx || db)(TableName.ProxiedServiceCredential).where({ serviceId }).delete(); + }; + + return { + ...orm, + findByServiceIds, + deleteByServiceId + }; +}; diff --git a/backend/src/ee/services/proxied-service/proxied-service-dal.ts b/backend/src/ee/services/proxied-service/proxied-service-dal.ts new file mode 100644 index 00000000000..b1413a588bb --- /dev/null +++ b/backend/src/ee/services/proxied-service/proxied-service-dal.ts @@ -0,0 +1,141 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TProxiedServiceDALFactory = ReturnType; + +export type TProxiedServiceWithScope = { + id: string; + name: string; + hostPattern: string; + isEnabled: boolean; + folderId: string; + projectId: string; + createdAt: Date; + updatedAt: Date; + environment: { id: string; name: string; slug: string }; + folder: { path: string }; +}; + +export type TProxiedServiceScoped = { + id: string; + name: string; + hostPattern: string; + isEnabled: boolean; + folderId: string; + projectId: string; + environmentSlug: string; + createdAt: Date; + updatedAt: Date; +}; + +export const proxiedServiceDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ProxiedService); + + // proxied_services has no projectId column; it is derived via folder -> environment -> project. + const findByFolderIds = async (folderIds: string[], tx?: Knex): Promise => { + if (!folderIds.length) return []; + const rows = await (tx || db.replicaNode())(TableName.ProxiedService) + .whereIn(`${TableName.ProxiedService}.folderId`, folderIds) + .join(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.ProxiedService}.folderId`) + .join(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`) + .whereNull(`${TableName.Environment}.deleteAfter`) + .select( + db.ref("id").withSchema(TableName.ProxiedService), + db.ref("name").withSchema(TableName.ProxiedService), + db.ref("hostPattern").withSchema(TableName.ProxiedService), + db.ref("isEnabled").withSchema(TableName.ProxiedService), + db.ref("folderId").withSchema(TableName.ProxiedService), + db.ref("createdAt").withSchema(TableName.ProxiedService), + db.ref("updatedAt").withSchema(TableName.ProxiedService) + ) + .select( + db.ref("projectId").withSchema(TableName.Environment).as("projectId"), + db.ref("id").withSchema(TableName.Environment).as("envId"), + db.ref("name").withSchema(TableName.Environment).as("envName"), + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + db.ref("name").withSchema(TableName.SecretFolder).as("folderName") + ); + + return rows.map((row) => ({ + id: row.id, + name: row.name, + hostPattern: row.hostPattern, + isEnabled: row.isEnabled, + folderId: row.folderId, + projectId: row.projectId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + environment: { + id: row.envId, + name: row.envName, + slug: row.envSlug + }, + folder: { + path: row.folderName + } + })); + }; + + const countByFolderIds = async (folderIds: string[], search?: string, tx?: Knex) => { + if (!folderIds.length) return 0; + const query = (tx || db.replicaNode())(TableName.ProxiedService).whereIn( + `${TableName.ProxiedService}.folderId`, + folderIds + ); + + if (search) { + void query.where(`${TableName.ProxiedService}.name`, "ilike", `%${search}%`); + } + + const [result] = await query.countDistinct<{ count: string | number }>({ + count: `${TableName.ProxiedService}.name` + }); + return Number(result?.count ?? 0); + }; + + // Loads a service by id together with its derived project id and environment slug for scoped permission checks. + const findByIdWithScope = async (serviceId: string, tx?: Knex): Promise => { + const row = await (tx || db.replicaNode())(TableName.ProxiedService) + .where(`${TableName.ProxiedService}.id`, serviceId) + .join(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.ProxiedService}.folderId`) + .join(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`) + .whereNull(`${TableName.Environment}.deleteAfter`) + .select( + db.ref("id").withSchema(TableName.ProxiedService), + db.ref("name").withSchema(TableName.ProxiedService), + db.ref("hostPattern").withSchema(TableName.ProxiedService), + db.ref("isEnabled").withSchema(TableName.ProxiedService), + db.ref("folderId").withSchema(TableName.ProxiedService), + db.ref("createdAt").withSchema(TableName.ProxiedService), + db.ref("updatedAt").withSchema(TableName.ProxiedService) + ) + .select( + db.ref("projectId").withSchema(TableName.Environment).as("projectId"), + db.ref("slug").withSchema(TableName.Environment).as("envSlug") + ) + .first(); + + if (!row) return null; + return { + id: row.id, + name: row.name, + hostPattern: row.hostPattern, + isEnabled: row.isEnabled, + folderId: row.folderId, + projectId: row.projectId, + environmentSlug: row.envSlug, + createdAt: row.createdAt, + updatedAt: row.updatedAt + }; + }; + + return { + ...orm, + findByFolderIds, + countByFolderIds, + findByIdWithScope + }; +}; diff --git a/backend/src/ee/services/proxied-service/proxied-service-enums.ts b/backend/src/ee/services/proxied-service/proxied-service-enums.ts new file mode 100644 index 00000000000..4ef88988224 --- /dev/null +++ b/backend/src/ee/services/proxied-service/proxied-service-enums.ts @@ -0,0 +1,16 @@ +export enum ProxiedServiceCredentialRole { + HeaderRewrite = "header-rewrite", + CredentialSubstitution = "credential-substitution" +} + +export enum ProxiedServiceHeaderPurpose { + Username = "username", + Password = "password" +} + +export enum ProxiedServiceSubstitutionSurface { + Header = "header", + Path = "path", + Query = "query", + Body = "body" +} diff --git a/backend/src/ee/services/proxied-service/proxied-service-service.ts b/backend/src/ee/services/proxied-service/proxied-service-service.ts new file mode 100644 index 00000000000..f060113ce1b --- /dev/null +++ b/backend/src/ee/services/proxied-service/proxied-service-service.ts @@ -0,0 +1,481 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { ActionProjectType, SecretType } from "@app/db/schemas"; +import { throwIfMissingSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns"; +import { + ProjectPermissionProxiedServiceActions, + ProjectPermissionSecretActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { prefixWithSlash, removeTrailingSlash } from "@app/lib/fn"; +import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { TPermissionServiceFactory } from "../permission/permission-service-types"; +import { TProxiedServiceCredentialDALFactory } from "./proxied-service-credential-dal"; +import { TProxiedServiceDALFactory } from "./proxied-service-dal"; +import { + TCreateProxiedServiceDTO, + TDeleteProxiedServiceDTO, + TGetProxiedServiceByIdDTO, + TGetProxiedServiceByNameDTO, + TListProxiedServicesDTO, + TProxiedServiceCredentialInput, + TProxiedServiceDashboardCountDTO, + TProxiedServiceDashboardListDTO, + TUpdateProxiedServiceDTO +} from "./proxied-service-types"; + +export type TProxiedServiceServiceFactory = ReturnType; + +type TProxiedServiceServiceFactoryDep = { + proxiedServiceDAL: TProxiedServiceDALFactory; + proxiedServiceCredentialDAL: TProxiedServiceCredentialDALFactory; + folderDAL: Pick< + TSecretFolderDALFactory, + "findBySecretPath" | "findBySecretPathMultiEnv" | "findSecretPathByFolderIds" + >; + secretV2BridgeDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +const toCredentialRow = (serviceId: string, credential: TProxiedServiceCredentialInput) => ({ + serviceId, + secretKey: credential.secretKey, + 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 +}); + +export const proxiedServiceServiceFactory = ({ + proxiedServiceDAL, + proxiedServiceCredentialDAL, + folderDAL, + secretV2BridgeDAL, + permissionService, + licenseService +}: TProxiedServiceServiceFactoryDep) => { + const $checkLicense = async (orgId: string) => { + const plan = await licenseService.getPlan(orgId); + if (!plan.secretsBrokering) { + throw new BadRequestError({ + message: "Failed to use secrets brokering due to plan restriction. Upgrade your plan to use proxied services." + }); + } + }; + + // Validates that every referenced secret exists in the folder. Assumes the caller already holds + // ReadValue on the folder path (checked separately). Stale references are allowed to persist afterwards + // (secret rename/delete), but must resolve at creation/update time to catch typos early. + const $validateSecretReferences = async (folderId: string, credentials: TProxiedServiceCredentialInput[]) => { + const uniqueKeys = [...new Set(credentials.map((c) => c.secretKey))]; + if (!uniqueKeys.length) return; + + const found = await secretV2BridgeDAL.findBySecretKeys( + folderId, + uniqueKeys.map((key) => ({ key, type: SecretType.Shared })) + ); + const foundKeys = new Set(found.map((s) => s.key)); + const missing = uniqueKeys.filter((key) => !foundKeys.has(key)); + if (missing.length) { + throw new BadRequestError({ + message: `Referenced secret(s) not found in folder: ${missing.join(", ")}` + }); + } + }; + + // Resolves the canonical secret path for a service's folder so scoped (glob) permission checks are accurate. + const $resolveSecretPath = async (projectId: string, folderId: string) => { + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(projectId, [folderId]); + if (!folderWithPath) { + throw new NotFoundError({ message: "Could not resolve the folder for this proxied service" }); + } + return prefixWithSlash(removeTrailingSlash(folderWithPath.path)); + }; + + const create = async ( + { projectId, environment, secretPath, name, hostPattern, isEnabled, credentials }: TCreateProxiedServiceDTO, + actor: OrgServiceActor + ) => { + await $checkLicense(actor.orgId); + const canonicalPath = prefixWithSlash(removeTrailingSlash(secretPath)); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionProxiedServiceActions.Create, + subject(ProjectPermissionSub.ProxiedServices, { environment, secretPath: canonicalPath }) + ); + // caller must be able to read the referenced secrets they are wiring up + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath: canonicalPath + }); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) { + throw new BadRequestError({ + message: `Could not find folder with path "${secretPath}" in environment "${environment}"` + }); + } + + const existing = await proxiedServiceDAL.findOne({ folderId: folder.id, name }); + if (existing) { + throw new BadRequestError({ message: `A proxied service named "${name}" already exists in this folder` }); + } + + await $validateSecretReferences(folder.id, credentials); + + return proxiedServiceDAL.transaction(async (tx) => { + const service = await proxiedServiceDAL.create( + { name, hostPattern, isEnabled: isEnabled ?? true, folderId: folder.id }, + tx + ); + const credentialRows = credentials.map((c) => toCredentialRow(service.id, c)); + const insertedCredentials = credentialRows.length + ? await proxiedServiceCredentialDAL.insertMany(credentialRows, tx) + : []; + return { ...service, credentials: insertedCredentials }; + }); + }; + + const list = async ({ projectId, environment, secretPath }: TListProxiedServicesDTO, actor: OrgServiceActor) => { + await $checkLicense(actor.orgId); + const canonicalPath = prefixWithSlash(removeTrailingSlash(secretPath)); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + // every service in this list shares one folder, so evaluate access once + const scopedSubject = subject(ProjectPermissionSub.ProxiedServices, { + environment, + secretPath: canonicalPath + }); + const canRead = permission.can(ProjectPermissionProxiedServiceActions.Read, scopedSubject); + const canProxy = permission.can(ProjectPermissionProxiedServiceActions.Proxy, scopedSubject); + if (!canRead && !canProxy) { + throw new ForbiddenRequestError({ + message: "You do not have permission to access proxied services in this folder" + }); + } + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) return { services: [] }; + + const services = await proxiedServiceDAL.findByFolderIds([folder.id]); + const credentials = await proxiedServiceCredentialDAL.findByServiceIds(services.map((s) => s.id)); + const credentialsByService = credentials.reduce>((acc, cred) => { + acc[cred.serviceId] = acc[cred.serviceId] || []; + acc[cred.serviceId].push(cred); + return acc; + }, {}); + + return { + services: services.map((svc) => ({ + ...svc, + canProxy, + credentials: credentialsByService[svc.id] ?? [] + })) + }; + }; + + const getById = async ({ serviceId }: TGetProxiedServiceByIdDTO, actor: OrgServiceActor) => { + await $checkLicense(actor.orgId); + const service = await proxiedServiceDAL.findByIdWithScope(serviceId); + if (!service) { + throw new NotFoundError({ message: `Proxied service with ID "${serviceId}" not found` }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId: service.projectId + }); + + const resolvedSecretPath = await $resolveSecretPath(service.projectId, service.folderId); + const scopedSubject = subject(ProjectPermissionSub.ProxiedServices, { + environment: service.environmentSlug, + secretPath: resolvedSecretPath + }); + const canRead = permission.can(ProjectPermissionProxiedServiceActions.Read, scopedSubject); + const canProxy = permission.can(ProjectPermissionProxiedServiceActions.Proxy, scopedSubject); + if (!canRead && !canProxy) { + throw new ForbiddenRequestError({ message: "You do not have permission to access this proxied service" }); + } + + const credentials = await proxiedServiceCredentialDAL.findByServiceIds([service.id]); + return { ...service, canProxy, credentials }; + }; + + const getByName = async ( + { projectId, environment, secretPath, name }: TGetProxiedServiceByNameDTO, + actor: OrgServiceActor + ) => { + await $checkLicense(actor.orgId); + const canonicalPath = prefixWithSlash(removeTrailingSlash(secretPath)); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + const scopedSubject = subject(ProjectPermissionSub.ProxiedServices, { + environment, + secretPath: canonicalPath + }); + const canRead = permission.can(ProjectPermissionProxiedServiceActions.Read, scopedSubject); + const canProxy = permission.can(ProjectPermissionProxiedServiceActions.Proxy, scopedSubject); + if (!canRead && !canProxy) { + throw new ForbiddenRequestError({ message: "You do not have permission to access this proxied service" }); + } + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) { + throw new NotFoundError({ message: `Proxied service "${name}" not found` }); + } + + const service = await proxiedServiceDAL.findOne({ folderId: folder.id, name }); + if (!service) { + throw new NotFoundError({ message: `Proxied service "${name}" not found` }); + } + + const credentials = await proxiedServiceCredentialDAL.findByServiceIds([service.id]); + return { ...service, canProxy, credentials }; + }; + + const updateById = async ( + { serviceId, name, hostPattern, isEnabled, credentials }: TUpdateProxiedServiceDTO, + actor: OrgServiceActor + ) => { + await $checkLicense(actor.orgId); + const service = await proxiedServiceDAL.findByIdWithScope(serviceId); + if (!service) { + throw new NotFoundError({ message: `Proxied service with ID "${serviceId}" not found` }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId: service.projectId + }); + const resolvedSecretPath = await $resolveSecretPath(service.projectId, service.folderId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionProxiedServiceActions.Edit, + subject(ProjectPermissionSub.ProxiedServices, { + environment: service.environmentSlug, + secretPath: resolvedSecretPath + }) + ); + + if (name && name !== service.name) { + const conflicting = await proxiedServiceDAL.findOne({ folderId: service.folderId, name }); + if (conflicting) { + throw new BadRequestError({ message: `A proxied service named "${name}" already exists in this folder` }); + } + } + + if (credentials) { + // same guard as create: the caller must be able to read the secrets it is wiring up, + // otherwise Edit-only actors could reference secrets they cannot read and exfiltrate them via the proxy + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment: service.environmentSlug, + secretPath: resolvedSecretPath + }); + await $validateSecretReferences(service.folderId, credentials); + } + + const serviceUpdate = { + ...(name !== undefined ? { name } : {}), + ...(hostPattern !== undefined ? { hostPattern } : {}), + ...(isEnabled !== undefined ? { isEnabled } : {}) + }; + + return proxiedServiceDAL.transaction(async (tx) => { + // avoid Knex "Empty .update() call" when only credentials (or nothing) changed + const updated = Object.keys(serviceUpdate).length + ? await proxiedServiceDAL.updateById(serviceId, serviceUpdate, tx) + : await proxiedServiceDAL.findById(serviceId, tx); + + let updatedCredentials = await proxiedServiceCredentialDAL.findByServiceIds([serviceId], tx); + if (credentials) { + await proxiedServiceCredentialDAL.deleteByServiceId(serviceId, tx); + updatedCredentials = credentials.length + ? await proxiedServiceCredentialDAL.insertMany( + credentials.map((c) => toCredentialRow(serviceId, c)), + tx + ) + : []; + } + + return { ...updated, credentials: updatedCredentials }; + }); + }; + + const deleteById = async ({ serviceId }: TDeleteProxiedServiceDTO, actor: OrgServiceActor) => { + await $checkLicense(actor.orgId); + const service = await proxiedServiceDAL.findByIdWithScope(serviceId); + if (!service) { + throw new NotFoundError({ message: `Proxied service with ID "${serviceId}" not found` }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId: service.projectId + }); + const resolvedSecretPath = await $resolveSecretPath(service.projectId, service.folderId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionProxiedServiceActions.Delete, + subject(ProjectPermissionSub.ProxiedServices, { + environment: service.environmentSlug, + secretPath: resolvedSecretPath + }) + ); + + // credentials cascade on serviceId FK + await proxiedServiceDAL.deleteById(serviceId); + return service; + }; + + const getDashboardProxiedServiceCount = async ( + { projectId, environments, secretPath, search }: TProxiedServiceDashboardCountDTO, + actor: OrgServiceActor + ) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + const canonicalSecretPath = prefixWithSlash(removeTrailingSlash(secretPath)); + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath); + if (!folders.length) return 0; + + const allowedFolders = folders.filter((f) => + permission.can( + ProjectPermissionProxiedServiceActions.Read, + subject(ProjectPermissionSub.ProxiedServices, { + environment: f.environment.slug, + secretPath: canonicalSecretPath + }) + ) + ); + if (!allowedFolders.length) return 0; + + return proxiedServiceDAL.countByFolderIds( + allowedFolders.map((f) => f.id), + search + ); + }; + + const getDashboardProxiedServices = async ( + { + projectId, + environments, + secretPath, + search, + orderBy, + orderDirection, + limit, + offset + }: TProxiedServiceDashboardListDTO, + actor: OrgServiceActor + ) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + const canonicalSecretPath = prefixWithSlash(removeTrailingSlash(secretPath)); + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath); + if (!folders.length) return []; + + const allowedFolders = folders.filter((f) => + permission.can( + ProjectPermissionProxiedServiceActions.Read, + subject(ProjectPermissionSub.ProxiedServices, { + environment: f.environment.slug, + secretPath: canonicalSecretPath + }) + ) + ); + if (!allowedFolders.length) return []; + + let services = await proxiedServiceDAL.findByFolderIds(allowedFolders.map((f) => f.id)); + + if (search) { + services = services.filter((svc) => svc.name.toLowerCase().includes(search.toLowerCase())); + } + + if (orderBy === "name") { + services.sort((a, b) => { + const cmp = a.name.localeCompare(b.name); + return orderDirection === OrderByDirection.DESC ? -cmp : cmp; + }); + } + + if (offset !== undefined && limit !== undefined) { + services = services.slice(offset, offset + limit); + } + + const credentials = await proxiedServiceCredentialDAL.findByServiceIds(services.map((s) => s.id)); + const credentialsByService = credentials.reduce>((acc, cred) => { + acc[cred.serviceId] = acc[cred.serviceId] || []; + acc[cred.serviceId].push(cred); + return acc; + }, {}); + + return services.map((svc) => ({ ...svc, credentials: credentialsByService[svc.id] ?? [] })); + }; + + return { + create, + list, + getById, + getByName, + updateById, + deleteById, + getDashboardProxiedServiceCount, + getDashboardProxiedServices + }; +}; diff --git a/backend/src/ee/services/proxied-service/proxied-service-types.ts b/backend/src/ee/services/proxied-service/proxied-service-types.ts new file mode 100644 index 00000000000..639e49ce509 --- /dev/null +++ b/backend/src/ee/services/proxied-service/proxied-service-types.ts @@ -0,0 +1,75 @@ +import { OrderByDirection } from "@app/lib/types"; + +import { + ProxiedServiceCredentialRole, + ProxiedServiceHeaderPurpose, + ProxiedServiceSubstitutionSurface +} from "./proxied-service-enums"; + +export type TProxiedServiceCredentialInput = { + secretKey: string; + role: ProxiedServiceCredentialRole; + headerName?: string | null; + headerPrefix?: string | null; + headerPurpose?: ProxiedServiceHeaderPurpose | null; + placeholderKey?: string | null; + placeholderValue?: string | null; + substitutionSurfaces?: ProxiedServiceSubstitutionSurface[] | null; +}; + +export type TCreateProxiedServiceDTO = { + projectId: string; + environment: string; + secretPath: string; + name: string; + hostPattern: string; + isEnabled?: boolean; + credentials: TProxiedServiceCredentialInput[]; +}; + +export type TUpdateProxiedServiceDTO = { + serviceId: string; + name?: string; + hostPattern?: string; + isEnabled?: boolean; + credentials?: TProxiedServiceCredentialInput[]; +}; + +export type TGetProxiedServiceByIdDTO = { + serviceId: string; +}; + +export type TGetProxiedServiceByNameDTO = { + projectId: string; + environment: string; + secretPath: string; + name: string; +}; + +export type TDeleteProxiedServiceDTO = { + serviceId: string; +}; + +export type TListProxiedServicesDTO = { + projectId: string; + environment: string; + secretPath: string; +}; + +export type TProxiedServiceDashboardListDTO = { + projectId: string; + environments: string[]; + secretPath: string; + search?: string; + orderBy?: "name"; + orderDirection?: OrderByDirection; + limit?: number; + offset?: number; +}; + +export type TProxiedServiceDashboardCountDTO = { + projectId: string; + environments: string[]; + secretPath: string; + search?: string; +}; diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index a7030f2c9b5..1fc0ca73844 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -39,7 +39,8 @@ export const PgSqlLock = { KmsProjectDataKeyCreation: (projectId: string) => pgAdvisoryLockHashText(`kms-project-data-key:${projectId}`), ScimGroupUpdate: (groupId: string) => pgAdvisoryLockHashText(`scim-group-update:${groupId}`), LastAdminGuard: (scope: "org", scopeId: string) => pgAdvisoryLockHashText(`last-admin-guard:${scope}:${scopeId}`), - AuditReportRequest: (projectId: string) => pgAdvisoryLockHashText(`audit-report-request:${projectId}`) + AuditReportRequest: (projectId: string) => pgAdvisoryLockHashText(`audit-report-request:${projectId}`), + OrgAgentProxyConfigInit: (orgId: string) => pgAdvisoryLockHashText(`org-agent-proxy-config-init:${orgId}`) } as const; // all the key prefixes used must be set here to avoid conflict diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 35a5edb0f5a..f879496e131 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1469,6 +1469,7 @@ export const DASHBOARD = { includeFolders: "Whether to include project folders in the response.", includeDynamicSecrets: "Whether to include dynamic project secrets in the response.", includeHoneyTokens: "Whether to include honey tokens in the response.", + includeProxiedServices: "Whether to include proxied services in the response.", includeImports: "Whether to include project secret imports in the response.", includeSecretRotations: "Whether to include project secret rotations in the response." }, @@ -1487,7 +1488,8 @@ export const DASHBOARD = { includeImports: "Whether to include project secret imports in the response.", includeDynamicSecrets: "Whether to include dynamic project secrets in the response.", includeSecretRotations: "Whether to include secret rotations in the response.", - includeHoneyTokens: "Whether to include honey tokens in the response." + includeHoneyTokens: "Whether to include honey tokens in the response.", + includeProxiedServices: "Whether to include proxied services in the response." } } as const; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index a1f765e37f5..74fc31c3e83 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -26,6 +26,8 @@ import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-appr import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-dal"; import { accessApprovalRequestReviewerDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-reviewer-dal"; import { accessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service"; +import { agentProxyCaServiceFactory } from "@app/ee/services/agent-proxy-ca/agent-proxy-ca-service"; +import { orgAgentProxyConfigDALFactory } from "@app/ee/services/agent-proxy-ca/org-agent-proxy-config-dal"; import { aiMcpActivityLogDALFactory } from "@app/ee/services/ai-mcp-activity-log/ai-mcp-activity-log-dal"; import { aiMcpActivityLogServiceFactory } from "@app/ee/services/ai-mcp-activity-log/ai-mcp-activity-log-service"; import { aiMcpEndpointDALFactory } from "@app/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-dal"; @@ -155,6 +157,9 @@ import { projectTemplateGroupMembershipDALFactory } from "@app/ee/services/proje import { projectTemplateIdentityMembershipDALFactory } from "@app/ee/services/project-template/project-template-identity-membership-dal"; import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { projectTemplateUserMembershipDALFactory } from "@app/ee/services/project-template/project-template-user-membership-dal"; +import { proxiedServiceCredentialDALFactory } from "@app/ee/services/proxied-service/proxied-service-credential-dal"; +import { proxiedServiceDALFactory } from "@app/ee/services/proxied-service/proxied-service-dal"; +import { proxiedServiceServiceFactory } from "@app/ee/services/proxied-service/proxied-service-service"; import { rateLimitDALFactory } from "@app/ee/services/rate-limit/rate-limit-dal"; import { rateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-service"; import { instanceRelayConfigDalFactory } from "@app/ee/services/relay/instance-relay-config-dal"; @@ -746,6 +751,10 @@ export const registerRoutes = async ( const honeyTokenDAL = honeyTokenDALFactory(db); const honeyTokenEventDAL = honeyTokenEventDALFactory(db); + const proxiedServiceDAL = proxiedServiceDALFactory(db); + const proxiedServiceCredentialDAL = proxiedServiceCredentialDALFactory(db); + const orgAgentProxyConfigDAL = orgAgentProxyConfigDALFactory(db); + const secretRotationV2DAL = secretRotationV2DALFactory(db, folderDAL); const microsoftTeamsIntegrationDAL = microsoftTeamsIntegrationDALFactory(db); const projectMicrosoftTeamsConfigDAL = projectMicrosoftTeamsConfigDALFactory(db); @@ -2906,6 +2915,22 @@ export const registerRoutes = async ( auditLogService }); + const proxiedServiceService = proxiedServiceServiceFactory({ + proxiedServiceDAL, + proxiedServiceCredentialDAL, + folderDAL, + secretV2BridgeDAL, + permissionService, + licenseService + }); + + const agentProxyCaService = agentProxyCaServiceFactory({ + orgAgentProxyConfigDAL, + kmsService, + licenseService, + permissionService + }); + const webhookService = webhookServiceFactory({ permissionService, webhookDAL, @@ -3919,6 +3944,8 @@ export const registerRoutes = async ( gitHubApp: gitHubAppService, honeyTokenConfig: honeyTokenConfigService, honeyToken: honeyTokenService, + proxiedService: proxiedServiceService, + agentProxyCa: agentProxyCaService, folderCommit: folderCommitService, secretScanningV2: secretScanningV2Service, reminder: reminderService, diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 3a994827f0d..0ebb39163f4 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -10,6 +10,8 @@ import { OrgRolesSchema, ProjectRolesSchema, ProjectsSchema, + ProxiedServiceCredentialsSchema, + ProxiedServicesSchema, SecretApprovalPoliciesSchema, SecretSharingSchema, SecretTagsSchema, @@ -289,6 +291,37 @@ export const SanitizedHoneyTokenSchema = HoneyTokensSchema.pick({ }) }); +export const SanitizedProxiedServiceSchema = ProxiedServicesSchema.pick({ + id: true, + name: true, + hostPattern: true, + isEnabled: true, + folderId: true, + createdAt: true, + updatedAt: true +}).extend({ + environment: z.object({ + id: z.string(), + name: z.string(), + slug: z.string() + }), + folder: z.object({ + path: z.string() + }), + credentials: ProxiedServiceCredentialsSchema.pick({ + id: true, + serviceId: true, + secretKey: true, + role: true, + headerName: true, + headerPrefix: true, + headerPurpose: true, + placeholderKey: true, + placeholderValue: true, + substitutionSurfaces: true + }).array() +}); + export const SanitizedProjectSchema = ProjectsSchema.pick({ id: true, name: true, diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 034125574c6..fe8d867604e 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -18,6 +18,7 @@ import { booleanSchema, SanitizedDynamicSecretSchema, SanitizedHoneyTokenSchema, + SanitizedProxiedServiceSchema, SanitizedTagSchema, SanitizedUserSchema, secretRawSchema @@ -109,13 +110,15 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { includeImports: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeImports), includeSecretRotations: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecretRotations), includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeDynamicSecrets), - includeHoneyTokens: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeHoneyTokens) + includeHoneyTokens: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeHoneyTokens), + includeProxiedServices: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeProxiedServices) }), response: { 200: z.object({ folders: SecretFoldersSchema.extend({ environment: z.string() }).array().optional(), dynamicSecrets: SanitizedDynamicSecretSchema.extend({ environment: z.string() }).array().optional(), honeyTokens: SanitizedHoneyTokenSchema.array().optional(), + proxiedServices: SanitizedProxiedServiceSchema.array().optional(), secretRotations: z .intersection( SecretRotationV2Schema, @@ -217,6 +220,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { totalImportCount: z.number().optional(), totalSecretRotationCount: z.number().optional(), totalHoneyTokenCount: z.number().optional(), + totalProxiedServiceCount: z.number().optional(), totalCount: z.number() }) } @@ -236,7 +240,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { includeImports, includeDynamicSecrets, includeSecretRotations, - includeHoneyTokens + includeHoneyTokens, + includeProxiedServices } = req.query; const environments = req.query.environments.split(","); @@ -268,6 +273,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { | Awaited> | undefined; let honeyTokens: Awaited> | undefined; + let proxiedServices: + | Awaited> + | undefined; let totalFolderCount: number | undefined; let totalDynamicSecretCount: number | undefined; @@ -275,6 +283,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let totalImportCount: number | undefined; let totalSecretRotationCount: number | undefined; let totalHoneyTokenCount: number | undefined; + let totalProxiedServiceCount: number | undefined; if (includeImports) { totalImportCount = await server.services.secretImport.getProjectImportMultiEnvCount({ @@ -366,7 +375,13 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { } } - if (!includeDynamicSecrets && !includeSecrets && !includeSecretRotations && !includeHoneyTokens) + if ( + !includeDynamicSecrets && + !includeSecrets && + !includeSecretRotations && + !includeHoneyTokens && + !includeProxiedServices + ) return { imports, folders, @@ -516,6 +531,42 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { } } + if (includeProxiedServices) { + totalProxiedServiceCount = await server.services.proxiedService.getDashboardProxiedServiceCount( + { + projectId, + search, + environments, + secretPath + }, + req.permission + ); + + if (remainingLimit > 0 && totalProxiedServiceCount > adjustedOffset) { + proxiedServices = await server.services.proxiedService.getDashboardProxiedServices( + { + projectId, + search, + orderBy, + orderDirection, + environments, + secretPath, + limit: remainingLimit, + offset: adjustedOffset + }, + req.permission + ); + + // multi-env: the same service name across envs is one overview row, so decrement by unique names + const uniqueProxiedServiceCount = new Set(proxiedServices.map((svc) => svc.name)).size; + + remainingLimit -= uniqueProxiedServiceCount; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalProxiedServiceCount); + } + } + if (includeSecrets) { // this is the unique count, ie duplicate secrets across envs only count as 1 totalSecretCount = await server.services.secret.getSecretsCountMultiEnv({ @@ -670,12 +721,14 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { imports, secretRotations, honeyTokens, + proxiedServices, totalFolderCount, totalDynamicSecretCount, totalImportCount, totalSecretCount, totalSecretRotationCount, totalHoneyTokenCount, + totalProxiedServiceCount, importedByEnvs, usedBySecretSyncs, totalCount: @@ -684,7 +737,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + (totalSecretRotationCount ?? 0) + - (totalHoneyTokenCount ?? 0) + (totalHoneyTokenCount ?? 0) + + (totalProxiedServiceCount ?? 0) }; } }); @@ -731,7 +785,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeDynamicSecrets), includeImports: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeImports), includeSecretRotations: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecretRotations), - includeHoneyTokens: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeHoneyTokens) + includeHoneyTokens: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeHoneyTokens), + includeProxiedServices: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeProxiedServices) }), response: { 200: z.object({ @@ -751,6 +806,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { folders: SecretFoldersSchema.array().optional(), dynamicSecrets: SanitizedDynamicSecretSchema.array().optional(), honeyTokens: SanitizedHoneyTokenSchema.array().optional(), + proxiedServices: SanitizedProxiedServiceSchema.array().optional(), secretRotations: z .intersection( SecretRotationV2Schema, @@ -840,6 +896,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { .optional(), totalSecretRotationCount: z.number().optional(), totalHoneyTokenCount: z.number().optional(), + totalProxiedServiceCount: z.number().optional(), totalCount: z.number() }) } @@ -860,7 +917,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { includeDynamicSecrets, includeImports, includeSecretRotations, - includeHoneyTokens + includeHoneyTokens, + includeProxiedServices } = req.query; if (!projectId || !environment) throw new BadRequestError({ message: "Missing project id or environment" }); @@ -902,6 +960,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { })[] | undefined; let honeyTokens: Awaited> | undefined; + let proxiedServices: + | Awaited> + | undefined; let totalImportCount: number | undefined; let totalFolderCount: number | undefined; @@ -909,6 +970,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let totalSecretCount: number | undefined; let totalSecretRotationCount: number | undefined; let totalHoneyTokenCount: number | undefined; + let totalProxiedServiceCount: number | undefined; if (includeImports) { totalImportCount = await server.services.secretImport.getProjectImportCount({ @@ -1094,6 +1156,39 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { } } + if (includeProxiedServices) { + totalProxiedServiceCount = await server.services.proxiedService.getDashboardProxiedServiceCount( + { + projectId, + search, + environments: [environment], + secretPath + }, + req.permission + ); + + if (remainingLimit > 0 && totalProxiedServiceCount > adjustedOffset) { + proxiedServices = await server.services.proxiedService.getDashboardProxiedServices( + { + projectId, + search, + orderBy, + orderDirection, + environments: [environment], + secretPath, + limit: remainingLimit, + offset: adjustedOffset + }, + req.permission + ); + + remainingLimit -= proxiedServices.length; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalProxiedServiceCount); + } + } + try { if (includeDynamicSecrets) { totalDynamicSecretCount = await server.services.dynamicSecret.getDynamicSecretCount({ @@ -1283,12 +1378,14 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { secrets, secretRotations, honeyTokens, + proxiedServices, totalImportCount, totalFolderCount, totalDynamicSecretCount, totalSecretCount, totalSecretRotationCount, totalHoneyTokenCount, + totalProxiedServiceCount, importedBy, usedBySecretSyncs, totalCount: @@ -1297,7 +1394,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + (totalSecretRotationCount ?? 0) + - (totalHoneyTokenCount ?? 0) + (totalHoneyTokenCount ?? 0) + + (totalProxiedServiceCount ?? 0) }; } }); diff --git a/backend/src/services/license-client/dual-read/feature-mapping/pki-security.ts b/backend/src/services/license-client/dual-read/feature-mapping/pki-security.ts index 07c8d470495..597dc51c5a1 100644 --- a/backend/src/services/license-client/dual-read/feature-mapping/pki-security.ts +++ b/backend/src/services/license-client/dual-read/feature-mapping/pki-security.ts @@ -76,6 +76,11 @@ export const pkiSecurityMappings: TFeatureMapping[] = [ v1Field: "honeyTokens", extractV1: (p) => p.honeyTokens }, + { + v2Key: "secrets_brokering", + v1Field: "secretsBrokering", + extractV1: (p) => p.secretsBrokering + }, { v2Key: "custom_rate_limits", v1Field: "customRateLimits", diff --git a/backend/src/services/project-role/project-role-fns.ts b/backend/src/services/project-role/project-role-fns.ts index f8157acbe13..4a63bf688b4 100644 --- a/backend/src/services/project-role/project-role-fns.ts +++ b/backend/src/services/project-role/project-role-fns.ts @@ -2,6 +2,8 @@ import { v4 as uuidv4 } from "uuid"; import { ProjectMembershipRole, ProjectType } from "@app/db/schemas"; import { + agentPermissions, + agentProxyPermissions, cryptographicOperatorPermissions, projectAdminPermissions, projectMemberPermissions, @@ -55,6 +57,28 @@ export const getPredefinedRoles = ({ projectId, projectType, roleFilter }: TGetP updatedAt: new Date(), type: ProjectType.KMS }, + { + id: uuidv4(), + projectId, + name: "Agent", + slug: ProjectMembershipRole.Agent, + permissions: agentPermissions, + description: "Proxy traffic through all proxied services in a project", + createdAt: new Date(), + updatedAt: new Date(), + type: ProjectType.SecretManager + }, + { + id: uuidv4(), + projectId, + name: "Agent Proxy", + slug: ProjectMembershipRole.AgentProxy, + permissions: agentProxyPermissions, + description: "Read all secret values in a project. Intended for the agent proxy", + createdAt: new Date(), + updatedAt: new Date(), + type: ProjectType.SecretManager + }, { id: uuidv4(), projectId, diff --git a/backend/src/services/role/project/project-role-factory.ts b/backend/src/services/role/project/project-role-factory.ts index ce74c47fdb7..d8c58d2feef 100644 --- a/backend/src/services/role/project/project-role-factory.ts +++ b/backend/src/services/role/project/project-role-factory.ts @@ -3,6 +3,8 @@ import { v4 as uuidv4 } from "uuid"; import { AccessScope, ActionProjectType, ProjectMembershipRole, ProjectType } from "@app/db/schemas"; import { + agentPermissions, + agentProxyPermissions, cryptographicOperatorPermissions, projectAdminPermissions, projectMemberPermissions, @@ -170,6 +172,28 @@ export const newProjectRoleFactory = ({ projectId, type: ProjectType.KMS }, + { + id: uuidv4(), + name: "Agent", + slug: ProjectMembershipRole.Agent, + permissions: agentPermissions, + description: "Proxy traffic through all proxied services in a project", + createdAt: new Date(), + updatedAt: new Date(), + projectId, + type: ProjectType.SecretManager + }, + { + id: uuidv4(), + name: "Agent Proxy", + slug: ProjectMembershipRole.AgentProxy, + permissions: agentProxyPermissions, + description: "Read all secret values in a project. Intended for the agent proxy", + createdAt: new Date(), + updatedAt: new Date(), + projectId, + type: ProjectType.SecretManager + }, { id: uuidv4(), name: "Viewer", diff --git a/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx b/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx new file mode 100644 index 00000000000..841443ea682 --- /dev/null +++ b/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx @@ -0,0 +1,41 @@ +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@app/components/v3"; + +import { ProxiedServiceForm } from "./forms"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + projectId: string; + environment: string; + secretPath: string; +}; + +export const CreateProxiedServiceModal = ({ + isOpen, + onOpenChange, + projectId, + environment, + secretPath +}: Props) => { + return ( + + + + Add Proxied Service + + Define a service the agent proxy can broker credentials for. + + +
+ onOpenChange(false)} + onCancel={() => onOpenChange(false)} + /> +
+
+
+ ); +}; diff --git a/frontend/src/components/proxied-services/DeleteProxiedServiceModal.tsx b/frontend/src/components/proxied-services/DeleteProxiedServiceModal.tsx new file mode 100644 index 00000000000..d11901edab0 --- /dev/null +++ b/frontend/src/components/proxied-services/DeleteProxiedServiceModal.tsx @@ -0,0 +1,72 @@ +import { Trash2Icon } from "lucide-react"; + +import { createNotification } from "@app/components/notifications"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle +} from "@app/components/v3"; +import { useDeleteProxiedService } from "@app/hooks/api/proxiedServices/mutations"; +import { TDashboardProxiedService } from "@app/hooks/api/proxiedServices/types"; + +type Props = { + proxiedService?: TDashboardProxiedService; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + projectId: string; +}; + +export const DeleteProxiedServiceModal = ({ + proxiedService, + isOpen, + onOpenChange, + projectId +}: Props) => { + const deleteProxiedService = useDeleteProxiedService(); + + if (!proxiedService) return null; + + const handleDelete = async () => { + try { + await deleteProxiedService.mutateAsync({ serviceId: proxiedService.id, projectId }); + createNotification({ + text: `Successfully deleted proxied service "${proxiedService.name}"`, + type: "success" + }); + onOpenChange(false); + } catch { + createNotification({ text: "Failed to delete proxied service", type: "error" }); + } + }; + + return ( + + + + + + + + Are you sure you want to delete {proxiedService.name}? + + + The agent proxy will stop brokering credentials for this service. This action cannot be + undone. + + + + Cancel + + Delete + + + + + ); +}; diff --git a/frontend/src/components/proxied-services/EditProxiedServiceModal.tsx b/frontend/src/components/proxied-services/EditProxiedServiceModal.tsx new file mode 100644 index 00000000000..3292b10f90d --- /dev/null +++ b/frontend/src/components/proxied-services/EditProxiedServiceModal.tsx @@ -0,0 +1,41 @@ +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@app/components/v3"; +import { TDashboardProxiedService } from "@app/hooks/api/proxiedServices/types"; + +import { ProxiedServiceForm } from "./forms"; + +type Props = { + proxiedService?: TDashboardProxiedService; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + projectId: string; +}; + +export const EditProxiedServiceModal = ({ + proxiedService, + isOpen, + onOpenChange, + projectId +}: Props) => { + if (!proxiedService) return null; + + return ( + + + + Edit Proxied Service + Update how the agent proxy brokers this service. + +
+ onOpenChange(false)} + onCancel={() => onOpenChange(false)} + /> +
+
+
+ ); +}; diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx new file mode 100644 index 00000000000..a0e7197e271 --- /dev/null +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -0,0 +1,458 @@ +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { PlusIcon, XIcon } from "lucide-react"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Empty, + EmptyDescription, + Field, + FieldContent, + FieldDescription, + FieldError, + FieldLabel, + IconButton, + Input, + SheetFooter, + Switch, + Tabs, + TabsList, + TabsTrigger +} from "@app/components/v3"; +import { + ProxiedServiceCredentialRole, + ProxiedServiceHeaderPurpose, + ProxiedServiceSubstitutionSurface +} from "@app/hooks/api/proxiedServices/enums"; +import { + useCreateProxiedService, + useUpdateProxiedService +} from "@app/hooks/api/proxiedServices/mutations"; +import { + TDashboardProxiedService, + TProxiedServiceCredentialInput +} from "@app/hooks/api/proxiedServices/types"; + +import { HeaderRewritingMode, proxiedServiceFormSchema, TProxiedServiceForm } from "./schema"; +import { SecretSelect } from "./SecretSelect"; +import { SurfaceSelect } from "./SurfaceSelect"; + +type Props = { + projectId: string; + environment: string; + secretPath: string; + proxiedService?: TDashboardProxiedService; + onComplete: () => void; + onCancel: () => void; +}; + +const genPlaceholder = () => + `placeholder_${Array.from({ length: 12 }, () => "abcdefghijklmnopqrstuvwxyz"[Math.floor(Math.random() * 26)]).join("")}`; + +const toDefaultValues = (svc?: TDashboardProxiedService): TProxiedServiceForm => { + if (!svc) { + return { + name: "", + hostPattern: "", + isEnabled: true, + headerMode: HeaderRewritingMode.Headers, + // pre-fill a bearer header on create (mockup behavior) + headers: [{ secretKey: "", headerName: "Authorization", headerPrefix: "Bearer" }], + substitutions: [] + }; + } + + const headerCreds = svc.credentials.filter( + (c) => c.role === ProxiedServiceCredentialRole.HeaderRewrite + ); + const isBasicAuth = headerCreds.some((c) => c.headerPurpose); + const username = headerCreds.find( + (c) => c.headerPurpose === ProxiedServiceHeaderPurpose.Username + ); + const password = headerCreds.find( + (c) => c.headerPurpose === ProxiedServiceHeaderPurpose.Password + ); + const subs = svc.credentials.filter( + (c) => c.role === ProxiedServiceCredentialRole.CredentialSubstitution + ); + + return { + name: svc.name, + hostPattern: svc.hostPattern, + isEnabled: svc.isEnabled, + headerMode: isBasicAuth ? HeaderRewritingMode.BasicAuth : HeaderRewritingMode.Headers, + headers: isBasicAuth + ? [] + : headerCreds.map((c) => ({ + secretKey: c.secretKey, + headerName: c.headerName ?? "", + headerPrefix: c.headerPrefix ?? "" + })), + basicAuth: isBasicAuth + ? { + usernameSecretKey: username?.secretKey ?? "", + passwordSecretKey: password?.secretKey ?? "" + } + : undefined, + substitutions: subs.map((c) => ({ + placeholderKey: c.placeholderKey ?? "", + placeholderValue: c.placeholderValue ?? genPlaceholder(), + secretKey: c.secretKey, + surfaces: (c.substitutionSurfaces ?? []) as ProxiedServiceSubstitutionSurface[] + })) + }; +}; + +const toCredentials = (form: TProxiedServiceForm): TProxiedServiceCredentialInput[] => { + const credentials: TProxiedServiceCredentialInput[] = []; + + if (form.headerMode === HeaderRewritingMode.BasicAuth) { + if (form.basicAuth?.usernameSecretKey) { + credentials.push({ + secretKey: form.basicAuth.usernameSecretKey, + role: ProxiedServiceCredentialRole.HeaderRewrite, + headerPurpose: ProxiedServiceHeaderPurpose.Username + }); + } + if (form.basicAuth?.passwordSecretKey) { + credentials.push({ + secretKey: form.basicAuth.passwordSecretKey, + role: ProxiedServiceCredentialRole.HeaderRewrite, + headerPurpose: ProxiedServiceHeaderPurpose.Password + }); + } + } else { + form.headers.forEach((h) => { + credentials.push({ + secretKey: h.secretKey, + role: ProxiedServiceCredentialRole.HeaderRewrite, + headerName: h.headerName, + headerPrefix: h.headerPrefix || null + }); + }); + } + + form.substitutions.forEach((s) => { + credentials.push({ + secretKey: s.secretKey, + role: ProxiedServiceCredentialRole.CredentialSubstitution, + placeholderKey: s.placeholderKey, + placeholderValue: s.placeholderValue, + substitutionSurfaces: s.surfaces + }); + }); + + return credentials; +}; + +export const ProxiedServiceForm = ({ + projectId, + environment, + secretPath, + proxiedService, + onComplete, + onCancel +}: Props) => { + const isEdit = Boolean(proxiedService); + const createProxiedService = useCreateProxiedService(); + const updateProxiedService = useUpdateProxiedService(); + + const { + control, + handleSubmit, + watch, + register, + formState: { errors, isSubmitting } + } = useForm({ + resolver: zodResolver(proxiedServiceFormSchema), + defaultValues: toDefaultValues(proxiedService) + }); + + const headerMode = watch("headerMode"); + + const headerFields = useFieldArray({ control, name: "headers" }); + const substitutionFields = useFieldArray({ control, name: "substitutions" }); + + const onSubmit = async (form: TProxiedServiceForm) => { + const credentials = toCredentials(form); + try { + if (isEdit && proxiedService) { + await updateProxiedService.mutateAsync({ + serviceId: proxiedService.id, + projectId, + name: form.name, + hostPattern: form.hostPattern, + isEnabled: form.isEnabled, + credentials + }); + } else { + await createProxiedService.mutateAsync({ + projectId, + environment, + secretPath, + name: form.name, + hostPattern: form.hostPattern, + isEnabled: form.isEnabled, + credentials + }); + } + createNotification({ + text: `Successfully ${isEdit ? "updated" : "created"} proxied service`, + type: "success" + }); + onComplete(); + } catch { + createNotification({ + text: `Failed to ${isEdit ? "update" : "create"} proxied service`, + type: "error" + }); + } + }; + + return ( +
+ ( + + Enabled + + + )} + /> + + + Service name + + + Lowercase letters, numbers, and hyphens only. + {errors.name && {errors.name.message}} + + + + + Host pattern + + + + Outbound requests to a matching host are intercepted. Wildcards supported (e.g. + *.stripe.com). + + {errors.hostPattern && {errors.hostPattern.message}} + + + + {/* Header Rewriting */} +
+
+
+

Header Rewriting

+

Sets these headers on every request.

+
+ ( + + + Headers + Basic Auth + + + )} + /> +
+ + {headerMode === HeaderRewritingMode.Headers ? ( +
+ {headerFields.fields.map((row, i) => ( +
+ + Name + + + + Prefix + + + + Value + ( + + )} + /> + + headerFields.remove(i)} + > + + +
+ ))} +
+ +
+
+ ) : ( +
+ + Username + ( + + )} + /> + + + Password + ( + + )} + /> + +
+ )} +
+ + {/* Credential Substitution */} +
+
+

Credential Substitution

+

+ Swap a placeholder in the request for the real credential, on the wire. +

+
+ + {substitutionFields.fields.length === 0 ? ( + + No substitutions added + + ) : ( + substitutionFields.fields.map((row, i) => ( +
+
+ + Set env var to the placeholder + + + + Placeholder value + } + /> + + substitutionFields.remove(i)} + > + + +
+ + and replace it in + ( + + )} + /> + + + with value of + ( + + )} + /> + +
+ )) + )} +
+ +
+
+ + + + + + + ); +}; diff --git a/frontend/src/components/proxied-services/forms/SecretSelect.tsx b/frontend/src/components/proxied-services/forms/SecretSelect.tsx new file mode 100644 index 00000000000..88b3f7f7b00 --- /dev/null +++ b/frontend/src/components/proxied-services/forms/SecretSelect.tsx @@ -0,0 +1,49 @@ +import { KeyIcon } from "lucide-react"; + +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@app/components/v3"; +import { useGetProjectSecrets } from "@app/hooks/api/secrets/queries"; + +type Props = { + projectId: string; + environment: string; + secretPath: string; + value?: string; + onChange: (value: string) => void; + isDisabled?: boolean; + placeholder?: string; +}; + +// Picks a secret key from the current folder. The key icon appears inside the dropdown +// options (to signal these are secret references) but not in the collapsed trigger. +export const SecretSelect = ({ + projectId, + environment, + secretPath, + value, + onChange, + isDisabled, + placeholder = "Select secret" +}: Props) => { + const { data: secrets = [] } = useGetProjectSecrets({ + projectId, + environment, + secretPath, + viewSecretValue: false + }); + + return ( + + ); +}; diff --git a/frontend/src/components/proxied-services/forms/SurfaceSelect.tsx b/frontend/src/components/proxied-services/forms/SurfaceSelect.tsx new file mode 100644 index 00000000000..50e888e710b --- /dev/null +++ b/frontend/src/components/proxied-services/forms/SurfaceSelect.tsx @@ -0,0 +1,74 @@ +import { ChevronDownIcon, XIcon } from "lucide-react"; + +import { + Badge, + Button, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger +} from "@app/components/v3"; +import { ProxiedServiceSubstitutionSurface } from "@app/hooks/api/proxiedServices/enums"; + +const SURFACE_LABELS: Record = { + [ProxiedServiceSubstitutionSurface.Path]: "Path", + [ProxiedServiceSubstitutionSurface.Query]: "Query", + [ProxiedServiceSubstitutionSurface.Body]: "Body", + [ProxiedServiceSubstitutionSurface.Header]: "Header" +}; + +const ALL_SURFACES = Object.values(ProxiedServiceSubstitutionSurface); + +type Props = { + value: ProxiedServiceSubstitutionSurface[]; + onChange: (value: ProxiedServiceSubstitutionSurface[]) => void; + isDisabled?: boolean; +}; + +// Multi-select for substitution surfaces: selected values show as removable chips in the trigger, +// the dropdown lists all surfaces as checkbox items. +export const SurfaceSelect = ({ value, onChange, isDisabled }: Props) => { + const toggle = (surface: ProxiedServiceSubstitutionSurface) => { + onChange(value.includes(surface) ? value.filter((s) => s !== surface) : [...value, surface]); + }; + + return ( + + + + + + {ALL_SURFACES.map((surface) => ( + toggle(surface)} + onSelect={(e) => e.preventDefault()} + > + {SURFACE_LABELS[surface]} + + ))} + + + ); +}; diff --git a/frontend/src/components/proxied-services/forms/index.ts b/frontend/src/components/proxied-services/forms/index.ts new file mode 100644 index 00000000000..5abab58fd03 --- /dev/null +++ b/frontend/src/components/proxied-services/forms/index.ts @@ -0,0 +1 @@ +export * from "./ProxiedServiceForm"; diff --git a/frontend/src/components/proxied-services/forms/schema.ts b/frontend/src/components/proxied-services/forms/schema.ts new file mode 100644 index 00000000000..250bc1c8d4d --- /dev/null +++ b/frontend/src/components/proxied-services/forms/schema.ts @@ -0,0 +1,94 @@ +import { z } from "zod"; + +import { + ProxiedServiceHeaderPurpose, + ProxiedServiceSubstitutionSurface +} from "@app/hooks/api/proxiedServices/enums"; + +const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +// Field-level schemas are permissive; required-ness is enforced conditionally in the +// top-level superRefine so that fields belonging to the inactive header mode (which are not +// rendered) don't block submission. +export const headerCredentialSchema = z.object({ + secretKey: z.string().trim(), + headerName: z.string().trim(), + headerPrefix: z.string().trim().optional() +}); + +export const basicAuthSchema = z.object({ + usernameSecretKey: z.string().trim(), + passwordSecretKey: z.string().trim() +}); + +export const substitutionSchema = z.object({ + 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") +}); + +export enum HeaderRewritingMode { + Headers = "headers", + BasicAuth = "basic-auth" +} + +export const proxiedServiceFormSchema = z + .object({ + name: z + .string() + .trim() + .min(1, "Name is required") + .max(64) + .regex(slugRegex, "Lowercase letters, numbers, and hyphens only"), + hostPattern: z.string().trim().min(1, "Host pattern is required"), + isEnabled: z.boolean().default(true), + headerMode: z.nativeEnum(HeaderRewritingMode).default(HeaderRewritingMode.Headers), + headers: z.array(headerCredentialSchema).default([]), + basicAuth: basicAuthSchema.optional(), + substitutions: z.array(substitutionSchema).default([]) + }) + .superRefine((form, ctx) => { + if (form.headerMode === HeaderRewritingMode.Headers) { + // only validate the rows the user actually sees in Headers mode + form.headers.forEach((row, i) => { + if (!row.secretKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Select a secret", + path: ["headers", i, "secretKey"] + }); + } + if (!row.headerName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Header name is required", + path: ["headers", i, "headerName"] + }); + } + }); + } else { + if (!form.basicAuth?.usernameSecretKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Select a secret", + path: ["basicAuth", "usernameSecretKey"] + }); + } + if (!form.basicAuth?.passwordSecretKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Select a secret", + path: ["basicAuth", "passwordSecretKey"] + }); + } + } + }); + +export type TProxiedServiceForm = z.infer; + +export { ProxiedServiceHeaderPurpose }; diff --git a/frontend/src/components/proxied-services/index.ts b/frontend/src/components/proxied-services/index.ts new file mode 100644 index 00000000000..34560889048 --- /dev/null +++ b/frontend/src/components/proxied-services/index.ts @@ -0,0 +1,3 @@ +export * from "./CreateProxiedServiceModal"; +export * from "./DeleteProxiedServiceModal"; +export * from "./EditProxiedServiceModal"; diff --git a/frontend/src/context/ProjectPermissionContext/index.tsx b/frontend/src/context/ProjectPermissionContext/index.tsx index 6d9a0783f05..e65b00e79f8 100644 --- a/frontend/src/context/ProjectPermissionContext/index.tsx +++ b/frontend/src/context/ProjectPermissionContext/index.tsx @@ -22,6 +22,7 @@ export { ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiSyncActions, ProjectPermissionPkiTemplateActions, + ProjectPermissionProxiedServiceActions, ProjectPermissionSshHostActions, ProjectPermissionSub } from "./types"; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 24bcc341858..26f64668e31 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -285,6 +285,14 @@ export enum ProjectPermissionHoneyTokenActions { Revoke = "revoke" } +export enum ProjectPermissionProxiedServiceActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + Proxy = "proxy" +} + export enum ProjectPermissionApprovalRequestActions { Read = "read", Create = "create" @@ -346,6 +354,7 @@ export type ConditionalProjectPermissionSubject = | ProjectPermissionSub.Groups | ProjectPermissionSub.Commits | ProjectPermissionSub.HoneyTokens + | ProjectPermissionSub.ProxiedServices | ProjectPermissionSub.ProjectFolderGrant; export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = { @@ -440,6 +449,7 @@ export enum ProjectPermissionSub { McpServers = "mcp-servers", McpActivityLogs = "mcp-activity-logs", HoneyTokens = "honey-tokens", + ProxiedServices = "proxied-services", ApprovalRequests = "approval-requests", ApprovalRequestGrants = "approval-request-grants", ProjectFolderGrant = "project-folder-grant", @@ -482,6 +492,11 @@ export type HoneyTokenSubjectFields = { secretPath: string; }; +export type ProxiedServiceSubjectFields = { + environment: string; + secretPath: string; +}; + export type ProjectFolderGrantSubjectFields = { environment: string; secretPath: string; @@ -719,6 +734,13 @@ export type ProjectPermissionSet = | (ForcedSubject & HoneyTokenSubjectFields) ) ] + | [ + ProjectPermissionProxiedServiceActions, + ( + | ProjectPermissionSub.ProxiedServices + | (ForcedSubject & ProxiedServiceSubjectFields) + ) + ] | [ ProjectPermissionMcpEndpointActions, ( diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx index 09dd9dfc59a..e0d0d18e630 100644 --- a/frontend/src/hooks/api/dashboard/queries.tsx +++ b/frontend/src/hooks/api/dashboard/queries.tsx @@ -207,6 +207,7 @@ export const useGetProjectSecretsOverview = ( includeDynamicSecrets, includeSecretRotations, includeHoneyTokens, + includeProxiedServices, environments }: TGetDashboardProjectSecretsOverviewDTO, options?: Omit< @@ -240,6 +241,7 @@ export const useGetProjectSecretsOverview = ( includeDynamicSecrets, includeSecretRotations, includeHoneyTokens, + includeProxiedServices, environments }), queryFn: async () => { @@ -258,6 +260,7 @@ export const useGetProjectSecretsOverview = ( includeDynamicSecrets, includeSecretRotations, includeHoneyTokens, + includeProxiedServices, environments }); @@ -319,6 +322,7 @@ export const useGetProjectSecretsDetails = ( includeDynamicSecrets, includeSecretRotations, includeHoneyTokens, + includeProxiedServices, tags }: TGetDashboardProjectSecretsDetailsDTO, options?: Omit< @@ -358,6 +362,7 @@ export const useGetProjectSecretsDetails = ( includeDynamicSecrets, includeSecretRotations, includeHoneyTokens, + includeProxiedServices, tags }), queryFn: async () => { @@ -376,6 +381,7 @@ export const useGetProjectSecretsDetails = ( includeDynamicSecrets, includeSecretRotations, includeHoneyTokens, + includeProxiedServices, tags }); diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts index 2975588efd8..9719a940826 100644 --- a/frontend/src/hooks/api/dashboard/types.ts +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -2,6 +2,7 @@ import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionCo import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { TDashboardHoneyToken } from "@app/hooks/api/honeyTokens/types"; +import { TDashboardProxiedService } from "@app/hooks/api/proxiedServices/types"; import { TSecretFolder } from "@app/hooks/api/secretFolders/types"; import { TSecretImport } from "@app/hooks/api/secretImports/types"; import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; @@ -22,6 +23,8 @@ export type DashboardProjectSecretsOverviewResponse = { totalSecretRotationCount?: number; honeyTokens?: TDashboardHoneyToken[]; totalHoneyTokenCount?: number; + proxiedServices?: TDashboardProxiedService[]; + totalProxiedServiceCount?: number; totalCount: number; totalUniqueSecretsInPage: number; totalUniqueDynamicSecretsInPage: number; @@ -49,12 +52,14 @@ export type DashboardProjectSecretsDetailsResponse = { secrets: (SecretV3Raw | null)[]; })[]; honeyTokens?: TDashboardHoneyToken[]; + proxiedServices?: TDashboardProxiedService[]; totalImportCount?: number; totalFolderCount?: number; totalDynamicSecretCount?: number; totalSecretCount?: number; totalSecretRotationCount?: number; totalHoneyTokenCount?: number; + totalProxiedServiceCount?: number; totalCount: number; importedBy?: ProjectSecretsImportedBy[]; usedBySecretSyncs?: UsedBySecretSyncs[]; @@ -83,6 +88,7 @@ export type DashboardProjectSecretsOverview = Omit< secrets: (SecretV3RawSanitized | null)[]; })[]; honeyTokens?: TDashboardHoneyToken[]; + proxiedServices?: TDashboardProxiedService[]; }; export type DashboardProjectSecretsDetails = Omit< @@ -94,6 +100,7 @@ export type DashboardProjectSecretsDetails = Omit< secrets: (SecretV3RawSanitized | null)[]; })[]; honeyTokens?: TDashboardHoneyToken[]; + proxiedServices?: TDashboardProxiedService[]; }; export enum DashboardSecretsOrderBy { @@ -115,6 +122,7 @@ export type TGetDashboardProjectSecretsOverviewDTO = { includeImports?: boolean; includeSecretRotations?: boolean; includeHoneyTokens?: boolean; + includeProxiedServices?: boolean; environments: string[]; }; diff --git a/frontend/src/hooks/api/proxiedServices/enums.ts b/frontend/src/hooks/api/proxiedServices/enums.ts new file mode 100644 index 00000000000..4ef88988224 --- /dev/null +++ b/frontend/src/hooks/api/proxiedServices/enums.ts @@ -0,0 +1,16 @@ +export enum ProxiedServiceCredentialRole { + HeaderRewrite = "header-rewrite", + CredentialSubstitution = "credential-substitution" +} + +export enum ProxiedServiceHeaderPurpose { + Username = "username", + Password = "password" +} + +export enum ProxiedServiceSubstitutionSurface { + Header = "header", + Path = "path", + Query = "query", + Body = "body" +} diff --git a/frontend/src/hooks/api/proxiedServices/index.ts b/frontend/src/hooks/api/proxiedServices/index.ts new file mode 100644 index 00000000000..f49a872a5ad --- /dev/null +++ b/frontend/src/hooks/api/proxiedServices/index.ts @@ -0,0 +1,4 @@ +export * from "./enums"; +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/proxiedServices/mutations.tsx b/frontend/src/hooks/api/proxiedServices/mutations.tsx new file mode 100644 index 00000000000..3dffe6741c7 --- /dev/null +++ b/frontend/src/hooks/api/proxiedServices/mutations.tsx @@ -0,0 +1,62 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; + +import { proxiedServiceKeys } from "./queries"; +import { + TCreateProxiedServiceDTO, + TDeleteProxiedServiceDTO, + TProxiedService, + TUpdateProxiedServiceDTO +} from "./types"; + +const invalidate = (queryClient: ReturnType) => { + queryClient.invalidateQueries({ queryKey: proxiedServiceKeys.all }); + queryClient.invalidateQueries({ queryKey: dashboardKeys.all() }); +}; + +export const useCreateProxiedService = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post<{ service: TProxiedService }>( + "/api/v1/proxied-services", + dto + ); + return data.service; + }, + onSuccess: () => invalidate(queryClient) + }); +}; + +export const useUpdateProxiedService = () => { + const queryClient = useQueryClient(); + + return useMutation({ + // projectId is only used for cache scoping, not sent in the request body + mutationFn: async ({ serviceId, projectId: _projectId, ...body }) => { + const { data } = await apiRequest.patch<{ service: TProxiedService }>( + `/api/v1/proxied-services/${serviceId}`, + body + ); + return data.service; + }, + onSuccess: () => invalidate(queryClient) + }); +}; + +export const useDeleteProxiedService = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ serviceId }) => { + const { data } = await apiRequest.delete<{ service: TProxiedService }>( + `/api/v1/proxied-services/${serviceId}` + ); + return data.service; + }, + onSuccess: () => invalidate(queryClient) + }); +}; diff --git a/frontend/src/hooks/api/proxiedServices/queries.tsx b/frontend/src/hooks/api/proxiedServices/queries.tsx new file mode 100644 index 00000000000..41def7814f4 --- /dev/null +++ b/frontend/src/hooks/api/proxiedServices/queries.tsx @@ -0,0 +1,48 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TListProxiedServicesDTO, TProxiedService } from "./types"; + +export const proxiedServiceKeys = { + all: ["proxiedServices"] as const, + list: ({ projectId, environment, secretPath }: TListProxiedServicesDTO) => + [...proxiedServiceKeys.all, "list", projectId, environment, secretPath] as const, + byId: (serviceId: string) => [...proxiedServiceKeys.all, "byId", serviceId] as const +}; + +export const useGetProxiedServices = ({ + projectId, + environment, + secretPath, + enabled = true +}: TListProxiedServicesDTO & { enabled?: boolean }) => + useQuery({ + queryKey: proxiedServiceKeys.list({ projectId, environment, secretPath }), + queryFn: async () => { + const { data } = await apiRequest.get<{ services: TProxiedService[] }>( + "/api/v1/proxied-services", + { params: { projectId, environment, secretPath } } + ); + return data.services; + }, + enabled + }); + +export const useGetProxiedServiceById = ({ + serviceId, + enabled = true +}: { + serviceId: string; + enabled?: boolean; +}) => + useQuery({ + queryKey: proxiedServiceKeys.byId(serviceId), + queryFn: async () => { + const { data } = await apiRequest.get<{ service: TProxiedService }>( + `/api/v1/proxied-services/${serviceId}` + ); + return data.service; + }, + enabled + }); diff --git a/frontend/src/hooks/api/proxiedServices/types.ts b/frontend/src/hooks/api/proxiedServices/types.ts new file mode 100644 index 00000000000..6c5106a0481 --- /dev/null +++ b/frontend/src/hooks/api/proxiedServices/types.ts @@ -0,0 +1,83 @@ +import { + ProxiedServiceCredentialRole, + ProxiedServiceHeaderPurpose, + ProxiedServiceSubstitutionSurface +} from "./enums"; + +export type TProxiedServiceCredential = { + id: string; + serviceId: string; + secretKey: string; + role: ProxiedServiceCredentialRole; + headerName?: string | null; + headerPrefix?: string | null; + headerPurpose?: ProxiedServiceHeaderPurpose | null; + placeholderKey?: string | null; + placeholderValue?: string | null; + substitutionSurfaces?: ProxiedServiceSubstitutionSurface[] | null; +}; + +export type TProxiedService = { + id: string; + name: string; + hostPattern: string; + isEnabled: boolean; + folderId: string; + createdAt: string; + updatedAt: string; + canProxy?: boolean; + credentials: TProxiedServiceCredential[]; +}; + +// Shape returned by the dashboard aggregate endpoint (includes environment + folder path). +export type TDashboardProxiedService = TProxiedService & { + environment: { + id: string; + name: string; + slug: string; + }; + folder: { + path: string; + }; +}; + +export type TProxiedServiceCredentialInput = { + secretKey: string; + role: ProxiedServiceCredentialRole; + headerName?: string | null; + headerPrefix?: string | null; + headerPurpose?: ProxiedServiceHeaderPurpose | null; + placeholderKey?: string | null; + placeholderValue?: string | null; + substitutionSurfaces?: ProxiedServiceSubstitutionSurface[] | null; +}; + +export type TCreateProxiedServiceDTO = { + projectId: string; + environment: string; + secretPath: string; + name: string; + hostPattern: string; + isEnabled?: boolean; + credentials: TProxiedServiceCredentialInput[]; +}; + +export type TUpdateProxiedServiceDTO = { + serviceId: string; + projectId: string; + name?: string; + hostPattern?: string; + isEnabled?: boolean; + credentials?: TProxiedServiceCredentialInput[]; +}; + +export type TDeleteProxiedServiceDTO = { + serviceId: string; + projectId: string; +}; + +export type TListProxiedServicesDTO = { + projectId: string; + environment: string; + secretPath: string; +}; diff --git a/frontend/src/hooks/api/roles/types.ts b/frontend/src/hooks/api/roles/types.ts index 3a6f72ff0b6..ac48b774316 100644 --- a/frontend/src/hooks/api/roles/types.ts +++ b/frontend/src/hooks/api/roles/types.ts @@ -5,7 +5,9 @@ export enum ProjectMembershipRole { Viewer = "viewer", NoAccess = "no-access", SshHostBootstrapper = "ssh-host-bootstrapper", - KmsCryptographicOperator = "cryptographic-operator" + KmsCryptographicOperator = "cryptographic-operator", + Agent = "agent", + AgentProxy = "agent-proxy" } export type TGetProjectRolesDTO = { diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 2c75be35e64..cca27ef1bc1 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -81,4 +81,5 @@ export type SubscriptionPlan = { emailDomainVerification: boolean; honeyTokens: boolean; honeyTokenLimit: number; + secretsBrokering: boolean; }; diff --git a/frontend/src/hooks/utils/secrets-overview.tsx b/frontend/src/hooks/utils/secrets-overview.tsx index b1636417462..246b0a0dd13 100644 --- a/frontend/src/hooks/utils/secrets-overview.tsx +++ b/frontend/src/hooks/utils/secrets-overview.tsx @@ -258,3 +258,37 @@ export const useHoneyTokenOverview = ( getHoneyTokenByName }; }; + +export const useProxiedServiceOverview = ( + proxiedServices: DashboardProjectSecretsOverview["proxiedServices"] +) => { + const proxiedServiceNames = useMemo(() => { + const names = new Set(); + proxiedServices?.forEach((svc) => { + names.add(svc.name); + }); + return [...names]; + }, [proxiedServices]); + + const isProxiedServicePresentInEnv = useCallback( + (name: string, env: string) => { + return Boolean( + proxiedServices?.find((svc) => svc.name === name && svc.environment.slug === env) + ); + }, + [proxiedServices] + ); + + const getProxiedServiceByName = useCallback( + (env: string, name: string) => { + return proxiedServices?.find((svc) => svc.environment.slug === env && svc.name === name); + }, + [proxiedServices] + ); + + return { + proxiedServiceNames, + isProxiedServicePresentInEnv, + getProxiedServiceByName + }; +}; diff --git a/frontend/src/lib/fn/permission.ts b/frontend/src/lib/fn/permission.ts index cfc1d476d31..67c97a9e06a 100644 --- a/frontend/src/lib/fn/permission.ts +++ b/frontend/src/lib/fn/permission.ts @@ -152,6 +152,7 @@ const PERMISSION_DISPLAY_NAMES: Record = { [ProjectPermissionSub.AppConnections]: "App Connections", [ProjectPermissionSub.McpEndpoints]: "MCP Endpoints", [ProjectPermissionSub.HoneyTokens]: "Honey Tokens", + [ProjectPermissionSub.ProxiedServices]: "Proxied Services", [ProjectPermissionSub.Role]: "Roles", [ProjectPermissionSub.Member]: "User Management", [ProjectPermissionSub.Groups]: "Groups", diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionConditions.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionConditions.tsx index e68dc1e8d5f..2c2d486c808 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionConditions.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionConditions.tsx @@ -10,6 +10,7 @@ type Props = { | ProjectPermissionSub.SecretImports | ProjectPermissionSub.Commits | ProjectPermissionSub.HoneyTokens + | ProjectPermissionSub.ProxiedServices | ProjectPermissionSub.ProjectFolderGrant; }; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index f5a83c04eed..aeac3caaf82 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -33,6 +33,7 @@ import { ProjectPermissionPkiSyncActions, ProjectPermissionPkiTemplateActions, ProjectPermissionProjectFolderGrantActions, + ProjectPermissionProxiedServiceActions, ProjectPermissionSecretActions, ProjectPermissionSecretApprovalRequestActions, ProjectPermissionSecretEventActions, @@ -74,6 +75,14 @@ const HoneyTokenPolicyActionSchema = z.object({ [ProjectPermissionHoneyTokenActions.Revoke]: z.boolean().optional() }); +const ProxiedServicePolicyActionSchema = z.object({ + [ProjectPermissionProxiedServiceActions.Read]: z.boolean().optional(), + [ProjectPermissionProxiedServiceActions.Create]: z.boolean().optional(), + [ProjectPermissionProxiedServiceActions.Edit]: z.boolean().optional(), + [ProjectPermissionProxiedServiceActions.Delete]: z.boolean().optional(), + [ProjectPermissionProxiedServiceActions.Proxy]: z.boolean().optional() +}); + const CertificatePolicyActionSchema = z.object({ [ProjectPermissionCertificateActions.Create]: z.boolean().optional(), [ProjectPermissionCertificateActions.Delete]: z.boolean().optional(), @@ -701,6 +710,12 @@ export const projectRoleFormSchema = z.object({ }) .array() .default([]), + [ProjectPermissionSub.ProxiedServices]: ProxiedServicePolicyActionSchema.extend({ + inverted: z.boolean().optional(), + conditions: ConditionSchema + }) + .array() + .default([]), [ProjectPermissionSub.Settings]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Environments]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.AuditLogs]: AuditLogsPolicyActionSchema.array().default([]), @@ -884,6 +899,7 @@ type TConditionalFields = | ProjectPermissionSub.Groups | ProjectPermissionSub.Commits | ProjectPermissionSub.HoneyTokens + | ProjectPermissionSub.ProxiedServices | ProjectPermissionSub.ProjectFolderGrant; export const isConditionalSubjects = ( @@ -911,6 +927,7 @@ export const isConditionalSubjects = ( subject === ProjectPermissionSub.Groups || subject === ProjectPermissionSub.Commits || subject === ProjectPermissionSub.HoneyTokens || + subject === ProjectPermissionSub.ProxiedServices || subject === ProjectPermissionSub.ProjectFolderGrant; const CONDITION_DISPLAY_ORDER = [ @@ -1031,6 +1048,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { ProjectPermissionSub.Webhooks, ProjectPermissionSub.ServiceTokens, ProjectPermissionSub.HoneyTokens, + ProjectPermissionSub.ProxiedServices, ProjectPermissionSub.Settings, ProjectPermissionSub.Environments, ProjectPermissionSub.AuditLogs, @@ -1404,6 +1422,29 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { return; } + if (subject === ProjectPermissionSub.ProxiedServices) { + formVal[subject]!.push({ + [ProjectPermissionProxiedServiceActions.Read]: action.includes( + ProjectPermissionProxiedServiceActions.Read + ), + [ProjectPermissionProxiedServiceActions.Create]: action.includes( + ProjectPermissionProxiedServiceActions.Create + ), + [ProjectPermissionProxiedServiceActions.Edit]: action.includes( + ProjectPermissionProxiedServiceActions.Edit + ), + [ProjectPermissionProxiedServiceActions.Delete]: action.includes( + ProjectPermissionProxiedServiceActions.Delete + ), + [ProjectPermissionProxiedServiceActions.Proxy]: action.includes( + ProjectPermissionProxiedServiceActions.Proxy + ), + conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [], + inverted + }); + return; + } + if (subject === ProjectPermissionSub.ProjectFolderGrant) { formVal[subject]!.push({ [ProjectPermissionProjectFolderGrantActions.ReadGrant]: action.includes( @@ -2450,6 +2491,37 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { } ] }, + [ProjectPermissionSub.ProxiedServices]: { + title: "Proxied Services", + description: "Manage proxied services and route agent traffic through them", + actions: [ + { + label: "Read", + value: ProjectPermissionProxiedServiceActions.Read, + description: "View proxied services" + }, + { + label: "Create", + value: ProjectPermissionProxiedServiceActions.Create, + description: "Create proxied services" + }, + { + label: "Modify", + value: ProjectPermissionProxiedServiceActions.Edit, + description: "Update proxied service configuration" + }, + { + label: "Remove", + value: ProjectPermissionProxiedServiceActions.Delete, + description: "Delete proxied services" + }, + { + label: "Proxy", + value: ProjectPermissionProxiedServiceActions.Proxy, + description: "Route traffic through proxied services (for agent identities)" + } + ] + }, [ProjectPermissionSub.Settings]: { title: "Settings", description: "Configure project-level settings and preferences", @@ -3373,6 +3445,7 @@ const SecretsManagerPermissionSubjects = (enabled = false) => ({ [ProjectPermissionSub.SecretRotation]: enabled, [ProjectPermissionSub.ServiceTokens]: enabled, [ProjectPermissionSub.HoneyTokens]: enabled, + [ProjectPermissionSub.ProxiedServices]: enabled, [ProjectPermissionSub.Commits]: enabled, [ProjectPermissionSub.Insights]: enabled, [ProjectPermissionSub.SecretEventSubscriptions]: enabled, @@ -3842,6 +3915,10 @@ export const RoleTemplates: Record = { { subject: ProjectPermissionSub.HoneyTokens, actions: [ProjectPermissionHoneyTokenActions.Read] + }, + { + subject: ProjectPermissionSub.ProxiedServices, + actions: [ProjectPermissionProxiedServiceActions.Read] } ] }, @@ -3907,6 +3984,10 @@ export const RoleTemplates: Record = { { subject: ProjectPermissionSub.HoneyTokens, actions: Object.values(ProjectPermissionHoneyTokenActions) + }, + { + subject: ProjectPermissionSub.ProxiedServices, + actions: Object.values(ProjectPermissionProxiedServiceActions) } ] }, @@ -3931,6 +4012,10 @@ export const RoleTemplates: Record = { subject: ProjectPermissionSub.HoneyTokens, actions: Object.values(ProjectPermissionHoneyTokenActions) }, + { + subject: ProjectPermissionSub.ProxiedServices, + actions: Object.values(ProjectPermissionProxiedServiceActions) + }, { subject: ProjectPermissionSub.Webhooks, actions: Object.values(ProjectPermissionActions) diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 8c026b30c49..388fb0ec556 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -36,6 +36,11 @@ import { import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; +import { + CreateProxiedServiceModal, + DeleteProxiedServiceModal, + EditProxiedServiceModal +} from "@app/components/proxied-services"; import { CreateSecretRotationV2Modal } from "@app/components/secret-rotations-v2"; import { DeleteSecretRotationV2Modal } from "@app/components/secret-rotations-v2/DeleteSecretRotationV2Modal"; import { EditSecretRotationV2Modal } from "@app/components/secret-rotations-v2/EditSecretRotationV2Modal"; @@ -157,6 +162,7 @@ import { TDashboardHoneyToken } from "@app/hooks/api/honeyTokens/types"; import { useImportDopplerSecrets, useImportVaultSecrets } from "@app/hooks/api/migration"; import { ExternalMigrationImportStatus } from "@app/hooks/api/migration/types"; import { ProjectType, ProjectVersion } from "@app/hooks/api/projects/types"; +import { TDashboardProxiedService } from "@app/hooks/api/proxiedServices/types"; import { useGetSecretApprovalRequestCount, useGetSecretApprovalRequests @@ -186,6 +192,7 @@ import { useDynamicSecretOverview, useFolderOverview, useHoneyTokenOverview, + useProxiedServiceOverview, useSecretImportOverview, useSecretOverview, useSecretRotationOverview @@ -228,6 +235,7 @@ import { FolderBreadcrumb, FolderTableRow, HoneyTokenTableRow, + ProxiedServiceTableRow, ResourceCount, ResourceFilter, ResourceSearchInput, @@ -258,7 +266,8 @@ export enum RowType { Secret = "secret", SecretRotation = "rotation", SecretImport = "import", - HoneyToken = "honeyToken" + HoneyToken = "honeyToken", + ProxiedService = "proxiedService" } type Filter = { @@ -271,7 +280,8 @@ const DEFAULT_FILTER_STATE = { [RowType.Secret]: false, [RowType.SecretRotation]: false, [RowType.SecretImport]: false, - [RowType.HoneyToken]: false + [RowType.HoneyToken]: false, + [RowType.ProxiedService]: false }; // const DEFAULT_COLLAPSED_HEADER_HEIGHT = 120; @@ -661,6 +671,9 @@ const OverviewPageContent = () => { includeImports: isFilteredByResources ? (filter[RowType.SecretImport] ?? true) : true, includeSecretRotations: isFilteredByResources ? filter.rotation : true, includeHoneyTokens: isFilteredByResources ? (filter[RowType.HoneyToken] ?? true) : true, + includeProxiedServices: isFilteredByResources + ? (filter[RowType.ProxiedService] ?? true) + : true, search: searchFilter, tags: tagFilter, limit, @@ -675,6 +688,7 @@ const OverviewPageContent = () => { dynamicSecrets, secretRotations, honeyTokens, + proxiedServices, totalFolderCount, totalSecretCount, totalDynamicSecretCount, @@ -732,6 +746,9 @@ const OverviewPageContent = () => { const { honeyTokenNames, isHoneyTokenPresentInEnv, getHoneyTokenByName } = useHoneyTokenOverview(honeyTokens); + const { proxiedServiceNames, isProxiedServicePresentInEnv, getProxiedServiceByName } = + useProxiedServiceOverview(proxiedServices); + const { secretImportNames, isSecretImportInEnv, getSecretImportByEnv, getSecretImportsForEnv } = useSecretImportOverview(overview?.imports); @@ -857,6 +874,9 @@ const OverviewPageContent = () => { "addDynamicSecret", "addSecretRotation", "addHoneyToken", + "addProxiedService", + "editProxiedService", + "deleteProxiedService", "editSecretRotation", "rotateSecretRotation", "viewSecretRotationGeneratedCredentials", @@ -2465,6 +2485,7 @@ const OverviewPageContent = () => { dynamicSecretNames.length === 0 && secretRotationNames.length === 0 && honeyTokenNames.length === 0 && + proxiedServiceNames.length === 0 && secretImportNames.length === 0 && !isOverviewLoading; @@ -2683,10 +2704,21 @@ const OverviewPageContent = () => { text: "Adding honey tokens can be unlocked if you upgrade to Infisical Pro plan." }); }} + onAddProxiedService={() => { + if (subscription?.secretsBrokering) { + handlePopUpOpen("addProxiedService"); + return; + } + handlePopUpOpen("upgradePlan", { + isEnterpriseFeature: true, + text: "Secrets brokering can be unlocked if you upgrade to Infisical Enterprise plan." + }); + }} onReplicateSecrets={() => handlePopUpOpen("replicateFolder")} isDyanmicSecretAvailable={userAvailableDynamicSecretEnvs.length > 0} isSecretRotationAvailable={userAvailableSecretRotationEnvs.length > 0} isHoneyTokenAvailable + isProxiedServiceAvailable={Boolean(subscription?.secretsBrokering)} isReplicateSecretsAvailable={visibleEnvs.length === 1} onAddSecretImport={handleAddSecretImport} isSecretImportAvailable={userAvailableSecretImportEnvs.length > 0} @@ -3317,6 +3349,22 @@ const OverviewPageContent = () => { } /> ))} + {proxiedServiceNames.map((proxiedServiceName, index) => ( + + handlePopUpOpen("editProxiedService", proxiedService) + } + onDelete={(proxiedService) => + handlePopUpOpen("deleteProxiedService", proxiedService) + } + /> + ))} {mergedSecKeys.map((key, index) => ( { isOpen={popUp.addHoneyToken.isOpen} onOpenChange={(isOpen) => handlePopUpToggle("addHoneyToken", isOpen)} /> + handlePopUpToggle("addProxiedService", isOpen)} + projectId={projectId} + environment={singleEnvSlug} + secretPath={secretPath} + /> + handlePopUpToggle("editProxiedService", isOpen)} + proxiedService={popUp.editProxiedService.data as TDashboardProxiedService} + projectId={projectId} + /> + handlePopUpToggle("deleteProxiedService", isOpen)} + proxiedService={popUp.deleteProxiedService.data as TDashboardProxiedService} + projectId={projectId} + /> void; @@ -33,6 +35,7 @@ type Props = { onAddDyanamicSecret: () => void; onAddSecretRotation: () => void; onAddHoneyToken: () => void; + onAddProxiedService: () => void; onAddSecretImport: () => void; onImportSecrets: () => void; onReplicateSecrets: () => void; @@ -41,6 +44,7 @@ type Props = { isDyanmicSecretAvailable: boolean; isSecretRotationAvailable: boolean; isHoneyTokenAvailable: boolean; + isProxiedServiceAvailable: boolean; isReplicateSecretsAvailable: boolean; isSecretImportAvailable: boolean; isSingleEnvSelected: boolean; @@ -54,6 +58,7 @@ export function AddResourceButtons({ onAddDyanamicSecret, onAddSecretRotation, onAddHoneyToken, + onAddProxiedService, onAddSecretImport, onImportSecrets, onReplicateSecrets, @@ -62,6 +67,7 @@ export function AddResourceButtons({ isDyanmicSecretAvailable, isSecretRotationAvailable, isHoneyTokenAvailable, + isProxiedServiceAvailable, isReplicateSecretsAvailable, isSecretImportAvailable, isSingleEnvSelected, @@ -157,6 +163,35 @@ export function AddResourceButtons({ )} + + {(isAllowed) => ( + + + + + Add Proxied Service + + + + {!isProxiedServiceAvailable + ? "Secrets brokering is not available on your plan" + : "Select a single environment to add a proxied service"} + + + )} + Bulk diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx new file mode 100644 index 00000000000..7858816e0b1 --- /dev/null +++ b/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx @@ -0,0 +1,234 @@ +import { subject } from "@casl/ability"; +import { ArrowRightLeftIcon, ChevronDownIcon, EditIcon, Trash2Icon } from "lucide-react"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Badge, + IconButton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Tooltip, + TooltipContent, + TooltipTrigger +} from "@app/components/v3"; +import { + ProjectPermissionProxiedServiceActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; +import { useToggle } from "@app/hooks"; +import { TDashboardProxiedService } from "@app/hooks/api/proxiedServices/types"; + +import { ResourceEnvironmentStatusCell } from "../ResourceEnvironmentStatusCell"; + +type Props = { + proxiedServiceName: string; + environments: { name: string; slug: string }[]; + isProxiedServiceInEnv: (name: string, env: string) => boolean; + getProxiedServiceByName: (slug: string, name: string) => TDashboardProxiedService | undefined; + tableWidth: number; + onEdit: (proxiedService: TDashboardProxiedService) => void; + onDelete: (proxiedService: TDashboardProxiedService) => void; +}; + +export const ProxiedServiceTableRow = ({ + proxiedServiceName, + environments = [], + isProxiedServiceInEnv, + getProxiedServiceByName, + tableWidth, + onEdit, + onDelete +}: Props) => { + const [isExpanded, setIsExpanded] = useToggle(false); + + const isSingleEnvView = environments.length === 1; + const totalCols = environments.length + 2; + + const singleEnvSlug = isSingleEnvView ? environments[0].slug : ""; + const singleEnvService = isSingleEnvView + ? getProxiedServiceByName(singleEnvSlug, proxiedServiceName) + : undefined; + + const renderActionButtons = (proxiedService: TDashboardProxiedService) => ( +
+ + {(isAllowed) => ( + + + onEdit(proxiedService)} + > + + + + Edit + + )} + + + {(isAllowed) => ( + + + onDelete(proxiedService)} + > + + + + Delete + + )} + +
+ ); + + const renderInlineDetails = (proxiedService: TDashboardProxiedService) => ( +
+ {proxiedService.hostPattern} + {!proxiedService.isEnabled && Disabled} +
+ ); + + return ( + <> + + + {!isSingleEnvView && isExpanded ? ( + + ) : ( + + )} + + + {isSingleEnvView && singleEnvService ? ( +
+ {proxiedServiceName} + {renderInlineDetails(singleEnvService)} +
+ {renderActionButtons(singleEnvService)} +
+
+ ) : ( + proxiedServiceName + )} +
+ {environments.length > 1 && + environments.map(({ slug }, i) => { + if (isExpanded) + return ( + + ); + + const isPresent = isProxiedServiceInEnv(proxiedServiceName, slug); + + return ( + + ); + })} +
+ {!isSingleEnvView && isExpanded && ( + + +
+ + + + Environment + + + + + {environments + .filter((env) => Boolean(getProxiedServiceByName(env.slug, proxiedServiceName))) + .map(({ name: envName, slug }) => { + const proxiedService = getProxiedServiceByName(slug, proxiedServiceName)!; + + return ( + + +
+ {envName} + {renderInlineDetails(proxiedService)} +
+ {renderActionButtons(proxiedService)} +
+
+
+
+ ); + })} +
+
+
+
+
+ )} + + ); +}; diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/index.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/index.tsx new file mode 100644 index 00000000000..320c5f2aebe --- /dev/null +++ b/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/index.tsx @@ -0,0 +1 @@ +export { ProxiedServiceTableRow } from "./ProxiedServiceTableRow"; diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/index.ts b/frontend/src/pages/secret-manager/OverviewPage/components/index.ts index cc8b093d0f8..ffd14ce0d10 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/index.ts +++ b/frontend/src/pages/secret-manager/OverviewPage/components/index.ts @@ -6,6 +6,7 @@ export * from "./EnvironmentSelect"; export * from "./FolderBreadcrumb"; export * from "./FolderTableRow"; export * from "./HoneyTokenTableRow"; +export * from "./ProxiedServiceTableRow"; export * from "./ResourceCount"; export * from "./ResourceFilter"; export * from "./ResourceSearchInput"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index 93f3edd88d4..bb9fcf7b364 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -67,6 +67,7 @@ import { useResizableColWidth } from "@app/hooks/useResizableColWidth"; import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { RequestAccessModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal"; import { HoneyTokenListView } from "@app/pages/secret-manager/SecretDashboardPage/components/HoneyTokenListView"; +import { ProxiedServiceListView } from "@app/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView"; import { SecretRotationListView } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView"; import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount"; @@ -327,6 +328,7 @@ const Page = () => { includeSecretRotations: canReadSecretRotations && (isResourceTypeFiltered ? filter.include.rotation : true), includeHoneyTokens: true, + includeProxiedServices: true, tags: filter.tags }); @@ -348,6 +350,7 @@ const Page = () => { dynamicSecrets, secretRotations, honeyTokens, + proxiedServices, secrets, totalImportCount = 0, totalFolderCount = 0, @@ -504,7 +507,8 @@ const Page = () => { (secrets?.length || 0) - (dynamicSecrets?.length || 0) - (secretRotations?.length || 0) - - (honeyTokens?.length || 0), + (honeyTokens?.length || 0) - + (proxiedServices?.length || 0), 0 ); const isNotEmpty = Boolean( @@ -1075,6 +1079,9 @@ const Page = () => { canNavigate={isFetched} /> )} + {Boolean(proxiedServices?.length) && ( + + )} {canReadDynamicSecret && Boolean(dynamicSecrets?.length) && ( )} + + {(isAllowed) => ( + + )} + handlePopUpToggle("addSecretRotation", isOpen)} /> + handlePopUpToggle("addProxiedService", isOpen)} + projectId={currentProject.id} + environment={environment} + secretPath={secretPath} + /> handlePopUpToggle("addFolder", isOpen)} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceItem.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceItem.tsx new file mode 100644 index 00000000000..77bf2c93964 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceItem.tsx @@ -0,0 +1,133 @@ +import { useState } from "react"; +import { subject } from "@casl/ability"; +import { AnimatePresence, motion } from "framer-motion"; +import { ArrowRightLeftIcon, PencilIcon, Trash2Icon } from "lucide-react"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Badge, IconButton } from "@app/components/v3"; +import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionProxiedServiceActions } from "@app/context/ProjectPermissionContext/types"; +import { ProxiedServiceCredentialRole } from "@app/hooks/api/proxiedServices/enums"; +import { TDashboardProxiedService } from "@app/hooks/api/proxiedServices/types"; + +type Props = { + proxiedService: TDashboardProxiedService; + onEdit: () => void; + onDelete: () => void; +}; + +export const ProxiedServiceItem = ({ proxiedService, onEdit, onDelete }: Props) => { + const { name, hostPattern, isEnabled, credentials, environment, folder } = proxiedService; + const [isExpanded, setIsExpanded] = useState(false); + + const permissionSubject = subject(ProjectPermissionSub.ProxiedServices, { + environment: environment.slug, + secretPath: folder.path + }); + + return ( + <> +
setIsExpanded(!isExpanded)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setIsExpanded(!isExpanded); + } + }} + role="button" + tabIndex={0} + aria-expanded={isExpanded} + aria-label={`${isExpanded ? "Collapse" : "Expand"} proxied service ${name}`} + > +
+ +
+
+
+ {name} + {hostPattern} + {!isEnabled && Disabled} +
+
+ + + + {(isAllowed) => ( + { + e.stopPropagation(); + onEdit(); + }} + > + + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + onDelete(); + }} + > + + + )} + + + +
+ {isExpanded && ( +
+ {credentials.length === 0 && ( +
+ No credentials configured +
+ )} + {credentials.map((cred) => ( +
+ {cred.secretKey} + + {cred.role === ProxiedServiceCredentialRole.HeaderRewrite + ? cred.headerName || "Basic Auth" + : "Substitution"} + +
+ ))} +
+ )} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceListView.tsx new file mode 100644 index 00000000000..793baf55d29 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceListView.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; + +import { + DeleteProxiedServiceModal, + EditProxiedServiceModal +} from "@app/components/proxied-services"; +import { useProject } from "@app/context"; +import { TDashboardProxiedService } from "@app/hooks/api/proxiedServices/types"; + +import { ProxiedServiceItem } from "./ProxiedServiceItem"; + +type Props = { + proxiedServices?: TDashboardProxiedService[]; +}; + +export const ProxiedServiceListView = ({ proxiedServices }: Props) => { + const { projectId } = useProject(); + + const [editTarget, setEditTarget] = useState(); + const [deleteTarget, setDeleteTarget] = useState(); + + return ( + <> + {proxiedServices?.map((proxiedService) => ( + setEditTarget(proxiedService)} + onDelete={() => setDeleteTarget(proxiedService)} + /> + ))} + { + if (!isOpen) setEditTarget(undefined); + }} + proxiedService={editTarget} + projectId={projectId} + /> + { + if (!isOpen) setDeleteTarget(undefined); + }} + proxiedService={deleteTarget} + projectId={projectId} + /> + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/index.tsx new file mode 100644 index 00000000000..99c2e4eb728 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/index.tsx @@ -0,0 +1 @@ +export { ProxiedServiceListView } from "./ProxiedServiceListView"; From a44a1ac46151c780e871df5e2ba1fbf57dcc05a1 Mon Sep 17 00:00:00 2001 From: Saif Date: Thu, 9 Jul 2026 23:50:26 +0530 Subject: [PATCH 02/43] fix: address pre-PR review for secrets brokering Backend: - CA: 10-year root, clamp intermediate lifetime to root, refuse to sign against an expired root, backdate notBefore for clock skew - CA endpoints locked to machine identities (drop AuthMode.JWT) - proxied service: per-key ReadValue check (not folder-wide), so a denied secret can't be wired into a service - cross-credential validation (basic-auth pairing, unique header names / placeholder keys+values, mutually-exclusive header modes), min 1 credential - hostPattern grammar validation with a 255-char cap - GET by name without scope returns 400 instead of 500 on the uuid column - dashboard: return the canonical secret path instead of the folder name Frontend: - send undefined (not null) for empty headerPrefix so the API accepts it - add Proxied Services to the overview resource filter - match honey-token/dynamic-secret upgrade-modal pattern; consistent Enterprise copy - warn when switching auth mode discards the other mode's credentials on edit - mirror backend cross-credential validation in the form schema --- .../src/ee/routes/v1/agent-proxy-ca-router.ts | 4 +- .../ee/routes/v1/proxied-service-router.ts | 129 +++++++++++++++++- .../agent-proxy-ca/agent-proxy-ca-service.ts | 25 +++- .../proxied-service/proxied-service-dal.ts | 2 + .../proxied-service-service.ts | 46 +++++-- .../forms/ProxiedServiceForm.tsx | 18 ++- .../proxied-services/forms/schema.ts | 34 +++++ .../OverviewPage/OverviewPage.tsx | 1 - .../AddResourceButtons/AddResourceButtons.tsx | 16 +-- .../ResourceFilter/ResourceFilter.tsx | 6 + .../components/ActionBar/ActionBar.tsx | 3 +- 11 files changed, 246 insertions(+), 38 deletions(-) diff --git a/backend/src/ee/routes/v1/agent-proxy-ca-router.ts b/backend/src/ee/routes/v1/agent-proxy-ca-router.ts index 567270cb448..d1ec2f2c2bc 100644 --- a/backend/src/ee/routes/v1/agent-proxy-ca-router.ts +++ b/backend/src/ee/routes/v1/agent-proxy-ca-router.ts @@ -9,7 +9,7 @@ export const registerAgentProxyCaRouter = async (server: FastifyZodProvider) => method: "GET", url: "/", config: { rateLimit: readLimit }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { response: { 200: z.object({ @@ -30,7 +30,7 @@ export const registerAgentProxyCaRouter = async (server: FastifyZodProvider) => method: "POST", url: "/sign", config: { rateLimit: writeLimit }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { body: z.object({ publicKey: z.string().trim().min(1) diff --git a/backend/src/ee/routes/v1/proxied-service-router.ts b/backend/src/ee/routes/v1/proxied-service-router.ts index 30894802b93..3ee88d1542e 100644 --- a/backend/src/ee/routes/v1/proxied-service-router.ts +++ b/backend/src/ee/routes/v1/proxied-service-router.ts @@ -6,11 +6,53 @@ import { ProxiedServiceHeaderPurpose, ProxiedServiceSubstitutionSurface } from "@app/ee/services/proxied-service/proxied-service-enums"; +import { BadRequestError } from "@app/lib/errors"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +// One host label: alphanumerics and internal hyphens, optionally a single leading "*." wildcard. +const HOST_LABELS_RE = /^(?:\*\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/i; + +// Validates the comma-separated hostPattern grammar so malformed values fail at creation +// instead of silently never matching at proxy time. +const hostPatternSchema = z + .string() + .trim() + .min(1, "Host pattern is required") + .max(255) + .superRefine((raw, ctx) => { + const segments = raw.split(",").map((s) => s.trim()); + segments.forEach((seg) => { + if (seg === "") { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Host pattern has an empty entry" }); + return; + } + if (seg.includes("://")) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `"${seg}" must not include a scheme (e.g. https://)` }); + return; + } + let hostPort = seg; + const slashIdx = hostPort.indexOf("/"); + if (slashIdx !== -1) hostPort = hostPort.slice(0, slashIdx); // path portion is free-form + let host = hostPort; + const colonIdx = hostPort.lastIndexOf(":"); + if (colonIdx !== -1) { + const portStr = hostPort.slice(colonIdx + 1); + host = hostPort.slice(0, colonIdx); + const port = Number(portStr); + if (!/^\d+$/.test(portStr) || port < 1 || port > 65535) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `"${seg}" has an invalid port` }); + return; + } + } + if (!HOST_LABELS_RE.test(host)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `"${seg}" is not a valid host pattern` }); + } + }); + }); + const CredentialInputSchema = z .object({ secretKey: z.string().trim().min(1), @@ -47,6 +89,79 @@ const CredentialInputSchema = z } }); +// Cross-credential rules that cannot be checked one row at a time: basic-auth pairing, +// unique header names / placeholders, and header-rewrite modes that cannot coexist. +const CredentialsArraySchema = CredentialInputSchema.array() + .min(1, "At least one credential is required") + .superRefine((credentials, ctx) => { + const headerNameCounts = new Map(); + const placeholderKeys = new Set(); + const placeholderValues = new Set(); + let usernameCount = 0; + let passwordCount = 0; + + credentials.forEach((cred, i) => { + if (cred.role === ProxiedServiceCredentialRole.HeaderRewrite) { + if (cred.headerPurpose === ProxiedServiceHeaderPurpose.Username) usernameCount += 1; + else if (cred.headerPurpose === ProxiedServiceHeaderPurpose.Password) passwordCount += 1; + else if (cred.headerName) { + const key = cred.headerName.toLowerCase(); + headerNameCounts.set(key, (headerNameCounts.get(key) ?? 0) + 1); + } + } else { + if (cred.placeholderKey) { + if (placeholderKeys.has(cred.placeholderKey)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate placeholder env var "${cred.placeholderKey}"`, + path: [i, "placeholderKey"] + }); + } + placeholderKeys.add(cred.placeholderKey); + } + if (cred.placeholderValue) { + if (placeholderValues.has(cred.placeholderValue)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Two credentials share the same placeholder value", + path: [i, "placeholderValue"] + }); + } + placeholderValues.add(cred.placeholderValue); + } + } + }); + + headerNameCounts.forEach((count, name) => { + if (count > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Header "${name}" is set by more than one credential` + }); + } + }); + + if (usernameCount > 1 || passwordCount > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Basic auth allows at most one username and one password credential" + }); + } + if (usernameCount !== passwordCount) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Basic auth requires both a username and a password credential" + }); + } + // Basic auth already owns the Authorization header, so it cannot coexist with named header rewrites. + if (usernameCount + passwordCount > 0 && headerNameCounts.size > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Basic auth cannot be combined with other header-rewrite credentials on the same service" + }); + } + }); + const SanitizedCredentialSchema = ProxiedServiceCredentialsSchema.pick({ id: true, serviceId: true, @@ -92,9 +207,9 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = environment: z.string().trim().min(1), secretPath: z.string().trim().default("/"), name: slugSchema({ field: "name" }), - hostPattern: z.string().trim().min(1), + hostPattern: hostPatternSchema, isEnabled: z.boolean().optional(), - credentials: CredentialInputSchema.array() + credentials: CredentialsArraySchema }), response: { 200: z.object({ service: ServiceWithCredentialsSchema }) @@ -149,6 +264,12 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = ); return { service }; } + // No scope params: this must be a lookup by ID. Reject a bare name so it never hits the uuid column. + if (!z.string().uuid().safeParse(serviceIdOrName).success) { + throw new BadRequestError({ + message: "projectId and environment query params are required when fetching a proxied service by name" + }); + } const service = await server.services.proxiedService.getById({ serviceId: serviceIdOrName }, req.permission); return { service }; } @@ -163,9 +284,9 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = params: z.object({ serviceId: z.string().uuid() }), body: z.object({ name: slugSchema({ field: "name" }).optional(), - hostPattern: z.string().trim().min(1).optional(), + hostPattern: hostPatternSchema.optional(), isEnabled: z.boolean().optional(), - credentials: CredentialInputSchema.array().optional() + credentials: CredentialsArraySchema.optional() }), response: { 200: z.object({ service: ServiceWithCredentialsSchema }) diff --git a/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts b/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts index 0a5f01a8108..8c0e97f104d 100644 --- a/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts +++ b/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts @@ -28,6 +28,11 @@ type TAgentProxyCaServiceFactoryDep = { const ROOT_CA_ALGORITHM = CertKeyAlgorithm.ECDSA_P256; const INTERMEDIATE_CA_TTL_MS = 7 * 24 * 60 * 60 * 1000; +// Long-lived root so the trust anchor stays stable (agents pin it and do not refresh in V1). +// The private key never leaves Infisical and only signs short-lived intermediates. +const ROOT_CA_VALIDITY_YEARS = 10; +// Backdate notBefore so a fresh cert is not rejected by an agent whose clock trails the server's. +const CLOCK_SKEW_MS = 5 * 60 * 1000; export const agentProxyCaServiceFactory = ({ orgAgentProxyConfigDAL, @@ -78,12 +83,13 @@ export const agentProxyCaServiceFactory = ({ const rootCaSerialNumber = createSerialNumber(); const rootCaIssuedAt = new Date(); - const rootCaExpiration = new Date(new Date().setFullYear(new Date().getFullYear() + 2)); + const rootCaNotBefore = new Date(rootCaIssuedAt.getTime() - CLOCK_SKEW_MS); + const rootCaExpiration = new Date(new Date().setFullYear(new Date().getFullYear() + ROOT_CA_VALIDITY_YEARS)); const rootCaCert = await x509.X509CertificateGenerator.createSelfSigned({ name: `O=${orgId},CN=Infisical Agent Proxy Root CA`, serialNumber: rootCaSerialNumber, - notBefore: rootCaIssuedAt, + notBefore: rootCaNotBefore, notAfter: rootCaExpiration, signingAlgorithm: alg, keys: rootCaKeys, @@ -144,6 +150,14 @@ export const agentProxyCaServiceFactory = ({ await $assertOrgMembership(actor); const { rootCaCert, rootCaPrivateKeyBuffer } = await $getOrgRootCa(actor.orgId); + + // Refuse to sign against an expired root: the resulting chain could never validate. + if (new Date() >= rootCaCert.notAfter) { + throw new BadRequestError({ + message: "The organization's agent proxy root CA has expired and can no longer sign intermediate certificates." + }); + } + const alg = keyAlgorithmToAlgCfg(ROOT_CA_ALGORITHM); let intermediatePublicKey: CryptoKey; @@ -175,13 +189,16 @@ export const agentProxyCaServiceFactory = ({ const serialNumber = createSerialNumber(); const issuedAt = new Date(); - const expiration = new Date(Date.now() + INTERMEDIATE_CA_TTL_MS); + const notBefore = new Date(issuedAt.getTime() - CLOCK_SKEW_MS); + // Clamp so an intermediate can never outlive the root it chains to. + const requestedExpiration = new Date(issuedAt.getTime() + INTERMEDIATE_CA_TTL_MS); + const expiration = requestedExpiration < rootCaCert.notAfter ? requestedExpiration : rootCaCert.notAfter; const intermediateCert = await x509.X509CertificateGenerator.create({ serialNumber, subject: `O=${actor.orgId},CN=Infisical Agent Proxy Intermediate CA`, issuer: rootCaCert.subject, - notBefore: issuedAt, + notBefore, notAfter: expiration, signingKey: importedRootCaPrivateKey, publicKey: intermediatePublicKey, diff --git a/backend/src/ee/services/proxied-service/proxied-service-dal.ts b/backend/src/ee/services/proxied-service/proxied-service-dal.ts index b1413a588bb..05c4a1f1be8 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-dal.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-dal.ts @@ -73,6 +73,8 @@ export const proxiedServiceDALFactory = (db: TDbClient) => { name: row.envName, slug: row.envSlug }, + // NOTE: this is the folder's own name, not its full path. Callers that surface this to clients + // (e.g. the dashboard aggregate) must override it with the canonical secret path. folder: { path: row.folderName } 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 f060113ce1b..6ad1cd50adb 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-service.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-service.ts @@ -1,10 +1,11 @@ -import { ForbiddenError, subject } from "@casl/ability"; +import { ForbiddenError, MongoAbility, subject } from "@casl/ability"; import { ActionProjectType, SecretType } from "@app/db/schemas"; import { throwIfMissingSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns"; import { ProjectPermissionProxiedServiceActions, ProjectPermissionSecretActions, + ProjectPermissionSet, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; @@ -92,6 +93,25 @@ export const proxiedServiceServiceFactory = ({ } }; + // Asserts ReadValue on each referenced secret individually (by name), not just on the folder path. + // A per-key check means a deny/inverted rule on a specific secret can't be bypassed by folder-wide + // ReadValue, so a caller can't wire a secret they were explicitly denied into a service they control. + const $assertCanReadReferencedSecrets = ( + permission: MongoAbility, + environment: string, + secretPath: string, + credentials: TProxiedServiceCredentialInput[] + ) => { + const uniqueKeys = [...new Set(credentials.map((c) => c.secretKey))]; + uniqueKeys.forEach((secretName) => { + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath, + secretName + }); + }); + }; + // Resolves the canonical secret path for a service's folder so scoped (glob) permission checks are accurate. const $resolveSecretPath = async (projectId: string, folderId: string) => { const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(projectId, [folderId]); @@ -120,11 +140,8 @@ export const proxiedServiceServiceFactory = ({ ProjectPermissionProxiedServiceActions.Create, subject(ProjectPermissionSub.ProxiedServices, { environment, secretPath: canonicalPath }) ); - // caller must be able to read the referenced secrets they are wiring up - throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { - environment, - secretPath: canonicalPath - }); + // caller must be able to read each referenced secret they are wiring up (per-key, see helper) + $assertCanReadReferencedSecrets(permission, environment, canonicalPath, credentials); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) { @@ -305,12 +322,9 @@ export const proxiedServiceServiceFactory = ({ } if (credentials) { - // same guard as create: the caller must be able to read the secrets it is wiring up, - // otherwise Edit-only actors could reference secrets they cannot read and exfiltrate them via the proxy - throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { - environment: service.environmentSlug, - secretPath: resolvedSecretPath - }); + // same per-key guard as create: Edit-only actors must not reference secrets they cannot read + // and exfiltrate them via the proxy + $assertCanReadReferencedSecrets(permission, service.environmentSlug, resolvedSecretPath, credentials); await $validateSecretReferences(service.folderId, credentials); } @@ -465,7 +479,13 @@ export const proxiedServiceServiceFactory = ({ return acc; }, {}); - return services.map((svc) => ({ ...svc, credentials: credentialsByService[svc.id] ?? [] })); + // DAL's folder.path carries the folder's own name, not its full path; stamp the canonical + // secret path so the frontend uses a correct secretPath for its scoped permission checks and pickers. + return services.map((svc) => ({ + ...svc, + folder: { ...svc.folder, path: canonicalSecretPath }, + credentials: credentialsByService[svc.id] ?? [] + })); }; return { diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx index a0e7197e271..9826e33e3b0 100644 --- a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -128,7 +128,8 @@ const toCredentials = (form: TProxiedServiceForm): TProxiedServiceCredentialInpu secretKey: h.secretKey, role: ProxiedServiceCredentialRole.HeaderRewrite, headerName: h.headerName, - headerPrefix: h.headerPrefix || null + // omit rather than send null: the API field is optional and rejects null + headerPrefix: h.headerPrefix || undefined }); }); } @@ -171,6 +172,13 @@ export const ProxiedServiceForm = ({ const headerMode = watch("headerMode"); + // On edit, switching header modes discards the other mode's credentials on save (they are + // mutually exclusive). Warn so the change is not silent. + const originalHeaderMode = proxiedService?.credentials.some((c) => c.headerPurpose) + ? HeaderRewritingMode.BasicAuth + : HeaderRewritingMode.Headers; + const showModeSwitchWarning = isEdit && headerMode !== originalHeaderMode; + const headerFields = useFieldArray({ control, name: "headers" }); const substitutionFields = useFieldArray({ control, name: "substitutions" }); @@ -265,6 +273,14 @@ export const ProxiedServiceForm = ({ /> + {showModeSwitchWarning && ( +

+ Switching auth type will replace the{" "} + {originalHeaderMode === HeaderRewritingMode.BasicAuth ? "Basic Auth" : "header"}{" "} + credentials on this service when you save. +

+ )} + {headerMode === HeaderRewritingMode.Headers ? (
{headerFields.fields.map((row, i) => ( diff --git a/frontend/src/components/proxied-services/forms/schema.ts b/frontend/src/components/proxied-services/forms/schema.ts index 250bc1c8d4d..3e88f24336b 100644 --- a/frontend/src/components/proxied-services/forms/schema.ts +++ b/frontend/src/components/proxied-services/forms/schema.ts @@ -55,6 +55,7 @@ export const proxiedServiceFormSchema = z .superRefine((form, ctx) => { if (form.headerMode === HeaderRewritingMode.Headers) { // only validate the rows the user actually sees in Headers mode + const seenHeaderNames = new Map(); form.headers.forEach((row, i) => { if (!row.secretKey) { ctx.addIssue({ @@ -69,8 +70,26 @@ export const proxiedServiceFormSchema = z message: "Header name is required", path: ["headers", i, "headerName"] }); + } else { + const key = row.headerName.trim().toLowerCase(); + if (seenHeaderNames.has(key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Duplicate header name", + path: ["headers", i, "headerName"] + }); + } + seenHeaderNames.set(key, i); } }); + // the backend rejects a service with no credentials at all + if (!form.headers.length && !form.substitutions.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Add at least one header or substitution", + path: ["headers"] + }); + } } else { if (!form.basicAuth?.usernameSecretKey) { ctx.addIssue({ @@ -87,6 +106,21 @@ export const proxiedServiceFormSchema = z }); } } + + // placeholder env var names must be unique: each maps to one env var the agent sees + const seenPlaceholderKeys = new Map(); + form.substitutions.forEach((row, i) => { + const key = row.placeholderKey.trim(); + if (!key) return; + if (seenPlaceholderKeys.has(key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Duplicate environment variable name", + path: ["substitutions", i, "placeholderKey"] + }); + } + seenPlaceholderKeys.set(key, i); + }); }); export type TProxiedServiceForm = z.infer; diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 388fb0ec556..32096ec0cbc 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -2718,7 +2718,6 @@ const OverviewPageContent = () => { isDyanmicSecretAvailable={userAvailableDynamicSecretEnvs.length > 0} isSecretRotationAvailable={userAvailableSecretRotationEnvs.length > 0} isHoneyTokenAvailable - isProxiedServiceAvailable={Boolean(subscription?.secretsBrokering)} isReplicateSecretsAvailable={visibleEnvs.length === 1} onAddSecretImport={handleAddSecretImport} isSecretImportAvailable={userAvailableSecretImportEnvs.length > 0} diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/AddResourceButtons/AddResourceButtons.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/AddResourceButtons/AddResourceButtons.tsx index e5bafbc1749..af1bb801d9d 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/AddResourceButtons/AddResourceButtons.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/AddResourceButtons/AddResourceButtons.tsx @@ -44,7 +44,6 @@ type Props = { isDyanmicSecretAvailable: boolean; isSecretRotationAvailable: boolean; isHoneyTokenAvailable: boolean; - isProxiedServiceAvailable: boolean; isReplicateSecretsAvailable: boolean; isSecretImportAvailable: boolean; isSingleEnvSelected: boolean; @@ -67,7 +66,6 @@ export function AddResourceButtons({ isDyanmicSecretAvailable, isSecretRotationAvailable, isHoneyTokenAvailable, - isProxiedServiceAvailable, isReplicateSecretsAvailable, isSecretImportAvailable, isSingleEnvSelected, @@ -168,25 +166,19 @@ export function AddResourceButtons({ a={ProjectPermissionSub.ProxiedServices} > {(isAllowed) => ( - + Add Proxied Service - {!isProxiedServiceAvailable - ? "Secrets brokering is not available on your plan" + {!isAllowed + ? "Access Restricted" : "Select a single environment to add a proxied service"} diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/ResourceFilter/ResourceFilter.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/ResourceFilter/ResourceFilter.tsx index add1e5c0a31..af90bceba12 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/ResourceFilter/ResourceFilter.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/ResourceFilter/ResourceFilter.tsx @@ -1,4 +1,5 @@ import { + ArrowRightLeftIcon, FilterIcon, FingerprintIcon, FolderIcon, @@ -45,6 +46,11 @@ const OVERVIEW_RESOURCE_TYPES: ResourceTypeOption[] = [ label: "Honey Tokens", icon: }, + { + type: "proxiedService", + label: "Proxied Services", + icon: + }, { type: "secret", label: "Secrets", icon: } ]; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx index ae83638c372..956a7ad1329 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx @@ -1099,7 +1099,8 @@ export const ActionBar = ({ return; } handlePopUpOpen("upgradePlan", { - featureName: "Secrets Brokering" + featureName: "Secrets Brokering", + isEnterpriseFeature: true }); }} variant="outline_bg" From e0b5de1f43d976055e85421c43953ba4212b13c6 Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 00:09:30 +0530 Subject: [PATCH 03/43] fix: rework proxied service form to match app conventions - fix the secret pickers: use the searchable FilterableSelect (the app's standard list picker) instead of a hand-rolled Select that showed no options - header rewriting: single shared column header row, an empty state when no headers remain, and inline validation errors per row - surface validation errors for name, host pattern, basic auth, and substitutions - use text-bunker-300 for section descriptions (not text-muted-foreground) and Title Case field labels, matching the honey-token form --- .../forms/ProxiedServiceForm.tsx | 265 +++++++++++------- .../proxied-services/forms/SecretSelect.tsx | 47 ++-- .../proxied-services/forms/SurfaceSelect.tsx | 2 +- 3 files changed, 192 insertions(+), 122 deletions(-) diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx index 9826e33e3b0..ef676e76b2d 100644 --- a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -182,6 +182,12 @@ export const ProxiedServiceForm = ({ const headerFields = useFieldArray({ control, name: "headers" }); const substitutionFields = useFieldArray({ control, name: "substitutions" }); + // 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 } } + | undefined; + const headersRootError = headersArrayError?.root?.message ?? headersArrayError?.message; + const onSubmit = async (form: TProxiedServiceForm) => { const credentials = toCredentials(form); try { @@ -219,7 +225,7 @@ export const ProxiedServiceForm = ({ }; return ( -
+ - Service name + Service Name Lowercase letters, numbers, and hyphens only. @@ -241,23 +247,23 @@ export const ProxiedServiceForm = ({ - Host pattern + Host Pattern - Outbound requests to a matching host are intercepted. Wildcards supported (e.g. - *.stripe.com). + Outbound requests to a matching host are intercepted. Wildcards (e.g. *.stripe.com) and + comma-separated patterns are supported. {errors.hostPattern && {errors.hostPattern.message}} {/* Header Rewriting */} -
+
-

Header Rewriting

-

Sets these headers on every request.

+

Header Rewriting

+

Sets these headers on every request.

- {headerFields.fields.map((row, i) => ( -
- - Name - - - - Prefix - - - - Value - ( - - )} - /> - - headerFields.remove(i)} - > - - + {headerFields.fields.length === 0 ? ( + + No headers added + + ) : ( +
+ {/* shared column headers */} +
+ Name + Prefix + Value + +
+ {headerFields.fields.map((row, i) => { + const rowError = + errors.headers?.[i]?.headerName?.message ?? + errors.headers?.[i]?.secretKey?.message; + return ( +
+
+ + +
+ ( + + )} + /> +
+ headerFields.remove(i)} + > + + +
+ {rowError && {rowError}} +
+ ); + })}
- ))} + )}
+ {headersRootError && {headersRootError}}
) : (
Username - ( - + + ( + + )} + /> + {errors.basicAuth?.usernameSecretKey && ( + {errors.basicAuth.usernameSecretKey.message} )} - /> + Password - ( - + + ( + + )} + /> + {errors.basicAuth?.passwordSecretKey && ( + {errors.basicAuth.passwordSecretKey.message} )} - /> +
)} @@ -374,8 +418,8 @@ export const ProxiedServiceForm = ({ {/* Credential Substitution */}
-

Credential Substitution

-

+

Credential Substitution

+

Swap a placeholder in the request for the real credential, on the wire.

@@ -387,56 +431,75 @@ export const ProxiedServiceForm = ({ ) : ( substitutionFields.fields.map((row, i) => (
-
+
- Set env var to the placeholder - + Environment Variable + + + {errors.substitutions?.[i]?.placeholderKey && ( + {errors.substitutions[i]?.placeholderKey?.message} + )} + - Placeholder value - } - /> + Placeholder Value + + } + /> + substitutionFields.remove(i)} >
- and replace it in - ( - + Replace In + + ( + + )} + /> + {errors.substitutions?.[i]?.surfaces && ( + {errors.substitutions[i]?.surfaces?.message} )} - /> + - with value of - ( - + Secret + + ( + + )} + /> + {errors.substitutions?.[i]?.secretKey && ( + {errors.substitutions[i]?.secretKey?.message} )} - /> +
)) diff --git a/frontend/src/components/proxied-services/forms/SecretSelect.tsx b/frontend/src/components/proxied-services/forms/SecretSelect.tsx index 88b3f7f7b00..7ad4e9724ea 100644 --- a/frontend/src/components/proxied-services/forms/SecretSelect.tsx +++ b/frontend/src/components/proxied-services/forms/SecretSelect.tsx @@ -1,6 +1,4 @@ -import { KeyIcon } from "lucide-react"; - -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@app/components/v3"; +import { FilterableSelect } from "@app/components/v3"; import { useGetProjectSecrets } from "@app/hooks/api/secrets/queries"; type Props = { @@ -10,11 +8,14 @@ type Props = { value?: string; onChange: (value: string) => void; isDisabled?: boolean; + isError?: boolean; placeholder?: string; }; -// Picks a secret key from the current folder. The key icon appears inside the dropdown -// options (to signal these are secret references) but not in the collapsed trigger. +type SecretOption = { label: string; value: string }; + +// Picks a secret key from the current folder. Uses the searchable FilterableSelect so long secret +// lists stay usable. A stale reference (secret renamed/deleted) still renders its raw key. export const SecretSelect = ({ projectId, environment, @@ -22,28 +23,34 @@ export const SecretSelect = ({ value, onChange, isDisabled, - placeholder = "Select secret" + isError, + placeholder = "Select a secret" }: Props) => { - const { data: secrets = [] } = useGetProjectSecrets({ + const { data: secrets = [], isPending } = useGetProjectSecrets({ projectId, environment, secretPath, viewSecretValue: false }); + const options: SecretOption[] = secrets.map((secret) => ({ + label: secret.key, + value: secret.key + })); + const selected = + options.find((option) => option.value === value) ?? (value ? { label: value, value } : null); + return ( - + onChange((newValue as SecretOption | null)?.value ?? "")} + getOptionLabel={(option) => option.label} + getOptionValue={(option) => option.value} + /> ); }; diff --git a/frontend/src/components/proxied-services/forms/SurfaceSelect.tsx b/frontend/src/components/proxied-services/forms/SurfaceSelect.tsx index 50e888e710b..8b666838a04 100644 --- a/frontend/src/components/proxied-services/forms/SurfaceSelect.tsx +++ b/frontend/src/components/proxied-services/forms/SurfaceSelect.tsx @@ -51,7 +51,7 @@ export const SurfaceSelect = ({ value, onChange, isDisabled }: Props) => { )) ) : ( - Select surfaces + Select surfaces )}
From 7f100aa609822349012910daecd484f8c77bca69 Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 00:18:19 +0530 Subject: [PATCH 04/43] fix: align proxied service sheet with the create-secret form Mirror the multi-env Create Secret sheet exactly: form owns a scrollable body with a sticky SheetFooter (border-t, primary action first); repeated header and substitution rows use the metadata-row pattern (label only on the first row, bordered bg-container/50 container, centered empty state, ghost Add button); neutral section descriptions use text-muted and warnings use text-warning. --- .../CreateProxiedServiceModal.tsx | 20 +- .../EditProxiedServiceModal.tsx | 20 +- .../forms/ProxiedServiceForm.tsx | 530 +++++++++--------- 3 files changed, 286 insertions(+), 284 deletions(-) diff --git a/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx b/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx index 841443ea682..08bd9f0bd50 100644 --- a/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx +++ b/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx @@ -19,22 +19,20 @@ export const CreateProxiedServiceModal = ({ }: Props) => { return ( - + - Add Proxied Service + Create Proxied Service Define a service the agent proxy can broker credentials for. -
- onOpenChange(false)} - onCancel={() => onOpenChange(false)} - /> -
+ onOpenChange(false)} + onCancel={() => onOpenChange(false)} + />
); diff --git a/frontend/src/components/proxied-services/EditProxiedServiceModal.tsx b/frontend/src/components/proxied-services/EditProxiedServiceModal.tsx index 3292b10f90d..6a3bf26bdcc 100644 --- a/frontend/src/components/proxied-services/EditProxiedServiceModal.tsx +++ b/frontend/src/components/proxied-services/EditProxiedServiceModal.tsx @@ -20,21 +20,19 @@ export const EditProxiedServiceModal = ({ return ( - + Edit Proxied Service Update how the agent proxy brokers this service. -
- onOpenChange(false)} - onCancel={() => onOpenChange(false)} - /> -
+ onOpenChange(false)} + onCancel={() => onOpenChange(false)} + />
); diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx index ef676e76b2d..e0e8b4b868d 100644 --- a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -1,12 +1,11 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { PlusIcon, XIcon } from "lucide-react"; +import { PlusIcon, TrashIcon } from "lucide-react"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { Button, - Empty, - EmptyDescription, Field, FieldContent, FieldDescription, @@ -225,266 +224,184 @@ export const ProxiedServiceForm = ({ }; return ( - - ( - - Enabled - - - )} - /> + +
+ ( + + Enabled + + + )} + /> - - Service Name - - - Lowercase letters, numbers, and hyphens only. - {errors.name && {errors.name.message}} - - + + Service Name + + + Lowercase letters, numbers, and hyphens only. + + + - - Host Pattern - - - - Outbound requests to a matching host are intercepted. Wildcards (e.g. *.stripe.com) and - comma-separated patterns are supported. - - {errors.hostPattern && {errors.hostPattern.message}} - - + + Host Pattern + + + + Outbound requests to a matching host are intercepted. Wildcards (e.g. *.stripe.com) + and comma-separated patterns are supported. + + + + - {/* Header Rewriting */} -
-
-
-

Header Rewriting

-

Sets these headers on every request.

+ {/* Header Rewriting */} +
+
+
+

Header Rewriting

+

Sets these headers on every request.

+
+ ( + + + Headers + Basic Auth + + + )} + />
- ( - - - Headers - Basic Auth - - - )} - /> -
- {showModeSwitchWarning && ( -

- Switching auth type will replace the{" "} - {originalHeaderMode === HeaderRewritingMode.BasicAuth ? "Basic Auth" : "header"}{" "} - credentials on this service when you save. -

- )} + {showModeSwitchWarning && ( +

+ Switching auth type will replace the{" "} + {originalHeaderMode === HeaderRewritingMode.BasicAuth ? "Basic Auth" : "header"}{" "} + credentials on this service when you save. +

+ )} - {headerMode === HeaderRewritingMode.Headers ? ( -
- {headerFields.fields.length === 0 ? ( - - No headers added - - ) : ( -
- {/* shared column headers */} -
- Name - Prefix - Value - -
- {headerFields.fields.map((row, i) => { - const rowError = - errors.headers?.[i]?.headerName?.message ?? - errors.headers?.[i]?.secretKey?.message; - return ( -
-
+ {headerMode === HeaderRewritingMode.Headers ? ( + <> +
+ {headerFields.fields.length === 0 && ( +

+ No headers added. Click below to add. +

+ )} + {headerFields.fields.map((row, i) => ( +
+ + {i === 0 && Name} + + + + + + {i === 0 && Prefix} + -
- ( - - )} - /> -
- headerFields.remove(i)} - > - - -
- {rowError && {rowError}} -
- ); - })} + + + + {i === 0 && Value} + + ( + + )} + /> + + + + headerFields.remove(i)} + > + + +
+ ))}
- )} -
- -
- {headersRootError && {headersRootError}} -
- ) : ( -
- - Username - - ( - - )} - /> - {errors.basicAuth?.usernameSecretKey && ( - {errors.basicAuth.usernameSecretKey.message} - )} - - - - Password - - ( - - )} - /> - {errors.basicAuth?.passwordSecretKey && ( - {errors.basicAuth.passwordSecretKey.message} - )} - - -
- )} -
- - {/* Credential Substitution */} -
-
-

Credential Substitution

-

- Swap a placeholder in the request for the real credential, on the wire. -

-
- - {substitutionFields.fields.length === 0 ? ( - - No substitutions added - - ) : ( - substitutionFields.fields.map((row, i) => ( -
-
- - Environment Variable - - - {errors.substitutions?.[i]?.placeholderKey && ( - {errors.substitutions[i]?.placeholderKey?.message} - )} - - - - Placeholder Value - - } - /> - - - +
- - Replace In + {headersRootError && {headersRootError}} + + ) : ( +
+ + Username ( - + )} /> - {errors.substitutions?.[i]?.surfaces && ( - {errors.substitutions[i]?.surfaces?.message} - )} + - - Secret + + Password ( )} /> - {errors.substitutions?.[i]?.secretKey && ( - {errors.substitutions[i]?.secretKey?.message} - )} +
- )) - )} -
- + )} +
+ + {/* Credential Substitution */} +
+
+

Credential Substitution

+

+ Swap a placeholder in the request for the real credential, on the wire. +

+
+ +
+ {substitutionFields.fields.length === 0 && ( +

+ No substitutions added. Click below to add. +

+ )} + {substitutionFields.fields.map((row, i) => ( +
+
+ + Environment Variable + + + + + + + Placeholder Value + + ( + + )} + /> + + + substitutionFields.remove(i)} + > + + +
+ + Replace In + + ( + + )} + /> + + + + + Secret + + ( + + )} + /> + + + +
+ ))} +
+
+ +
- - - From c34e01bbce98cf6d6eb27df08316e651edae5626 Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 00:25:48 +0530 Subject: [PATCH 05/43] feat: show key icon in the proxied service secret picker --- .../components/proxied-services/forms/SecretSelect.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/components/proxied-services/forms/SecretSelect.tsx b/frontend/src/components/proxied-services/forms/SecretSelect.tsx index 7ad4e9724ea..77ab99cdd1e 100644 --- a/frontend/src/components/proxied-services/forms/SecretSelect.tsx +++ b/frontend/src/components/proxied-services/forms/SecretSelect.tsx @@ -1,3 +1,5 @@ +import { KeyIcon } from "lucide-react"; + import { FilterableSelect } from "@app/components/v3"; import { useGetProjectSecrets } from "@app/hooks/api/secrets/queries"; @@ -51,6 +53,12 @@ export const SecretSelect = ({ onChange={(newValue) => onChange((newValue as SecretOption | null)?.value ?? "")} getOptionLabel={(option) => option.label} getOptionValue={(option) => option.value} + formatOptionLabel={(option) => ( +
+ + {option.label} +
+ )} /> ); }; From 8e18572686044119c55d9859eb647007193cdb9c Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 00:44:48 +0530 Subject: [PATCH 06/43] feat: proxied service entity color + field tooltips - add --color-proxied-service (#4c7da1, steel blue) and use it for the entity icon in the add menu, resource filter, table row, and list view - add info tooltips to the Host Pattern field and the Credential Substitution section explaining matching and the placeholder-substitution flow - drop the auth-mode switch warning --- .../forms/ProxiedServiceForm.tsx | 53 +++++++++++-------- frontend/src/index.css | 1 + .../AddResourceButtons/AddResourceButtons.tsx | 2 +- .../ProxiedServiceTableRow.tsx | 2 +- .../ResourceFilter/ResourceFilter.tsx | 2 +- .../ProxiedServiceItem.tsx | 2 +- 6 files changed, 35 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx index e0e8b4b868d..c748abb3ba6 100644 --- a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -1,6 +1,6 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { PlusIcon, TrashIcon } from "lucide-react"; +import { InfoIcon, PlusIcon, TrashIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; @@ -17,7 +17,10 @@ import { Switch, Tabs, TabsList, - TabsTrigger + TabsTrigger, + Tooltip, + TooltipContent, + TooltipTrigger } from "@app/components/v3"; import { ProxiedServiceCredentialRole, @@ -171,13 +174,6 @@ export const ProxiedServiceForm = ({ const headerMode = watch("headerMode"); - // On edit, switching header modes discards the other mode's credentials on save (they are - // mutually exclusive). Warn so the change is not silent. - const originalHeaderMode = proxiedService?.credentials.some((c) => c.headerPurpose) - ? HeaderRewritingMode.BasicAuth - : HeaderRewritingMode.Headers; - const showModeSwitchWarning = isEdit && headerMode !== originalHeaderMode; - const headerFields = useFieldArray({ control, name: "headers" }); const substitutionFields = useFieldArray({ control, name: "substitutions" }); @@ -247,17 +243,24 @@ export const ProxiedServiceForm = ({ - Host Pattern + + Host Pattern + + + + + + The hosts this service applies to. Match an exact host, a wildcard (*.stripe.com), + or a port/path (api.stripe.com:443/v1/*). Comma-separate multiple patterns. + + + - - Outbound requests to a matching host are intercepted. Wildcards (e.g. *.stripe.com) - and comma-separated patterns are supported. - @@ -283,14 +286,6 @@ export const ProxiedServiceForm = ({ />
- {showModeSwitchWarning && ( -

- Switching auth type will replace the{" "} - {originalHeaderMode === HeaderRewritingMode.BasicAuth ? "Basic Auth" : "header"}{" "} - credentials on this service when you save. -

- )} - {headerMode === HeaderRewritingMode.Headers ? ( <>
@@ -423,7 +418,19 @@ export const ProxiedServiceForm = ({ {/* Credential Substitution */}
-

Credential Substitution

+
+

Credential Substitution

+ + + + + + The placeholder is delivered as an environment variable your application reads. + Your application sends that placeholder value in its request, and the proxy swaps + it for the real secret on the wire. + + +

Swap a placeholder in the request for the real credential, on the wire.

diff --git a/frontend/src/index.css b/frontend/src/index.css index bf1efa7b00f..cf998956e24 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -100,6 +100,7 @@ --color-import: #457d91; --color-secret-rotation: #4c6081; --color-override: #694c81; + --color-proxied-service: #4c7da1; /*legacy color schema */ --color-org-v1: #30b3ff; diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/AddResourceButtons/AddResourceButtons.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/AddResourceButtons/AddResourceButtons.tsx index af1bb801d9d..efa6c413057 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/AddResourceButtons/AddResourceButtons.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/AddResourceButtons/AddResourceButtons.tsx @@ -172,7 +172,7 @@ export function AddResourceButtons({ onClick={onAddProxiedService} isDisabled={!isSingleEnvSelected || !isAllowed} > - + Add Proxied Service diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx index 7858816e0b1..3bec6d64a0f 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx @@ -145,7 +145,7 @@ export const ProxiedServiceTableRow = ({ {!isSingleEnvView && isExpanded ? ( ) : ( - + )} + icon: }, { type: "secret", label: "Secrets", icon: } ]; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceItem.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceItem.tsx index 77bf2c93964..523797ca38f 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceItem.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceItem.tsx @@ -42,7 +42,7 @@ export const ProxiedServiceItem = ({ proxiedService, onEdit, onDelete }: Props) aria-label={`${isExpanded ? "Collapse" : "Expand"} proxied service ${name}`} >
- +
From d542db56a54664cac9a31709fb3d4bcdcfaa611b Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 00:49:53 +0530 Subject: [PATCH 07/43] fix: truncate long host patterns in the overview proxied service row --- .../ProxiedServiceTableRow/ProxiedServiceTableRow.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx index 3bec6d64a0f..cee1358b35f 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx @@ -124,7 +124,11 @@ export const ProxiedServiceTableRow = ({ "group-hover:mr-24" )} > - {proxiedService.hostPattern} + + + {proxiedService.hostPattern} + + {!proxiedService.isEnabled && Disabled}
); From b030de52abf8f2f8b62ce73d02f283db0fc876b7 Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 01:02:09 +0530 Subject: [PATCH 08/43] fix: truncate host pattern badge, add proxied service count to overview footer - host pattern badge truncates at 240px with a hover title (was overflowing the row) - hoist the secret picker option formatter out of render (react/no-unstable-nested-components) - show a proxied service count in the overview ResourceCount footer --- .../proxied-services/forms/SecretSelect.tsx | 15 +++++++----- .../OverviewPage/OverviewPage.tsx | 2 ++ .../ProxiedServiceTableRow.tsx | 4 ++-- .../ResourceCount/ResourceCount.tsx | 24 +++++++++++++++++-- 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/proxied-services/forms/SecretSelect.tsx b/frontend/src/components/proxied-services/forms/SecretSelect.tsx index 77ab99cdd1e..1d3f5233c07 100644 --- a/frontend/src/components/proxied-services/forms/SecretSelect.tsx +++ b/frontend/src/components/proxied-services/forms/SecretSelect.tsx @@ -16,6 +16,14 @@ type Props = { type SecretOption = { label: string; value: string }; +// Hoisted out of the component so it isn't recreated each render (react/no-unstable-nested-components). +const formatSecretOption = (option: SecretOption) => ( +
+ + {option.label} +
+); + // Picks a secret key from the current folder. Uses the searchable FilterableSelect so long secret // lists stay usable. A stale reference (secret renamed/deleted) still renders its raw key. export const SecretSelect = ({ @@ -53,12 +61,7 @@ export const SecretSelect = ({ onChange={(newValue) => onChange((newValue as SecretOption | null)?.value ?? "")} getOptionLabel={(option) => option.label} getOptionValue={(option) => option.value} - formatOptionLabel={(option) => ( -
- - {option.label} -
- )} + formatOptionLabel={formatSecretOption} /> ); }; diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 32096ec0cbc..5822895fade 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -693,6 +693,7 @@ const OverviewPageContent = () => { totalSecretCount, totalDynamicSecretCount, totalSecretRotationCount, + totalProxiedServiceCount, totalImportCount, totalCount = 0, totalUniqueFoldersInPage, @@ -3424,6 +3425,7 @@ const OverviewPageContent = () => { folderCount={totalFolderCount} importCount={totalImportCount} secretRotationCount={totalSecretRotationCount} + proxiedServiceCount={totalProxiedServiceCount} /> } count={totalCount} diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx index cee1358b35f..d73ebfe2d13 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx @@ -124,8 +124,8 @@ export const ProxiedServiceTableRow = ({ "group-hover:mr-24" )} > - - + + {proxiedService.hostPattern} diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/ResourceCount/ResourceCount.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/ResourceCount/ResourceCount.tsx index a82277fe2cd..3bcf77d05a5 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/ResourceCount/ResourceCount.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/ResourceCount/ResourceCount.tsx @@ -1,4 +1,11 @@ -import { FingerprintIcon, FolderIcon, ImportIcon, KeyIcon, RefreshCwIcon } from "lucide-react"; +import { + ArrowRightLeftIcon, + FingerprintIcon, + FolderIcon, + ImportIcon, + KeyIcon, + RefreshCwIcon +} from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@app/components/v3"; @@ -8,6 +15,7 @@ type Props = { secretCount?: number; dynamicSecretCount?: number; secretRotationCount?: number; + proxiedServiceCount?: number; }; export function ResourceCount({ @@ -15,7 +23,8 @@ export function ResourceCount({ dynamicSecretCount = 0, secretCount = 0, importCount = 0, - secretRotationCount = 0 + secretRotationCount = 0, + proxiedServiceCount = 0 }: Props) { return (
@@ -63,6 +72,17 @@ export function ResourceCount({ Total secret rotation count matching filters )} + {proxiedServiceCount > 0 && ( + + +
+ + {proxiedServiceCount} +
+
+ Total proxied service count matching filters +
+ )} {secretCount > 0 && ( From c711f6568d42a5c2b5daff80e2a45b7ef3791e4d Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 01:10:06 +0530 Subject: [PATCH 09/43] fix: don't count proxied services as no-access secrets in overview The no-access secret row count subtracted every resource type except proxied services, so N proxied services in a page rendered N phantom 'no permission' secret rows. Add totalUniqueProxiedServicesInPage and subtract it too. --- frontend/src/hooks/api/dashboard/queries.tsx | 6 +++++- .../src/pages/secret-manager/OverviewPage/OverviewPage.tsx | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx index e0d0d18e630..acf1d919bef 100644 --- a/frontend/src/hooks/api/dashboard/queries.tsx +++ b/frontend/src/hooks/api/dashboard/queries.tsx @@ -283,6 +283,9 @@ export const useGetProjectSecretsOverview = ( const uniqueSecretImports = select.imports ? unique(select.imports, (i) => i.id) : []; const uniqueSecretRotations = secretRotations ? unique(secretRotations, (i) => i.name) : []; const uniqueHoneyTokens = honeyTokens ? unique(honeyTokens, (i) => i.name) : []; + const uniqueProxiedServices = select.proxiedServices + ? unique(select.proxiedServices, (i) => i.name) + : []; return { ...select, @@ -299,7 +302,8 @@ export const useGetProjectSecretsOverview = ( totalUniqueFoldersInPage: uniqueFolders.length, totalUniqueSecretImportsInPage: uniqueSecretImports.length, totalUniqueSecretRotationsInPage: uniqueSecretRotations.length, - totalUniqueHoneyTokensInPage: uniqueHoneyTokens.length + totalUniqueHoneyTokensInPage: uniqueHoneyTokens.length, + totalUniqueProxiedServicesInPage: uniqueProxiedServices.length }; }, []), placeholderData: (previousData) => previousData diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 5822895fade..4238aae994e 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -702,6 +702,7 @@ const OverviewPageContent = () => { totalUniqueDynamicSecretsInPage, totalUniqueSecretRotationsInPage, totalUniqueHoneyTokensInPage, + totalUniqueProxiedServicesInPage, importedByEnvs, usedBySecretSyncs } = overview ?? {}; @@ -3401,7 +3402,8 @@ const OverviewPageContent = () => { (totalUniqueSecretsInPage || 0) - (totalUniqueSecretImportsInPage || 0) - (totalUniqueSecretRotationsInPage || 0) - - (totalUniqueHoneyTokensInPage || 0), + (totalUniqueHoneyTokensInPage || 0) - + (totalUniqueProxiedServicesInPage || 0), 0 )} /> From ccb004ccd9024df8859117fb211e6e4c6bc18267 Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 11:12:19 +0530 Subject: [PATCH 10/43] feat: audit logs, license soft-gate, and validation hardening for proxied services Backend: - emit audit logs on proxied service create/update/delete - soft-gate the dashboard proxied-service methods on the secretsBrokering license (return empty rather than throwing, so the shared overview isn't broken) - move host-pattern + credential validation schemas into proxied-service-schemas.ts and switch the backend regexes to RE2 (codebase convention) - de-duplicate the sanitized response schema (shared base + credential picks) Frontend: - mirror the host-pattern grammar + 255 cap in the form schema (inline errors) - surface the backend error message on save failure instead of a generic toast - gate the dashboard includeProxiedServices flag on the subscription --- .../ee/routes/v1/proxied-service-router.ts | 242 ++++-------------- .../ee/services/audit-log/audit-log-types.ts | 42 +++ .../proxied-service-schemas.ts | 191 ++++++++++++++ .../proxied-service-service.ts | 20 +- backend/src/server/routes/sanitizedSchemas.ts | 30 +-- .../forms/ProxiedServiceForm.tsx | 14 +- .../proxied-services/forms/schema.ts | 50 +++- .../OverviewPage/OverviewPage.tsx | 6 +- .../SecretDashboardPage.tsx | 6 +- 9 files changed, 381 insertions(+), 220 deletions(-) create mode 100644 backend/src/ee/services/proxied-service/proxied-service-schemas.ts diff --git a/backend/src/ee/routes/v1/proxied-service-router.ts b/backend/src/ee/routes/v1/proxied-service-router.ts index 3ee88d1542e..120ed0da868 100644 --- a/backend/src/ee/routes/v1/proxied-service-router.ts +++ b/backend/src/ee/routes/v1/proxied-service-router.ts @@ -1,194 +1,18 @@ import { z } from "zod"; -import { ProxiedServiceCredentialsSchema, ProxiedServicesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { - ProxiedServiceCredentialRole, - ProxiedServiceHeaderPurpose, - ProxiedServiceSubstitutionSurface -} from "@app/ee/services/proxied-service/proxied-service-enums"; + CredentialsArraySchema, + hostPatternSchema, + ProxiedServiceWithCredentialsSchema, + SanitizedProxiedServiceBaseSchema +} from "@app/ee/services/proxied-service/proxied-service-schemas"; import { BadRequestError } from "@app/lib/errors"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -// One host label: alphanumerics and internal hyphens, optionally a single leading "*." wildcard. -const HOST_LABELS_RE = /^(?:\*\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/i; - -// Validates the comma-separated hostPattern grammar so malformed values fail at creation -// instead of silently never matching at proxy time. -const hostPatternSchema = z - .string() - .trim() - .min(1, "Host pattern is required") - .max(255) - .superRefine((raw, ctx) => { - const segments = raw.split(",").map((s) => s.trim()); - segments.forEach((seg) => { - if (seg === "") { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Host pattern has an empty entry" }); - return; - } - if (seg.includes("://")) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: `"${seg}" must not include a scheme (e.g. https://)` }); - return; - } - let hostPort = seg; - const slashIdx = hostPort.indexOf("/"); - if (slashIdx !== -1) hostPort = hostPort.slice(0, slashIdx); // path portion is free-form - let host = hostPort; - const colonIdx = hostPort.lastIndexOf(":"); - if (colonIdx !== -1) { - const portStr = hostPort.slice(colonIdx + 1); - host = hostPort.slice(0, colonIdx); - const port = Number(portStr); - if (!/^\d+$/.test(portStr) || port < 1 || port > 65535) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: `"${seg}" has an invalid port` }); - return; - } - } - if (!HOST_LABELS_RE.test(host)) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: `"${seg}" is not a valid host pattern` }); - } - }); - }); - -const CredentialInputSchema = z - .object({ - secretKey: z.string().trim().min(1), - role: z.nativeEnum(ProxiedServiceCredentialRole), - headerName: z.string().trim().min(1).optional(), - headerPrefix: z.string().trim().optional(), - headerPurpose: z.nativeEnum(ProxiedServiceHeaderPurpose).optional(), - placeholderKey: z.string().trim().min(1).optional(), - placeholderValue: z.string().trim().min(1).optional(), - substitutionSurfaces: z.array(z.nativeEnum(ProxiedServiceSubstitutionSurface)).nonempty().optional() - }) - .superRefine((cred, ctx) => { - if (cred.role === ProxiedServiceCredentialRole.HeaderRewrite) { - // either a named header (optionally with a prefix) or a basic-auth purpose, not both - if (cred.headerPurpose) { - if (cred.headerName || cred.headerPrefix) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "headerPurpose cannot be combined with headerName or headerPrefix" - }); - } - } else if (!cred.headerName) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Header rewriting requires either headerName or headerPurpose" - }); - } - } else if (!cred.placeholderKey || !cred.placeholderValue || !cred.substitutionSurfaces) { - // credential substitution - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Credential substitution requires placeholderKey, placeholderValue, and substitutionSurfaces" - }); - } - }); - -// Cross-credential rules that cannot be checked one row at a time: basic-auth pairing, -// unique header names / placeholders, and header-rewrite modes that cannot coexist. -const CredentialsArraySchema = CredentialInputSchema.array() - .min(1, "At least one credential is required") - .superRefine((credentials, ctx) => { - const headerNameCounts = new Map(); - const placeholderKeys = new Set(); - const placeholderValues = new Set(); - let usernameCount = 0; - let passwordCount = 0; - - credentials.forEach((cred, i) => { - if (cred.role === ProxiedServiceCredentialRole.HeaderRewrite) { - if (cred.headerPurpose === ProxiedServiceHeaderPurpose.Username) usernameCount += 1; - else if (cred.headerPurpose === ProxiedServiceHeaderPurpose.Password) passwordCount += 1; - else if (cred.headerName) { - const key = cred.headerName.toLowerCase(); - headerNameCounts.set(key, (headerNameCounts.get(key) ?? 0) + 1); - } - } else { - if (cred.placeholderKey) { - if (placeholderKeys.has(cred.placeholderKey)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Duplicate placeholder env var "${cred.placeholderKey}"`, - path: [i, "placeholderKey"] - }); - } - placeholderKeys.add(cred.placeholderKey); - } - if (cred.placeholderValue) { - if (placeholderValues.has(cred.placeholderValue)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Two credentials share the same placeholder value", - path: [i, "placeholderValue"] - }); - } - placeholderValues.add(cred.placeholderValue); - } - } - }); - - headerNameCounts.forEach((count, name) => { - if (count > 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Header "${name}" is set by more than one credential` - }); - } - }); - - if (usernameCount > 1 || passwordCount > 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Basic auth allows at most one username and one password credential" - }); - } - if (usernameCount !== passwordCount) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Basic auth requires both a username and a password credential" - }); - } - // Basic auth already owns the Authorization header, so it cannot coexist with named header rewrites. - if (usernameCount + passwordCount > 0 && headerNameCounts.size > 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Basic auth cannot be combined with other header-rewrite credentials on the same service" - }); - } - }); - -const SanitizedCredentialSchema = ProxiedServiceCredentialsSchema.pick({ - id: true, - serviceId: true, - secretKey: true, - role: true, - headerName: true, - headerPrefix: true, - headerPurpose: true, - placeholderKey: true, - placeholderValue: true, - substitutionSurfaces: true -}); - -const SanitizedServiceSchema = ProxiedServicesSchema.pick({ - id: true, - name: true, - hostPattern: true, - isEnabled: true, - folderId: true, - createdAt: true, - updatedAt: true -}); - -const ServiceWithCredentialsSchema = SanitizedServiceSchema.extend({ - credentials: SanitizedCredentialSchema.array() -}); - const ScopeQuerySchema = z.object({ projectId: z.string().trim().min(1), environment: z.string().trim().min(1), @@ -212,11 +36,26 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = credentials: CredentialsArraySchema }), response: { - 200: z.object({ service: ServiceWithCredentialsSchema }) + 200: z.object({ service: ProxiedServiceWithCredentialsSchema }) } }, handler: async (req) => { const service = await server.services.proxiedService.create(req.body, req.permission); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.projectId, + event: { + type: EventType.CREATE_PROXIED_SERVICE, + metadata: { + proxiedServiceId: service.id, + name: service.name, + hostPattern: service.hostPattern, + secretKeys: [...new Set(req.body.credentials.map((c) => c.secretKey))], + environment: req.body.environment, + secretPath: req.body.secretPath + } + } + }); return { service }; } }); @@ -230,7 +69,7 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = querystring: ScopeQuerySchema, response: { 200: z.object({ - services: ServiceWithCredentialsSchema.extend({ canProxy: z.boolean() }).array() + services: ProxiedServiceWithCredentialsSchema.extend({ canProxy: z.boolean() }).array() }) } }, @@ -250,7 +89,7 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = querystring: ScopeQuerySchema.partial(), response: { 200: z.object({ - service: ServiceWithCredentialsSchema.extend({ canProxy: z.boolean() }) + service: ProxiedServiceWithCredentialsSchema.extend({ canProxy: z.boolean() }) }) } }, @@ -289,7 +128,7 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = credentials: CredentialsArraySchema.optional() }), response: { - 200: z.object({ service: ServiceWithCredentialsSchema }) + 200: z.object({ service: ProxiedServiceWithCredentialsSchema }) } }, handler: async (req) => { @@ -297,6 +136,22 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = { serviceId: req.params.serviceId, ...req.body }, req.permission ); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: service.projectId, + event: { + type: EventType.UPDATE_PROXIED_SERVICE, + metadata: { + proxiedServiceId: service.id, + name: service.name, + hostPattern: service.hostPattern, + updatedFields: Object.keys(req.body), + secretKeys: [...new Set(service.credentials.map((c) => c.secretKey))], + environment: service.environment, + secretPath: service.secretPath + } + } + }); return { service }; } }); @@ -309,7 +164,7 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = schema: { params: z.object({ serviceId: z.string().uuid() }), response: { - 200: z.object({ service: SanitizedServiceSchema }) + 200: z.object({ service: SanitizedProxiedServiceBaseSchema }) } }, handler: async (req) => { @@ -317,6 +172,19 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = { serviceId: req.params.serviceId }, req.permission ); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: service.projectId, + event: { + type: EventType.DELETE_PROXIED_SERVICE, + metadata: { + proxiedServiceId: service.id, + name: service.name, + environment: service.environmentSlug, + secretPath: service.secretPath + } + } + }); return { service }; } }); 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 d56c6ecfe9a..0a0812c60d7 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -819,6 +819,9 @@ export enum EventType { CREATE_DYNAMIC_SECRET = "create-dynamic-secret", UPDATE_DYNAMIC_SECRET = "update-dynamic-secret", DELETE_DYNAMIC_SECRET = "delete-dynamic-secret", + CREATE_PROXIED_SERVICE = "create-proxied-service", + UPDATE_PROXIED_SERVICE = "update-proxied-service", + DELETE_PROXIED_SERVICE = "delete-proxied-service", GET_DYNAMIC_SECRET = "get-dynamic-secret", LIST_DYNAMIC_SECRETS = "list-dynamic-secrets", @@ -6907,6 +6910,42 @@ interface DeleteDynamicSecretEvent { }; } +interface CreateProxiedServiceEvent { + type: EventType.CREATE_PROXIED_SERVICE; + metadata: { + proxiedServiceId: string; + name: string; + hostPattern: string; + // secret key names only; never placeholder/secret values + secretKeys: string[]; + environment: string; + secretPath: string; + }; +} + +interface UpdateProxiedServiceEvent { + type: EventType.UPDATE_PROXIED_SERVICE; + metadata: { + proxiedServiceId: string; + name: string; + hostPattern: string; + updatedFields: string[]; + secretKeys: string[]; + environment: string; + secretPath: string; + }; +} + +interface DeleteProxiedServiceEvent { + type: EventType.DELETE_PROXIED_SERVICE; + metadata: { + proxiedServiceId: string; + name: string; + environment: string; + secretPath: string; + }; +} + interface GetDynamicSecretEvent { type: EventType.GET_DYNAMIC_SECRET; metadata: { @@ -7957,6 +7996,9 @@ export type Event = | McpServerSyncToolsEvent | McpActivityLogListEvent | CreateDynamicSecretEvent + | CreateProxiedServiceEvent + | UpdateProxiedServiceEvent + | DeleteProxiedServiceEvent | UpdateDynamicSecretEvent | DeleteDynamicSecretEvent | GetDynamicSecretEvent diff --git a/backend/src/ee/services/proxied-service/proxied-service-schemas.ts b/backend/src/ee/services/proxied-service/proxied-service-schemas.ts new file mode 100644 index 00000000000..7f1196b9a74 --- /dev/null +++ b/backend/src/ee/services/proxied-service/proxied-service-schemas.ts @@ -0,0 +1,191 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { ProxiedServiceCredentialsSchema, ProxiedServicesSchema } from "@app/db/schemas"; + +import { + ProxiedServiceCredentialRole, + ProxiedServiceHeaderPurpose, + ProxiedServiceSubstitutionSurface +} from "./proxied-service-enums"; + +// One host label: alphanumerics and internal hyphens, optionally a single leading "*." wildcard. +// RE2 (per codebase convention) for linear-time matching on user input. +const HOST_LABELS_RE = new RE2(/^(?:\*\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/i); +const PORT_RE = new RE2(/^\d+$/); + +// Validates the comma-separated hostPattern grammar so malformed values fail at creation +// instead of silently never matching at proxy time. The matching grammar itself lives in the +// agent-proxy CLI (packages/agentproxy/match.go); keep the two in sync. +export const hostPatternSchema = z + .string() + .trim() + .min(1, "Host pattern is required") + .max(255) + .superRefine((raw, ctx) => { + const segments = raw.split(",").map((s) => s.trim()); + segments.forEach((seg) => { + if (seg === "") { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Host pattern has an empty entry" }); + return; + } + if (seg.includes("://")) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `"${seg}" must not include a scheme (e.g. https://)` }); + return; + } + let hostPort = seg; + const slashIdx = hostPort.indexOf("/"); + if (slashIdx !== -1) hostPort = hostPort.slice(0, slashIdx); // path portion is free-form + let host = hostPort; + const colonIdx = hostPort.lastIndexOf(":"); + if (colonIdx !== -1) { + const portStr = hostPort.slice(colonIdx + 1); + host = hostPort.slice(0, colonIdx); + const port = Number(portStr); + if (!PORT_RE.test(portStr) || port < 1 || port > 65535) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `"${seg}" has an invalid port` }); + return; + } + } + if (!HOST_LABELS_RE.test(host)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `"${seg}" is not a valid host pattern` }); + } + }); + }); + +const CredentialInputSchema = z + .object({ + secretKey: z.string().trim().min(1), + role: z.nativeEnum(ProxiedServiceCredentialRole), + headerName: z.string().trim().min(1).optional(), + headerPrefix: z.string().trim().optional(), + headerPurpose: z.nativeEnum(ProxiedServiceHeaderPurpose).optional(), + placeholderKey: z.string().trim().min(1).optional(), + placeholderValue: z.string().trim().min(1).optional(), + substitutionSurfaces: z.array(z.nativeEnum(ProxiedServiceSubstitutionSurface)).nonempty().optional() + }) + .superRefine((cred, ctx) => { + if (cred.role === ProxiedServiceCredentialRole.HeaderRewrite) { + // either a named header (optionally with a prefix) or a basic-auth purpose, not both + if (cred.headerPurpose) { + if (cred.headerName || cred.headerPrefix) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "headerPurpose cannot be combined with headerName or headerPrefix" + }); + } + } else if (!cred.headerName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Header rewriting requires either headerName or headerPurpose" + }); + } + } else if (!cred.placeholderKey || !cred.placeholderValue || !cred.substitutionSurfaces) { + // credential substitution + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Credential substitution requires placeholderKey, placeholderValue, and substitutionSurfaces" + }); + } + }); + +// Cross-credential rules that cannot be checked one row at a time: basic-auth pairing, +// unique header names / placeholders, and header-rewrite modes that cannot coexist. +export const CredentialsArraySchema = CredentialInputSchema.array() + .min(1, "At least one credential is required") + .superRefine((credentials, ctx) => { + const headerNameCounts = new Map(); + const placeholderKeys = new Set(); + const placeholderValues = new Set(); + let usernameCount = 0; + let passwordCount = 0; + + credentials.forEach((cred, i) => { + if (cred.role === ProxiedServiceCredentialRole.HeaderRewrite) { + if (cred.headerPurpose === ProxiedServiceHeaderPurpose.Username) usernameCount += 1; + else if (cred.headerPurpose === ProxiedServiceHeaderPurpose.Password) passwordCount += 1; + else if (cred.headerName) { + const key = cred.headerName.toLowerCase(); + headerNameCounts.set(key, (headerNameCounts.get(key) ?? 0) + 1); + } + } else { + if (cred.placeholderKey) { + if (placeholderKeys.has(cred.placeholderKey)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate placeholder env var "${cred.placeholderKey}"`, + path: [i, "placeholderKey"] + }); + } + placeholderKeys.add(cred.placeholderKey); + } + if (cred.placeholderValue) { + if (placeholderValues.has(cred.placeholderValue)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Two credentials share the same placeholder value", + path: [i, "placeholderValue"] + }); + } + placeholderValues.add(cred.placeholderValue); + } + } + }); + + headerNameCounts.forEach((count, name) => { + if (count > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Header "${name}" is set by more than one credential` + }); + } + }); + + if (usernameCount > 1 || passwordCount > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Basic auth allows at most one username and one password credential" + }); + } + if (usernameCount !== passwordCount) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Basic auth requires both a username and a password credential" + }); + } + // Basic auth already owns the Authorization header, so it cannot coexist with named header rewrites. + if (usernameCount + passwordCount > 0 && headerNameCounts.size > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Basic auth cannot be combined with other header-rewrite credentials on the same service" + }); + } + }); + +// Sanitized response shapes, shared by the CRUD router and the dashboard aggregate schema. +export const SanitizedProxiedServiceCredentialSchema = ProxiedServiceCredentialsSchema.pick({ + id: true, + serviceId: true, + secretKey: true, + role: true, + headerName: true, + headerPrefix: true, + headerPurpose: true, + placeholderKey: true, + placeholderValue: true, + substitutionSurfaces: true +}); + +export const SanitizedProxiedServiceBaseSchema = ProxiedServicesSchema.pick({ + id: true, + name: true, + hostPattern: true, + isEnabled: true, + folderId: true, + createdAt: true, + updatedAt: true +}); + +export const ProxiedServiceWithCredentialsSchema = SanitizedProxiedServiceBaseSchema.extend({ + credentials: SanitizedProxiedServiceCredentialSchema.array() +}); 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 6ad1cd50adb..595b74d7145 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-service.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-service.ts @@ -351,7 +351,14 @@ export const proxiedServiceServiceFactory = ({ : []; } - return { ...updated, credentials: updatedCredentials }; + // scope fields are stripped from the API response but let the router emit an audit log + return { + ...updated, + credentials: updatedCredentials, + projectId: service.projectId, + environment: service.environmentSlug, + secretPath: resolvedSecretPath + }; }); }; @@ -381,13 +388,18 @@ export const proxiedServiceServiceFactory = ({ // credentials cascade on serviceId FK await proxiedServiceDAL.deleteById(serviceId); - return service; + return { ...service, secretPath: resolvedSecretPath }; }; const getDashboardProxiedServiceCount = async ( { projectId, environments, secretPath, search }: TProxiedServiceDashboardCountDTO, actor: OrgServiceActor ) => { + // soft license gate: the dashboard aggregate runs alongside secrets/folders, so we return an + // empty result rather than throwing (a throw would break the whole overview for unlicensed orgs) + const plan = await licenseService.getPlan(actor.orgId); + if (!plan.secretsBrokering) return 0; + const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, @@ -431,6 +443,10 @@ export const proxiedServiceServiceFactory = ({ }: TProxiedServiceDashboardListDTO, actor: OrgServiceActor ) => { + // soft license gate (see getDashboardProxiedServiceCount) + const plan = await licenseService.getPlan(actor.orgId); + if (!plan.secretsBrokering) return []; + const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 0ebb39163f4..521067b937e 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -10,14 +10,16 @@ import { OrgRolesSchema, ProjectRolesSchema, ProjectsSchema, - ProxiedServiceCredentialsSchema, - ProxiedServicesSchema, SecretApprovalPoliciesSchema, SecretSharingSchema, SecretTagsSchema, UsersSchema } from "@app/db/schemas"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { + SanitizedProxiedServiceBaseSchema, + SanitizedProxiedServiceCredentialSchema +} from "@app/ee/services/proxied-service/proxied-service-schemas"; import { ResourceMetadataNonEncryptionSchema } from "@app/services/resource-metadata/resource-metadata-schema"; import { UnpackedPermissionSchema } from "./sanitizedSchema/permission"; @@ -291,15 +293,8 @@ export const SanitizedHoneyTokenSchema = HoneyTokensSchema.pick({ }) }); -export const SanitizedProxiedServiceSchema = ProxiedServicesSchema.pick({ - id: true, - name: true, - hostPattern: true, - isEnabled: true, - folderId: true, - createdAt: true, - updatedAt: true -}).extend({ +// dashboard aggregate shape: shared base + credential picks, plus environment/folder for the UI +export const SanitizedProxiedServiceSchema = SanitizedProxiedServiceBaseSchema.extend({ environment: z.object({ id: z.string(), name: z.string(), @@ -308,18 +303,7 @@ export const SanitizedProxiedServiceSchema = ProxiedServicesSchema.pick({ folder: z.object({ path: z.string() }), - credentials: ProxiedServiceCredentialsSchema.pick({ - id: true, - serviceId: true, - secretKey: true, - role: true, - headerName: true, - headerPrefix: true, - headerPurpose: true, - placeholderKey: true, - placeholderValue: true, - substitutionSurfaces: true - }).array() + credentials: SanitizedProxiedServiceCredentialSchema.array() }); export const SanitizedProjectSchema = ProjectsSchema.pick({ diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx index c748abb3ba6..7b57a3020ed 100644 --- a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -1,5 +1,6 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import { AxiosError } from "axios"; import { InfoIcon, PlusIcon, TrashIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; @@ -211,9 +212,18 @@ export const ProxiedServiceForm = ({ type: "success" }); onComplete(); - } catch { + } catch (err) { + // surface the backend reason (e.g. "Referenced secret(s) not found", permission errors) + const raw = (err as AxiosError<{ message?: string | { message?: string }[] }>)?.response?.data + ?.message; + const detail = Array.isArray(raw) + ? raw + .map((issue) => issue?.message) + .filter(Boolean) + .join(", ") + : raw; createNotification({ - text: `Failed to ${isEdit ? "update" : "create"} proxied service`, + text: `Failed to ${isEdit ? "update" : "create"} proxied service${detail ? `: ${detail}` : ""}`, type: "error" }); } diff --git a/frontend/src/components/proxied-services/forms/schema.ts b/frontend/src/components/proxied-services/forms/schema.ts index 3e88f24336b..dec2264f09c 100644 --- a/frontend/src/components/proxied-services/forms/schema.ts +++ b/frontend/src/components/proxied-services/forms/schema.ts @@ -7,6 +7,54 @@ import { const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +// mirrors the backend host-pattern grammar (proxied-service-schemas.ts) so bad patterns fail inline +const hostLabelsRe = + /^(?:\*\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/i; + +const hostPatternField = z + .string() + .trim() + .min(1, "Host pattern is required") + .max(255, "Host pattern is too long (max 255 characters)") + .superRefine((raw, ctx) => { + raw + .split(",") + .map((s) => s.trim()) + .forEach((seg) => { + if (seg === "") { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Host pattern has an empty entry" }); + return; + } + if (seg.includes("://")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `"${seg}" must not include a scheme (e.g. https://)` + }); + return; + } + let hostPort = seg; + const slashIdx = hostPort.indexOf("/"); + if (slashIdx !== -1) hostPort = hostPort.slice(0, slashIdx); + let host = hostPort; + const colonIdx = hostPort.lastIndexOf(":"); + if (colonIdx !== -1) { + const portStr = hostPort.slice(colonIdx + 1); + host = hostPort.slice(0, colonIdx); + const port = Number(portStr); + if (!/^\d+$/.test(portStr) || port < 1 || port > 65535) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `"${seg}" has an invalid port` }); + return; + } + } + if (!hostLabelsRe.test(host)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `"${seg}" is not a valid host pattern` + }); + } + }); + }); + // Field-level schemas are permissive; required-ness is enforced conditionally in the // top-level superRefine so that fields belonging to the inactive header mode (which are not // rendered) don't block submission. @@ -45,7 +93,7 @@ export const proxiedServiceFormSchema = z .min(1, "Name is required") .max(64) .regex(slugRegex, "Lowercase letters, numbers, and hyphens only"), - hostPattern: z.string().trim().min(1, "Host pattern is required"), + hostPattern: hostPatternField, isEnabled: z.boolean().default(true), headerMode: z.nativeEnum(HeaderRewritingMode).default(HeaderRewritingMode.Headers), headers: z.array(headerCredentialSchema).default([]), diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 4238aae994e..2256e99a678 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -671,9 +671,9 @@ const OverviewPageContent = () => { includeImports: isFilteredByResources ? (filter[RowType.SecretImport] ?? true) : true, includeSecretRotations: isFilteredByResources ? filter.rotation : true, includeHoneyTokens: isFilteredByResources ? (filter[RowType.HoneyToken] ?? true) : true, - includeProxiedServices: isFilteredByResources - ? (filter[RowType.ProxiedService] ?? true) - : true, + includeProxiedServices: + Boolean(subscription?.secretsBrokering) && + (isFilteredByResources ? (filter[RowType.ProxiedService] ?? true) : true), search: searchFilter, tags: tagFilter, limit, diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index bb9fcf7b364..78a923e19aa 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -32,7 +32,8 @@ import { ProjectPermissionSub, useOrganization, useProject, - useProjectPermission + useProjectPermission, + useSubscription } from "@app/context"; import { ProjectPermissionCommitsActions, @@ -118,6 +119,7 @@ const Page = () => { }); const { permission } = useProjectPermission(); + const { subscription } = useSubscription(); const { mutateAsync: createCommit, isPending: isCommitPending } = useCreateCommit(); const tableRef = useRef(null); @@ -328,7 +330,7 @@ const Page = () => { includeSecretRotations: canReadSecretRotations && (isResourceTypeFiltered ? filter.include.rotation : true), includeHoneyTokens: true, - includeProxiedServices: true, + includeProxiedServices: Boolean(subscription?.secretsBrokering), tags: filter.tags }); From 2a5d164dbd8156a818fd641b635043133c9788b3 Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 11:22:28 +0530 Subject: [PATCH 11/43] feat: register proxied service audit events in the frontend audit log viewer Add create/update/delete proxied service events to the audit-log EventType enum and display-name map so they render with labels and appear in the event filter. --- frontend/src/hooks/api/auditLogs/constants.tsx | 3 +++ frontend/src/hooks/api/auditLogs/enums.tsx | 3 +++ 2 files changed, 6 insertions(+) diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index f9d62d0b987..34bf6396813 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -403,6 +403,9 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.CREATE_DYNAMIC_SECRET]: "Create Dynamic Secret", [EventType.UPDATE_DYNAMIC_SECRET]: "Update Dynamic Secret", [EventType.DELETE_DYNAMIC_SECRET]: "Delete Dynamic Secret", + [EventType.CREATE_PROXIED_SERVICE]: "Create Proxied Service", + [EventType.UPDATE_PROXIED_SERVICE]: "Update Proxied Service", + [EventType.DELETE_PROXIED_SERVICE]: "Delete Proxied Service", [EventType.GET_DYNAMIC_SECRET]: "Get Dynamic Secret", [EventType.LIST_DYNAMIC_SECRETS]: "List Dynamic Secrets", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 8739be061f6..707d28a0fa1 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -395,6 +395,9 @@ export enum EventType { CREATE_DYNAMIC_SECRET = "create-dynamic-secret", UPDATE_DYNAMIC_SECRET = "update-dynamic-secret", DELETE_DYNAMIC_SECRET = "delete-dynamic-secret", + CREATE_PROXIED_SERVICE = "create-proxied-service", + UPDATE_PROXIED_SERVICE = "update-proxied-service", + DELETE_PROXIED_SERVICE = "delete-proxied-service", GET_DYNAMIC_SECRET = "get-dynamic-secret", LIST_DYNAMIC_SECRETS = "list-dynamic-secrets", From 74900792a512a87ace1d339452a7b3c9e2e8a66c Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 16:05:48 +0530 Subject: [PATCH 12/43] refactor: address review feedback for proxied services Backend: - sanitize ILIKE search input in proxied service DAL - use isUuidV4 helper for id-or-name disambiguation - group proxied service audit events contiguously - add audit event for intermediate CA signing - unify update/delete return shape; drop redundant credentials fetch - type keyAlgorithm response as CertKeyAlgorithm; unify KeyUsage style Frontend: - add Agent / Agent Proxy role display names - remove dead proxied service query hooks, DTOs, and canProxy field - drop unused projectId from update/delete mutations - type-to-confirm delete modal, truncate host pattern badge - wire Enabled label to its switch; add color to DESIGN.md accent list --- DESIGN.md | 3 +- .../src/ee/routes/v1/agent-proxy-ca-router.ts | 18 ++- .../ee/routes/v1/proxied-service-router.ts | 5 +- .../agent-proxy-ca/agent-proxy-ca-service.ts | 14 +- .../ee/services/audit-log/audit-log-types.ts | 58 ++++--- .../proxied-service/proxied-service-dal.ts | 147 +++++++++++++----- .../proxied-service-service.ts | 47 +++--- .../proxied-service/proxied-service-types.ts | 1 + .../DeleteProxiedServiceModal.tsx | 47 ++++-- .../forms/ProxiedServiceForm.tsx | 12 +- .../proxied-services/forms/schema.ts | 13 +- frontend/src/helpers/roles.ts | 4 + .../src/hooks/api/auditLogs/constants.tsx | 5 +- frontend/src/hooks/api/auditLogs/enums.tsx | 7 +- .../hooks/api/proxiedServices/mutations.tsx | 8 +- .../src/hooks/api/proxiedServices/queries.tsx | 47 +----- .../src/hooks/api/proxiedServices/types.ts | 15 +- .../OverviewPage/OverviewPage.tsx | 1 - .../ProxiedServiceItem.tsx | 6 +- .../ProxiedServiceListView.tsx | 1 - 20 files changed, 270 insertions(+), 189 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 653ae50cf86..a94b692741e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -88,7 +88,8 @@ The `mineshaft-*` scale (50–900) is the underlying neutral ramp; see Reserved for resource types in the secret management product: `--color-folder`, `--color-secret`, `--color-dynamic-secret`, -`--color-import`, `--color-secret-rotation`, `--color-override`. +`--color-import`, `--color-secret-rotation`, `--color-override`, +`--color-proxied-service`. Do not repurpose these for generic UI. ### Tint pattern diff --git a/backend/src/ee/routes/v1/agent-proxy-ca-router.ts b/backend/src/ee/routes/v1/agent-proxy-ca-router.ts index d1ec2f2c2bc..1dec2487ba6 100644 --- a/backend/src/ee/routes/v1/agent-proxy-ca-router.ts +++ b/backend/src/ee/routes/v1/agent-proxy-ca-router.ts @@ -1,8 +1,10 @@ import { z } from "zod"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; export const registerAgentProxyCaRouter = async (server: FastifyZodProvider) => { server.route({ @@ -14,7 +16,7 @@ export const registerAgentProxyCaRouter = async (server: FastifyZodProvider) => response: { 200: z.object({ certificate: z.string(), - keyAlgorithm: z.string(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm), issuedAt: z.date(), expiration: z.date(), serialNumber: z.string() @@ -45,7 +47,19 @@ export const registerAgentProxyCaRouter = async (server: FastifyZodProvider) => } }, handler: async (req) => { - return server.services.agentProxyCa.signIntermediate(req.permission, req.body.publicKey); + const intermediateCa = await server.services.agentProxyCa.signIntermediate(req.permission, req.body.publicKey); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.SIGN_AGENT_PROXY_INTERMEDIATE_CA, + metadata: { + serialNumber: intermediateCa.serialNumber, + expiration: intermediateCa.expiration.toISOString() + } + } + }); + return intermediateCa; } }); }; diff --git a/backend/src/ee/routes/v1/proxied-service-router.ts b/backend/src/ee/routes/v1/proxied-service-router.ts index 120ed0da868..addb3d8f5bc 100644 --- a/backend/src/ee/routes/v1/proxied-service-router.ts +++ b/backend/src/ee/routes/v1/proxied-service-router.ts @@ -8,6 +8,7 @@ import { SanitizedProxiedServiceBaseSchema } from "@app/ee/services/proxied-service/proxied-service-schemas"; import { BadRequestError } from "@app/lib/errors"; +import { isUuidV4 } from "@app/lib/validator"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -104,7 +105,7 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = return { service }; } // No scope params: this must be a lookup by ID. Reject a bare name so it never hits the uuid column. - if (!z.string().uuid().safeParse(serviceIdOrName).success) { + if (!isUuidV4(serviceIdOrName)) { throw new BadRequestError({ message: "projectId and environment query params are required when fetching a proxied service by name" }); @@ -180,7 +181,7 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = metadata: { proxiedServiceId: service.id, name: service.name, - environment: service.environmentSlug, + environment: service.environment, secretPath: service.secretPath } } diff --git a/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts b/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts index 8c0e97f104d..ae5654f3a9b 100644 --- a/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts +++ b/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts @@ -5,7 +5,7 @@ import { PgSqlLock } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; -import { CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { createSerialNumber, keyAlgorithmToAlgCfg @@ -137,7 +137,8 @@ export const agentProxyCaServiceFactory = ({ const { config, rootCaCert } = await $getOrgRootCa(actor.orgId); return { certificate: rootCaCert.toString("pem"), - keyAlgorithm: config.rootCaKeyAlgorithm, + // the column is a plain string in the generated schema but only ever stores ROOT_CA_ALGORITHM + keyAlgorithm: config.rootCaKeyAlgorithm as CertKeyAlgorithm, issuedAt: config.rootCaIssuedAt, expiration: config.rootCaExpiration, serialNumber: config.rootCaSerialNumber @@ -171,7 +172,7 @@ export const agentProxyCaServiceFactory = ({ [] ); } catch { - throw new BadRequestError({ message: "Invalid intermediate CA public key" }); + throw new BadRequestError({ message: "Invalid public key: must be an ECDSA P-256 public key in PEM format" }); } const rootCaSkObj = crypto.nativeCrypto.createPrivateKey({ @@ -204,11 +205,8 @@ export const agentProxyCaServiceFactory = ({ publicKey: intermediatePublicKey, signingAlgorithm: alg, extensions: [ - new x509.KeyUsagesExtension( - // eslint-disable-next-line no-bitwise - x509.KeyUsageFlags[CertKeyUsage.KEY_CERT_SIGN] | x509.KeyUsageFlags[CertKeyUsage.CRL_SIGN], - true - ), + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), new x509.BasicConstraintsExtension(true, 0, true), await x509.AuthorityKeyIdentifierExtension.create(rootCaCert, false), await x509.SubjectKeyIdentifierExtension.create(intermediatePublicKey) 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 0a0812c60d7..931ba707b22 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -819,11 +819,14 @@ export enum EventType { CREATE_DYNAMIC_SECRET = "create-dynamic-secret", UPDATE_DYNAMIC_SECRET = "update-dynamic-secret", DELETE_DYNAMIC_SECRET = "delete-dynamic-secret", + GET_DYNAMIC_SECRET = "get-dynamic-secret", + LIST_DYNAMIC_SECRETS = "list-dynamic-secrets", + + // Proxied Services CREATE_PROXIED_SERVICE = "create-proxied-service", UPDATE_PROXIED_SERVICE = "update-proxied-service", DELETE_PROXIED_SERVICE = "delete-proxied-service", - GET_DYNAMIC_SECRET = "get-dynamic-secret", - LIST_DYNAMIC_SECRETS = "list-dynamic-secrets", + SIGN_AGENT_PROXY_INTERMEDIATE_CA = "sign-agent-proxy-intermediate-ca", // Dynamic Secret Leases CREATE_DYNAMIC_SECRET_LEASE = "create-dynamic-secret-lease", @@ -6910,6 +6913,28 @@ interface DeleteDynamicSecretEvent { }; } +interface GetDynamicSecretEvent { + type: EventType.GET_DYNAMIC_SECRET; + metadata: { + dynamicSecretName: string; + dynamicSecretId: string; + dynamicSecretType: string; + + environment: string; + secretPath: string; + projectId: string; + }; +} + +interface ListDynamicSecretsEvent { + type: EventType.LIST_DYNAMIC_SECRETS; + metadata: { + environment: string; + secretPath: string; + projectId: string; + }; +} + interface CreateProxiedServiceEvent { type: EventType.CREATE_PROXIED_SERVICE; metadata: { @@ -6946,25 +6971,11 @@ interface DeleteProxiedServiceEvent { }; } -interface GetDynamicSecretEvent { - type: EventType.GET_DYNAMIC_SECRET; - metadata: { - dynamicSecretName: string; - dynamicSecretId: string; - dynamicSecretType: string; - - environment: string; - secretPath: string; - projectId: string; - }; -} - -interface ListDynamicSecretsEvent { - type: EventType.LIST_DYNAMIC_SECRETS; +interface SignAgentProxyIntermediateCaEvent { + type: EventType.SIGN_AGENT_PROXY_INTERMEDIATE_CA; metadata: { - environment: string; - secretPath: string; - projectId: string; + serialNumber: string; + expiration: string; }; } @@ -7996,13 +8007,14 @@ export type Event = | McpServerSyncToolsEvent | McpActivityLogListEvent | CreateDynamicSecretEvent - | CreateProxiedServiceEvent - | UpdateProxiedServiceEvent - | DeleteProxiedServiceEvent | UpdateDynamicSecretEvent | DeleteDynamicSecretEvent | GetDynamicSecretEvent | ListDynamicSecretsEvent + | CreateProxiedServiceEvent + | UpdateProxiedServiceEvent + | DeleteProxiedServiceEvent + | SignAgentProxyIntermediateCaEvent | ListDynamicSecretLeasesEvent | CreateDynamicSecretLeaseEvent | DeleteDynamicSecretLeaseEvent diff --git a/backend/src/ee/services/proxied-service/proxied-service-dal.ts b/backend/src/ee/services/proxied-service/proxied-service-dal.ts index 05c4a1f1be8..04953b9b659 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-dal.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-dal.ts @@ -2,7 +2,9 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; +import { sanitizeSqlLikeString } from "@app/lib/fn"; import { ormify } from "@app/lib/knex"; +import { OrderByDirection } from "@app/lib/types"; export type TProxiedServiceDALFactory = ReturnType; @@ -31,6 +33,58 @@ export type TProxiedServiceScoped = { updatedAt: Date; }; +// Shared shape for a scoped-with-env row selected off the ProxiedService + Environment + Folder join. +const scopedSelectColumns = (db: TDbClient) => [ + db.ref("id").withSchema(TableName.ProxiedService), + db.ref("name").withSchema(TableName.ProxiedService), + db.ref("hostPattern").withSchema(TableName.ProxiedService), + db.ref("isEnabled").withSchema(TableName.ProxiedService), + db.ref("folderId").withSchema(TableName.ProxiedService), + db.ref("createdAt").withSchema(TableName.ProxiedService), + db.ref("updatedAt").withSchema(TableName.ProxiedService), + db.ref("projectId").withSchema(TableName.Environment).as("projectId"), + db.ref("id").withSchema(TableName.Environment).as("envId"), + db.ref("name").withSchema(TableName.Environment).as("envName"), + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + db.ref("name").withSchema(TableName.SecretFolder).as("folderName") +]; + +type TScopedRow = { + id: string; + name: string; + hostPattern: string; + isEnabled: boolean; + folderId: string; + projectId: string; + createdAt: Date; + updatedAt: Date; + envId: string; + envName: string; + envSlug: string; + folderName: string; +}; + +const mapRowToScope = (row: TScopedRow): TProxiedServiceWithScope => ({ + id: row.id, + name: row.name, + hostPattern: row.hostPattern, + isEnabled: row.isEnabled, + folderId: row.folderId, + projectId: row.projectId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + environment: { + id: row.envId, + name: row.envName, + slug: row.envSlug + }, + // NOTE: this is the folder's own name, not its full path. Callers that surface this to clients + // (e.g. the dashboard aggregate) must override it with the canonical secret path. + folder: { + path: row.folderName + } +}); + export const proxiedServiceDALFactory = (db: TDbClient) => { const orm = ormify(db, TableName.ProxiedService); @@ -42,43 +96,65 @@ export const proxiedServiceDALFactory = (db: TDbClient) => { .join(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.ProxiedService}.folderId`) .join(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`) .whereNull(`${TableName.Environment}.deleteAfter`) + .select(scopedSelectColumns(db)); + + return rows.map(mapRowToScope); + }; + + // Multi-env dashboard listing: folder IDs span environments, so the same service name can appear + // once per environment. We DENSE_RANK by name and window on the rank (mirrors dynamic secrets) so + // limit/offset count UNIQUE NAMES and every env-row of a paged-in name stays together -- matching + // countByFolderIds (countDistinct name) and the router's unique-name limit accounting. + const findDashboardByFolderIds = async ( + { + folderIds, + search, + limit, + offset = 0, + orderDirection = OrderByDirection.ASC + }: { + folderIds: string[]; + search?: string; + limit?: number; + offset?: number; + orderDirection?: OrderByDirection; + }, + tx?: Knex + ): Promise => { + if (!folderIds.length) return []; + + const query = (tx || db.replicaNode())(TableName.ProxiedService) + .whereIn(`${TableName.ProxiedService}.folderId`, folderIds) + .where((bd) => { + // same ilike predicate as countByFolderIds so count and list never diverge + if (search) { + void bd.whereILike(`${TableName.ProxiedService}.name`, `%${sanitizeSqlLikeString(search)}%`); + } + }) + .join(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.ProxiedService}.folderId`) + .join(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`) + .whereNull(`${TableName.Environment}.deleteAfter`) .select( - db.ref("id").withSchema(TableName.ProxiedService), - db.ref("name").withSchema(TableName.ProxiedService), - db.ref("hostPattern").withSchema(TableName.ProxiedService), - db.ref("isEnabled").withSchema(TableName.ProxiedService), - db.ref("folderId").withSchema(TableName.ProxiedService), - db.ref("createdAt").withSchema(TableName.ProxiedService), - db.ref("updatedAt").withSchema(TableName.ProxiedService) + ...scopedSelectColumns(db), + // orderDirection is a constrained enum (asc|desc), safe to interpolate + db.raw(`DENSE_RANK() OVER (ORDER BY ${TableName.ProxiedService}."name" ${orderDirection}) as rank`) ) - .select( - db.ref("projectId").withSchema(TableName.Environment).as("projectId"), - db.ref("id").withSchema(TableName.Environment).as("envId"), - db.ref("name").withSchema(TableName.Environment).as("envName"), - db.ref("slug").withSchema(TableName.Environment).as("envSlug"), - db.ref("name").withSchema(TableName.SecretFolder).as("folderName") - ); + .orderBy(`${TableName.ProxiedService}.name`, orderDirection); - return rows.map((row) => ({ - id: row.id, - name: row.name, - hostPattern: row.hostPattern, - isEnabled: row.isEnabled, - folderId: row.folderId, - projectId: row.projectId, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - environment: { - id: row.envId, - name: row.envName, - slug: row.envSlug - }, - // NOTE: this is the folder's own name, not its full path. Callers that surface this to clients - // (e.g. the dashboard aggregate) must override it with the canonical secret path. - folder: { - path: row.folderName - } - })); + let rows: TScopedRow[]; + if (limit) { + const rankOffset = offset + 1; + rows = await (tx || db.replicaNode()) + .with("w", query) + .select("*") + .from("w") + .where("w.rank", ">=", rankOffset) + .andWhere("w.rank", "<", rankOffset + limit); + } else { + rows = await query; + } + + return rows.map(mapRowToScope); }; const countByFolderIds = async (folderIds: string[], search?: string, tx?: Knex) => { @@ -89,7 +165,7 @@ export const proxiedServiceDALFactory = (db: TDbClient) => { ); if (search) { - void query.where(`${TableName.ProxiedService}.name`, "ilike", `%${search}%`); + void query.whereILike(`${TableName.ProxiedService}.name`, `%${sanitizeSqlLikeString(search)}%`); } const [result] = await query.countDistinct<{ count: string | number }>({ @@ -137,6 +213,7 @@ export const proxiedServiceDALFactory = (db: TDbClient) => { return { ...orm, findByFolderIds, + findDashboardByFolderIds, countByFolderIds, findByIdWithScope }; 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 595b74d7145..e09a22faa54 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-service.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-service.ts @@ -10,7 +10,7 @@ import { } from "@app/ee/services/permission/project-permission"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { prefixWithSlash, removeTrailingSlash } from "@app/lib/fn"; -import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; +import { OrgServiceActor } from "@app/lib/types"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal"; @@ -340,7 +340,7 @@ export const proxiedServiceServiceFactory = ({ ? await proxiedServiceDAL.updateById(serviceId, serviceUpdate, tx) : await proxiedServiceDAL.findById(serviceId, tx); - let updatedCredentials = await proxiedServiceCredentialDAL.findByServiceIds([serviceId], tx); + let updatedCredentials; if (credentials) { await proxiedServiceCredentialDAL.deleteByServiceId(serviceId, tx); updatedCredentials = credentials.length @@ -349,6 +349,8 @@ export const proxiedServiceServiceFactory = ({ tx ) : []; + } else { + updatedCredentials = await proxiedServiceCredentialDAL.findByServiceIds([serviceId], tx); } // scope fields are stripped from the API response but let the router emit an audit log @@ -388,7 +390,9 @@ export const proxiedServiceServiceFactory = ({ // credentials cascade on serviceId FK await proxiedServiceDAL.deleteById(serviceId); - return { ...service, secretPath: resolvedSecretPath }; + const { environmentSlug, ...rest } = service; + // scope fields are stripped from the API response but let the router emit an audit log (same shape as updateById) + return { ...rest, environment: environmentSlug, secretPath: resolvedSecretPath }; }; const getDashboardProxiedServiceCount = async ( @@ -431,16 +435,7 @@ export const proxiedServiceServiceFactory = ({ }; const getDashboardProxiedServices = async ( - { - projectId, - environments, - secretPath, - search, - orderBy, - orderDirection, - limit, - offset - }: TProxiedServiceDashboardListDTO, + { projectId, environments, secretPath, search, orderDirection, limit, offset }: TProxiedServiceDashboardListDTO, actor: OrgServiceActor ) => { // soft license gate (see getDashboardProxiedServiceCount) @@ -471,22 +466,16 @@ export const proxiedServiceServiceFactory = ({ ); if (!allowedFolders.length) return []; - let services = await proxiedServiceDAL.findByFolderIds(allowedFolders.map((f) => f.id)); - - if (search) { - services = services.filter((svc) => svc.name.toLowerCase().includes(search.toLowerCase())); - } - - if (orderBy === "name") { - services.sort((a, b) => { - const cmp = a.name.localeCompare(b.name); - return orderDirection === OrderByDirection.DESC ? -cmp : cmp; - }); - } - - if (offset !== undefined && limit !== undefined) { - services = services.slice(offset, offset + limit); - } + // Ranked/searched/paged in the DAL: limit & offset count UNIQUE NAMES across environments (see + // findDashboardByFolderIds), so pagination stays consistent with countByFolderIds and the router's + // unique-name limit accounting -- a service present in multiple envs is never split across pages. + const services = await proxiedServiceDAL.findDashboardByFolderIds({ + folderIds: allowedFolders.map((f) => f.id), + search, + limit, + offset, + orderDirection + }); const credentials = await proxiedServiceCredentialDAL.findByServiceIds(services.map((s) => s.id)); const credentialsByService = credentials.reduce>((acc, cred) => { 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 639e49ce509..4232f2e620f 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-types.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-types.ts @@ -61,6 +61,7 @@ export type TProxiedServiceDashboardListDTO = { environments: string[]; secretPath: string; search?: string; + // accepted for parity with the dashboard router's query param; the DAL always orders by name orderBy?: "name"; orderDirection?: OrderByDirection; limit?: number; diff --git a/frontend/src/components/proxied-services/DeleteProxiedServiceModal.tsx b/frontend/src/components/proxied-services/DeleteProxiedServiceModal.tsx index d11901edab0..6039c178f5e 100644 --- a/frontend/src/components/proxied-services/DeleteProxiedServiceModal.tsx +++ b/frontend/src/components/proxied-services/DeleteProxiedServiceModal.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { Trash2Icon } from "lucide-react"; import { createNotification } from "@app/components/notifications"; @@ -10,7 +11,11 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, - AlertDialogTitle + AlertDialogTitle, + Field, + FieldContent, + FieldLabel, + Input } from "@app/components/v3"; import { useDeleteProxiedService } from "@app/hooks/api/proxiedServices/mutations"; import { TDashboardProxiedService } from "@app/hooks/api/proxiedServices/types"; @@ -19,22 +24,21 @@ type Props = { proxiedService?: TDashboardProxiedService; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; - projectId: string; }; -export const DeleteProxiedServiceModal = ({ - proxiedService, - isOpen, - onOpenChange, - projectId -}: Props) => { +export const DeleteProxiedServiceModal = ({ proxiedService, isOpen, onOpenChange }: Props) => { const deleteProxiedService = useDeleteProxiedService(); + const [inputData, setInputData] = useState(""); + + useEffect(() => { + setInputData(""); + }, [isOpen]); if (!proxiedService) return null; const handleDelete = async () => { try { - await deleteProxiedService.mutateAsync({ serviceId: proxiedService.id, projectId }); + await deleteProxiedService.mutateAsync({ serviceId: proxiedService.id }); createNotification({ text: `Successfully deleted proxied service "${proxiedService.name}"`, type: "success" @@ -60,9 +64,32 @@ export const DeleteProxiedServiceModal = ({ undone. +
{ + e.preventDefault(); + if (inputData === proxiedService.name) handleDelete(); + }} + > + + + Type {proxiedService.name} to confirm + + + setInputData(e.target.value)} + placeholder={`Type ${proxiedService.name} here`} + /> + + +
Cancel - + Delete diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx index 7b57a3020ed..d9d7335060e 100644 --- a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -190,7 +190,6 @@ export const ProxiedServiceForm = ({ if (isEdit && proxiedService) { await updateProxiedService.mutateAsync({ serviceId: proxiedService.id, - projectId, name: form.name, hostPattern: form.hostPattern, isEnabled: form.isEnabled, @@ -237,8 +236,15 @@ export const ProxiedServiceForm = ({ name="isEnabled" render={({ field }) => ( - Enabled - + + Enabled + + )} /> diff --git a/frontend/src/components/proxied-services/forms/schema.ts b/frontend/src/components/proxied-services/forms/schema.ts index dec2264f09c..3359825bda3 100644 --- a/frontend/src/components/proxied-services/forms/schema.ts +++ b/frontend/src/components/proxied-services/forms/schema.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { - ProxiedServiceHeaderPurpose, - ProxiedServiceSubstitutionSurface -} from "@app/hooks/api/proxiedServices/enums"; +import { ProxiedServiceSubstitutionSurface } from "@app/hooks/api/proxiedServices/enums"; const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -58,18 +55,18 @@ const hostPatternField = z // Field-level schemas are permissive; required-ness is enforced conditionally in the // top-level superRefine so that fields belonging to the inactive header mode (which are not // rendered) don't block submission. -export const headerCredentialSchema = z.object({ +const headerCredentialSchema = z.object({ secretKey: z.string().trim(), headerName: z.string().trim(), headerPrefix: z.string().trim().optional() }); -export const basicAuthSchema = z.object({ +const basicAuthSchema = z.object({ usernameSecretKey: z.string().trim(), passwordSecretKey: z.string().trim() }); -export const substitutionSchema = z.object({ +const substitutionSchema = z.object({ placeholderKey: z .string() .trim() @@ -172,5 +169,3 @@ export const proxiedServiceFormSchema = z }); export type TProxiedServiceForm = z.infer; - -export { ProxiedServiceHeaderPurpose }; diff --git a/frontend/src/helpers/roles.ts b/frontend/src/helpers/roles.ts index 56872d04a35..b7700cf6d02 100644 --- a/frontend/src/helpers/roles.ts +++ b/frontend/src/helpers/roles.ts @@ -33,6 +33,10 @@ export const formatProjectRoleName = (role: string, customRoleName?: string) => return "SSH Host Bootstrapper"; case ProjectMembershipRole.KmsCryptographicOperator: return "Cryptographic Operator"; + case ProjectMembershipRole.Agent: + return "Agent"; + case ProjectMembershipRole.AgentProxy: + return "Agent Proxy"; default: return role; } diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 34bf6396813..f713c756ba6 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -403,11 +403,12 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.CREATE_DYNAMIC_SECRET]: "Create Dynamic Secret", [EventType.UPDATE_DYNAMIC_SECRET]: "Update Dynamic Secret", [EventType.DELETE_DYNAMIC_SECRET]: "Delete Dynamic Secret", + [EventType.GET_DYNAMIC_SECRET]: "Get Dynamic Secret", + [EventType.LIST_DYNAMIC_SECRETS]: "List Dynamic Secrets", [EventType.CREATE_PROXIED_SERVICE]: "Create Proxied Service", [EventType.UPDATE_PROXIED_SERVICE]: "Update Proxied Service", [EventType.DELETE_PROXIED_SERVICE]: "Delete Proxied Service", - [EventType.GET_DYNAMIC_SECRET]: "Get Dynamic Secret", - [EventType.LIST_DYNAMIC_SECRETS]: "List Dynamic Secrets", + [EventType.SIGN_AGENT_PROXY_INTERMEDIATE_CA]: "Sign Agent Proxy Intermediate CA", [EventType.CREATE_DYNAMIC_SECRET_LEASE]: "Create Dynamic Secret Lease", [EventType.DELETE_DYNAMIC_SECRET_LEASE]: "Delete Dynamic Secret Lease", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 707d28a0fa1..cbf730a9e2a 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -395,11 +395,14 @@ export enum EventType { CREATE_DYNAMIC_SECRET = "create-dynamic-secret", UPDATE_DYNAMIC_SECRET = "update-dynamic-secret", DELETE_DYNAMIC_SECRET = "delete-dynamic-secret", + GET_DYNAMIC_SECRET = "get-dynamic-secret", + LIST_DYNAMIC_SECRETS = "list-dynamic-secrets", + + // Proxied Services CREATE_PROXIED_SERVICE = "create-proxied-service", UPDATE_PROXIED_SERVICE = "update-proxied-service", DELETE_PROXIED_SERVICE = "delete-proxied-service", - GET_DYNAMIC_SECRET = "get-dynamic-secret", - LIST_DYNAMIC_SECRETS = "list-dynamic-secrets", + SIGN_AGENT_PROXY_INTERMEDIATE_CA = "sign-agent-proxy-intermediate-ca", // Dynamic Secret Leases CREATE_DYNAMIC_SECRET_LEASE = "create-dynamic-secret-lease", diff --git a/frontend/src/hooks/api/proxiedServices/mutations.tsx b/frontend/src/hooks/api/proxiedServices/mutations.tsx index 3dffe6741c7..2d6376569ad 100644 --- a/frontend/src/hooks/api/proxiedServices/mutations.tsx +++ b/frontend/src/hooks/api/proxiedServices/mutations.tsx @@ -8,6 +8,7 @@ import { TCreateProxiedServiceDTO, TDeleteProxiedServiceDTO, TProxiedService, + TProxiedServiceBase, TUpdateProxiedServiceDTO } from "./types"; @@ -35,8 +36,7 @@ export const useUpdateProxiedService = () => { const queryClient = useQueryClient(); return useMutation({ - // projectId is only used for cache scoping, not sent in the request body - mutationFn: async ({ serviceId, projectId: _projectId, ...body }) => { + mutationFn: async ({ serviceId, ...body }) => { const { data } = await apiRequest.patch<{ service: TProxiedService }>( `/api/v1/proxied-services/${serviceId}`, body @@ -50,9 +50,9 @@ export const useUpdateProxiedService = () => { export const useDeleteProxiedService = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: async ({ serviceId }) => { - const { data } = await apiRequest.delete<{ service: TProxiedService }>( + const { data } = await apiRequest.delete<{ service: TProxiedServiceBase }>( `/api/v1/proxied-services/${serviceId}` ); return data.service; diff --git a/frontend/src/hooks/api/proxiedServices/queries.tsx b/frontend/src/hooks/api/proxiedServices/queries.tsx index 41def7814f4..fbb5a709619 100644 --- a/frontend/src/hooks/api/proxiedServices/queries.tsx +++ b/frontend/src/hooks/api/proxiedServices/queries.tsx @@ -1,48 +1,3 @@ -import { useQuery } from "@tanstack/react-query"; - -import { apiRequest } from "@app/config/request"; - -import { TListProxiedServicesDTO, TProxiedService } from "./types"; - export const proxiedServiceKeys = { - all: ["proxiedServices"] as const, - list: ({ projectId, environment, secretPath }: TListProxiedServicesDTO) => - [...proxiedServiceKeys.all, "list", projectId, environment, secretPath] as const, - byId: (serviceId: string) => [...proxiedServiceKeys.all, "byId", serviceId] as const + all: ["proxiedServices"] as const }; - -export const useGetProxiedServices = ({ - projectId, - environment, - secretPath, - enabled = true -}: TListProxiedServicesDTO & { enabled?: boolean }) => - useQuery({ - queryKey: proxiedServiceKeys.list({ projectId, environment, secretPath }), - queryFn: async () => { - const { data } = await apiRequest.get<{ services: TProxiedService[] }>( - "/api/v1/proxied-services", - { params: { projectId, environment, secretPath } } - ); - return data.services; - }, - enabled - }); - -export const useGetProxiedServiceById = ({ - serviceId, - enabled = true -}: { - serviceId: string; - enabled?: boolean; -}) => - useQuery({ - queryKey: proxiedServiceKeys.byId(serviceId), - queryFn: async () => { - const { data } = await apiRequest.get<{ service: TProxiedService }>( - `/api/v1/proxied-services/${serviceId}` - ); - return data.service; - }, - enabled - }); diff --git a/frontend/src/hooks/api/proxiedServices/types.ts b/frontend/src/hooks/api/proxiedServices/types.ts index 6c5106a0481..8437b768e6f 100644 --- a/frontend/src/hooks/api/proxiedServices/types.ts +++ b/frontend/src/hooks/api/proxiedServices/types.ts @@ -17,7 +17,8 @@ export type TProxiedServiceCredential = { substitutionSurfaces?: ProxiedServiceSubstitutionSurface[] | null; }; -export type TProxiedService = { +// Shape returned by the DELETE endpoint (base schema, no credentials). +export type TProxiedServiceBase = { id: string; name: string; hostPattern: string; @@ -25,7 +26,9 @@ export type TProxiedService = { folderId: string; createdAt: string; updatedAt: string; - canProxy?: boolean; +}; + +export type TProxiedService = TProxiedServiceBase & { credentials: TProxiedServiceCredential[]; }; @@ -64,7 +67,6 @@ export type TCreateProxiedServiceDTO = { export type TUpdateProxiedServiceDTO = { serviceId: string; - projectId: string; name?: string; hostPattern?: string; isEnabled?: boolean; @@ -73,11 +75,4 @@ export type TUpdateProxiedServiceDTO = { export type TDeleteProxiedServiceDTO = { serviceId: string; - projectId: string; -}; - -export type TListProxiedServicesDTO = { - projectId: string; - environment: string; - secretPath: string; }; diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 2256e99a678..ba59f631bc0 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -3692,7 +3692,6 @@ const OverviewPageContent = () => { isOpen={popUp.deleteProxiedService.isOpen} onOpenChange={(isOpen) => handlePopUpToggle("deleteProxiedService", isOpen)} proxiedService={popUp.deleteProxiedService.data as TDashboardProxiedService} - projectId={projectId} />
{name} - {hostPattern} + + + {hostPattern} + + {!isEnabled && Disabled}
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceListView.tsx index 793baf55d29..d668be43d75 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceListView.tsx @@ -43,7 +43,6 @@ export const ProxiedServiceListView = ({ proxiedServices }: Props) => { if (!isOpen) setDeleteTarget(undefined); }} proxiedService={deleteTarget} - projectId={projectId} /> ); From 1cb1979df801ea108fd13e014dded023be782854 Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 16:28:58 +0530 Subject: [PATCH 13/43] chore: use realistic timestamp for proxied-services migration --- ...xied-services.ts => 20260710162607_create-proxied-services.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/src/db/migrations/{20260709120000_create-proxied-services.ts => 20260710162607_create-proxied-services.ts} (100%) diff --git a/backend/src/db/migrations/20260709120000_create-proxied-services.ts b/backend/src/db/migrations/20260710162607_create-proxied-services.ts similarity index 100% rename from backend/src/db/migrations/20260709120000_create-proxied-services.ts rename to backend/src/db/migrations/20260710162607_create-proxied-services.ts From 0b544b40f701ea9302033327749f8f2cc998bff5 Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 17:23:45 +0530 Subject: [PATCH 14/43] docs: note why CredentialSubstitution enum value looks unused --- backend/src/ee/services/proxied-service/proxied-service-enums.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/ee/services/proxied-service/proxied-service-enums.ts b/backend/src/ee/services/proxied-service/proxied-service-enums.ts index 4ef88988224..f890202c80f 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-enums.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-enums.ts @@ -1,5 +1,6 @@ export enum ProxiedServiceCredentialRole { HeaderRewrite = "header-rewrite", + // referenced via z.nativeEnum, not by name; do not delete CredentialSubstitution = "credential-substitution" } From 3a2e89cf8a425dc2d0205958abb1fa4bd7a87922 Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 17:50:34 +0530 Subject: [PATCH 15/43] fix: bound proxied service credential field lengths to match db columns --- .../proxied-service/proxied-service-schemas.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 7f1196b9a74..dc0ab6e4218 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-schemas.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-schemas.ts @@ -55,13 +55,13 @@ export const hostPatternSchema = z const CredentialInputSchema = z .object({ - secretKey: z.string().trim().min(1), + secretKey: z.string().trim().min(1).max(255), role: z.nativeEnum(ProxiedServiceCredentialRole), - headerName: z.string().trim().min(1).optional(), - headerPrefix: z.string().trim().optional(), + headerName: z.string().trim().min(1).max(255).optional(), + headerPrefix: z.string().trim().max(255).optional(), headerPurpose: z.nativeEnum(ProxiedServiceHeaderPurpose).optional(), - placeholderKey: z.string().trim().min(1).optional(), - placeholderValue: z.string().trim().min(1).optional(), + placeholderKey: z.string().trim().min(1).max(255).optional(), + placeholderValue: z.string().trim().min(1).max(255).optional(), substitutionSurfaces: z.array(z.nativeEnum(ProxiedServiceSubstitutionSurface)).nonempty().optional() }) .superRefine((cred, ctx) => { From 283516f20ad4f09508090a39d0a02e0fcc8e5756 Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 18:23:46 +0530 Subject: [PATCH 16/43] docs: note intermediate CA TTL meaning --- backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts b/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts index ae5654f3a9b..11300627cf5 100644 --- a/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts +++ b/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts @@ -27,6 +27,7 @@ type TAgentProxyCaServiceFactoryDep = { }; const ROOT_CA_ALGORITHM = CertKeyAlgorithm.ECDSA_P256; +// 7 days; the agent proxy re-signs a fresh intermediate before this elapses const INTERMEDIATE_CA_TTL_MS = 7 * 24 * 60 * 60 * 1000; // Long-lived root so the trust anchor stays stable (agents pin it and do not refresh in V1). // The private key never leaves Infisical and only signs short-lived intermediates. From 4eccf85abf86893acd92cccdfb078860955c5006 Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 19:58:53 +0530 Subject: [PATCH 17/43] feat: only allow attaching readable secrets in proxied service picker --- .../proxied-services/forms/SecretSelect.tsx | 55 +++++++++++++++---- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/proxied-services/forms/SecretSelect.tsx b/frontend/src/components/proxied-services/forms/SecretSelect.tsx index 1d3f5233c07..c91922a74a5 100644 --- a/frontend/src/components/proxied-services/forms/SecretSelect.tsx +++ b/frontend/src/components/proxied-services/forms/SecretSelect.tsx @@ -1,7 +1,10 @@ -import { KeyIcon } from "lucide-react"; +import { KeyIcon, LockIcon } from "lucide-react"; -import { FilterableSelect } from "@app/components/v3"; +import { FilterableSelect, Tooltip, TooltipContent, TooltipTrigger } from "@app/components/v3"; +import { useProjectPermission } from "@app/context"; +import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { useGetProjectSecrets } from "@app/hooks/api/secrets/queries"; +import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; type Props = { projectId: string; @@ -14,15 +17,30 @@ type Props = { placeholder?: string; }; -type SecretOption = { label: string; value: string }; +// isReadable drives whether the option can be picked: attaching a secret to a proxied service +// requires ReadValue on the backend, so a secret the caller can only describe is shown disabled. +type SecretOption = { label: string; value: string; isReadable: boolean }; // Hoisted out of the component so it isn't recreated each render (react/no-unstable-nested-components). -const formatSecretOption = (option: SecretOption) => ( -
- - {option.label} -
-); +const formatSecretOption = (option: SecretOption) => + option.isReadable ? ( +
+ + {option.label} +
+ ) : ( + + +
+ + {option.label} +
+
+ + You need read access to this secret's value to use it in a proxied service. + +
+ ); // Picks a secret key from the current folder. Uses the searchable FilterableSelect so long secret // lists stay usable. A stale reference (secret renamed/deleted) still renders its raw key. @@ -36,6 +54,7 @@ export const SecretSelect = ({ isError, placeholder = "Select a secret" }: Props) => { + const { permission } = useProjectPermission(); const { data: secrets = [], isPending } = useGetProjectSecrets({ projectId, environment, @@ -45,10 +64,23 @@ export const SecretSelect = ({ const options: SecretOption[] = secrets.map((secret) => ({ label: secret.key, - value: secret.key + value: secret.key, + // the list endpoint only requires describe, so re-check ReadValue client-side (backend enforces it too) + isReadable: hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment, + secretPath, + secretName: secret.key, + secretTags: secret.tags?.map((tag) => tag.slug) ?? [] + } + ) })); + // 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 } : null); + 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} From e06632ec50f9c1520c31cdef69f991668656b526 Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 21:18:35 +0530 Subject: [PATCH 18/43] refactor: gate proxied service dashboard includes like sibling resources --- .../secret-manager/OverviewPage/OverviewPage.tsx | 6 +++--- .../SecretDashboardPage/SecretDashboardPage.tsx | 14 +++++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index ba59f631bc0..b7aafd6cc5b 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -671,9 +671,9 @@ const OverviewPageContent = () => { includeImports: isFilteredByResources ? (filter[RowType.SecretImport] ?? true) : true, includeSecretRotations: isFilteredByResources ? filter.rotation : true, includeHoneyTokens: isFilteredByResources ? (filter[RowType.HoneyToken] ?? true) : true, - includeProxiedServices: - Boolean(subscription?.secretsBrokering) && - (isFilteredByResources ? (filter[RowType.ProxiedService] ?? true) : true), + includeProxiedServices: isFilteredByResources + ? (filter[RowType.ProxiedService] ?? true) + : true, search: searchFilter, tags: tagFilter, limit, diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index 78a923e19aa..37814d02625 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -32,11 +32,11 @@ import { ProjectPermissionSub, useOrganization, useProject, - useProjectPermission, - useSubscription + useProjectPermission } from "@app/context"; import { ProjectPermissionCommitsActions, + ProjectPermissionProxiedServiceActions, ProjectPermissionSecretActions, ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types"; @@ -119,7 +119,6 @@ const Page = () => { }); const { permission } = useProjectPermission(); - const { subscription } = useSubscription(); const { mutateAsync: createCommit, isPending: isCommitPending } = useCreateCommit(); const tableRef = useRef(null); @@ -240,6 +239,11 @@ const Page = () => { subject(ProjectPermissionSub.SecretRotation, { environment, secretPath }) ); + const canReadProxiedServices = permission.can( + ProjectPermissionProxiedServiceActions.Read, + subject(ProjectPermissionSub.ProxiedServices, { environment, secretPath }) + ); + const canDoReadRollback = permission.can( ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback @@ -330,7 +334,7 @@ const Page = () => { includeSecretRotations: canReadSecretRotations && (isResourceTypeFiltered ? filter.include.rotation : true), includeHoneyTokens: true, - includeProxiedServices: Boolean(subscription?.secretsBrokering), + includeProxiedServices: canReadProxiedServices, tags: filter.tags }); @@ -1081,7 +1085,7 @@ const Page = () => { canNavigate={isFetched} /> )} - {Boolean(proxiedServices?.length) && ( + {canReadProxiedServices && Boolean(proxiedServices?.length) && ( )} {canReadDynamicSecret && Boolean(dynamicSecrets?.length) && ( From eb8411a784ab615295c9ecf73673d10c67de6e35 Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 10 Jul 2026 22:36:21 +0530 Subject: [PATCH 19/43] chore: drop proxied services from the legacy single-env secret page --- .../SecretDashboardPage.tsx | 15 +- .../components/ActionBar/ActionBar.tsx | 43 +----- .../ProxiedServiceItem.tsx | 137 ------------------ .../ProxiedServiceListView.tsx | 49 ------- .../ProxiedServiceListView/index.tsx | 1 - 5 files changed, 2 insertions(+), 243 deletions(-) delete mode 100644 frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceItem.tsx delete mode 100644 frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceListView.tsx delete mode 100644 frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/index.tsx diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index 37814d02625..93f3edd88d4 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -36,7 +36,6 @@ import { } from "@app/context"; import { ProjectPermissionCommitsActions, - ProjectPermissionProxiedServiceActions, ProjectPermissionSecretActions, ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types"; @@ -68,7 +67,6 @@ import { useResizableColWidth } from "@app/hooks/useResizableColWidth"; import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { RequestAccessModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal"; import { HoneyTokenListView } from "@app/pages/secret-manager/SecretDashboardPage/components/HoneyTokenListView"; -import { ProxiedServiceListView } from "@app/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView"; import { SecretRotationListView } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView"; import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount"; @@ -239,11 +237,6 @@ const Page = () => { subject(ProjectPermissionSub.SecretRotation, { environment, secretPath }) ); - const canReadProxiedServices = permission.can( - ProjectPermissionProxiedServiceActions.Read, - subject(ProjectPermissionSub.ProxiedServices, { environment, secretPath }) - ); - const canDoReadRollback = permission.can( ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback @@ -334,7 +327,6 @@ const Page = () => { includeSecretRotations: canReadSecretRotations && (isResourceTypeFiltered ? filter.include.rotation : true), includeHoneyTokens: true, - includeProxiedServices: canReadProxiedServices, tags: filter.tags }); @@ -356,7 +348,6 @@ const Page = () => { dynamicSecrets, secretRotations, honeyTokens, - proxiedServices, secrets, totalImportCount = 0, totalFolderCount = 0, @@ -513,8 +504,7 @@ const Page = () => { (secrets?.length || 0) - (dynamicSecrets?.length || 0) - (secretRotations?.length || 0) - - (honeyTokens?.length || 0) - - (proxiedServices?.length || 0), + (honeyTokens?.length || 0), 0 ); const isNotEmpty = Boolean( @@ -1085,9 +1075,6 @@ const Page = () => { canNavigate={isFetched} /> )} - {canReadProxiedServices && Boolean(proxiedServices?.length) && ( - - )} {canReadDynamicSecret && Boolean(dynamicSecrets?.length) && ( )} - - {(isAllowed) => ( - - )} - handlePopUpToggle("addSecretRotation", isOpen)} /> - handlePopUpToggle("addProxiedService", isOpen)} - projectId={currentProject.id} - environment={environment} - secretPath={secretPath} - /> handlePopUpToggle("addFolder", isOpen)} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceItem.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceItem.tsx deleted file mode 100644 index 137611e1bc3..00000000000 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceItem.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { useState } from "react"; -import { subject } from "@casl/ability"; -import { AnimatePresence, motion } from "framer-motion"; -import { ArrowRightLeftIcon, PencilIcon, Trash2Icon } from "lucide-react"; - -import { ProjectPermissionCan } from "@app/components/permissions"; -import { Badge, IconButton } from "@app/components/v3"; -import { ProjectPermissionSub } from "@app/context"; -import { ProjectPermissionProxiedServiceActions } from "@app/context/ProjectPermissionContext/types"; -import { ProxiedServiceCredentialRole } from "@app/hooks/api/proxiedServices/enums"; -import { TDashboardProxiedService } from "@app/hooks/api/proxiedServices/types"; - -type Props = { - proxiedService: TDashboardProxiedService; - onEdit: () => void; - onDelete: () => void; -}; - -export const ProxiedServiceItem = ({ proxiedService, onEdit, onDelete }: Props) => { - const { name, hostPattern, isEnabled, credentials, environment, folder } = proxiedService; - const [isExpanded, setIsExpanded] = useState(false); - - const permissionSubject = subject(ProjectPermissionSub.ProxiedServices, { - environment: environment.slug, - secretPath: folder.path - }); - - return ( - <> -
setIsExpanded(!isExpanded)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setIsExpanded(!isExpanded); - } - }} - role="button" - tabIndex={0} - aria-expanded={isExpanded} - aria-label={`${isExpanded ? "Collapse" : "Expand"} proxied service ${name}`} - > -
- -
-
-
- {name} - - - {hostPattern} - - - {!isEnabled && Disabled} -
-
- - - - {(isAllowed) => ( - { - e.stopPropagation(); - onEdit(); - }} - > - - - )} - - - {(isAllowed) => ( - { - e.stopPropagation(); - onDelete(); - }} - > - - - )} - - - -
- {isExpanded && ( -
- {credentials.length === 0 && ( -
- No credentials configured -
- )} - {credentials.map((cred) => ( -
- {cred.secretKey} - - {cred.role === ProxiedServiceCredentialRole.HeaderRewrite - ? cred.headerName || "Basic Auth" - : "Substitution"} - -
- ))} -
- )} - - ); -}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceListView.tsx deleted file mode 100644 index d668be43d75..00000000000 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/ProxiedServiceListView.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useState } from "react"; - -import { - DeleteProxiedServiceModal, - EditProxiedServiceModal -} from "@app/components/proxied-services"; -import { useProject } from "@app/context"; -import { TDashboardProxiedService } from "@app/hooks/api/proxiedServices/types"; - -import { ProxiedServiceItem } from "./ProxiedServiceItem"; - -type Props = { - proxiedServices?: TDashboardProxiedService[]; -}; - -export const ProxiedServiceListView = ({ proxiedServices }: Props) => { - const { projectId } = useProject(); - - const [editTarget, setEditTarget] = useState(); - const [deleteTarget, setDeleteTarget] = useState(); - - return ( - <> - {proxiedServices?.map((proxiedService) => ( - setEditTarget(proxiedService)} - onDelete={() => setDeleteTarget(proxiedService)} - /> - ))} - { - if (!isOpen) setEditTarget(undefined); - }} - proxiedService={editTarget} - projectId={projectId} - /> - { - if (!isOpen) setDeleteTarget(undefined); - }} - proxiedService={deleteTarget} - /> - - ); -}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/index.tsx deleted file mode 100644 index 99c2e4eb728..00000000000 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ProxiedServiceListView/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ProxiedServiceListView } from "./ProxiedServiceListView"; From d9c3f175ad1eb05b0b40993e768bc54aed71c292 Mon Sep 17 00:00:00 2001 From: Saif Date: Sat, 11 Jul 2026 04:47:28 +0530 Subject: [PATCH 20/43] chore: trim non-essential comments from secrets brokering code --- .../20260710162607_create-proxied-services.ts | 11 +++------ .../ee/routes/v1/proxied-service-router.ts | 2 -- .../agent-proxy-ca/agent-proxy-ca-service.ts | 14 ++--------- .../ee/services/permission/default-roles.ts | 4 ---- .../proxied-service/proxied-service-dal.ts | 11 +-------- .../proxied-service-schemas.ts | 14 ++--------- .../proxied-service-service.ts | 23 +------------------ backend/src/server/routes/sanitizedSchemas.ts | 1 - .../forms/ProxiedServiceForm.tsx | 4 ---- .../proxied-services/forms/SecretSelect.tsx | 7 +----- .../proxied-services/forms/SurfaceSelect.tsx | 2 -- .../proxied-services/forms/schema.ts | 7 +----- .../src/hooks/api/proxiedServices/types.ts | 2 -- 13 files changed, 11 insertions(+), 91 deletions(-) diff --git a/backend/src/db/migrations/20260710162607_create-proxied-services.ts b/backend/src/db/migrations/20260710162607_create-proxied-services.ts index 3d9c5d27f5a..3260bf33871 100644 --- a/backend/src/db/migrations/20260710162607_create-proxied-services.ts +++ b/backend/src/db/migrations/20260710162607_create-proxied-services.ts @@ -36,7 +36,6 @@ export async function up(knex: Knex): Promise { t.uuid("folderId").notNullable(); t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE"); - // project is derived via folder -> environment -> project chain; no projectId column t.unique(["folderId", "name"]); t.timestamps(true, true, true); @@ -53,20 +52,16 @@ export async function up(knex: Knex): Promise { t.foreign("serviceId").references("id").inTable(TableName.ProxiedService).onDelete("CASCADE"); t.index("serviceId"); - // Reference to a secret by key name only. Location is implicitly the parent - // service's folder in V1, so no folder/env/path columns are stored here. t.string("secretKey").notNullable(); - t.string("role").notNullable(); // "header-rewrite" | "credential-substitution" + t.string("role").notNullable(); - // Header rewriting fields (nullable) t.string("headerName"); t.string("headerPrefix"); - t.string("headerPurpose"); // basic auth: "username" | "password" + t.string("headerPurpose"); - // Credential substitution fields (nullable) t.string("placeholderKey"); t.string("placeholderValue"); - t.specificType("substitutionSurfaces", "text[]"); // subset of header|path|query|body + t.specificType("substitutionSurfaces", "text[]"); t.timestamps(true, true, true); }); diff --git a/backend/src/ee/routes/v1/proxied-service-router.ts b/backend/src/ee/routes/v1/proxied-service-router.ts index addb3d8f5bc..00defeef117 100644 --- a/backend/src/ee/routes/v1/proxied-service-router.ts +++ b/backend/src/ee/routes/v1/proxied-service-router.ts @@ -79,7 +79,6 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = } }); - // Distinguishes slug vs id by the presence of scope query params (projectId + environment). server.route({ method: "GET", url: "/:serviceIdOrName", @@ -104,7 +103,6 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = ); return { service }; } - // No scope params: this must be a lookup by ID. Reject a bare name so it never hits the uuid column. if (!isUuidV4(serviceIdOrName)) { throw new BadRequestError({ message: "projectId and environment query params are required when fetching a proxied service by name" diff --git a/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts b/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts index 11300627cf5..61c5bc25c83 100644 --- a/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts +++ b/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts @@ -27,12 +27,8 @@ type TAgentProxyCaServiceFactoryDep = { }; const ROOT_CA_ALGORITHM = CertKeyAlgorithm.ECDSA_P256; -// 7 days; the agent proxy re-signs a fresh intermediate before this elapses const INTERMEDIATE_CA_TTL_MS = 7 * 24 * 60 * 60 * 1000; -// Long-lived root so the trust anchor stays stable (agents pin it and do not refresh in V1). -// The private key never leaves Infisical and only signs short-lived intermediates. const ROOT_CA_VALIDITY_YEARS = 10; -// Backdate notBefore so a fresh cert is not rejected by an agent whose clock trails the server's. const CLOCK_SKEW_MS = 5 * 60 * 1000; export const agentProxyCaServiceFactory = ({ @@ -50,7 +46,6 @@ export const agentProxyCaServiceFactory = ({ } }; - // Validates the actor still belongs to the org. Any org member may call the CA endpoints (no dedicated subject). const $assertOrgMembership = async (actor: OrgServiceActor) => { await permissionService.getOrgPermission({ actor: actor.type, @@ -62,7 +57,6 @@ export const agentProxyCaServiceFactory = ({ }); }; - // Lazily generates (once per org) and returns the org's root CA, decrypted for use. const $getOrgRootCa = async (orgId: string) => { const { encryptor: orgKmsEncryptor, decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, @@ -74,7 +68,6 @@ export const agentProxyCaServiceFactory = ({ if (existing) return existing; await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.OrgAgentProxyConfigInit(orgId)]); - // re-check after acquiring the lock in case another instance created it const afterLock = await orgAgentProxyConfigDAL.findOne({ orgId }, tx); if (afterLock) return afterLock; @@ -130,7 +123,6 @@ export const agentProxyCaServiceFactory = ({ return { config, rootCaCert, rootCaPrivateKeyBuffer }; }; - // Returns the org's public root CA certificate (auto-creating it on first access). const getRootCa = async (actor: OrgServiceActor) => { await $checkLicense(actor.orgId); await $assertOrgMembership(actor); @@ -138,7 +130,7 @@ export const agentProxyCaServiceFactory = ({ const { config, rootCaCert } = await $getOrgRootCa(actor.orgId); return { certificate: rootCaCert.toString("pem"), - // the column is a plain string in the generated schema but only ever stores ROOT_CA_ALGORITHM + // column is a plain string in the generated schema but only ever stores ROOT_CA_ALGORITHM keyAlgorithm: config.rootCaKeyAlgorithm as CertKeyAlgorithm, issuedAt: config.rootCaIssuedAt, expiration: config.rootCaExpiration, @@ -146,14 +138,12 @@ export const agentProxyCaServiceFactory = ({ }; }; - // Signs a caller-provided public key as a short-lived intermediate CA chained to the org root CA. const signIntermediate = async (actor: OrgServiceActor, publicKeyPem: string) => { await $checkLicense(actor.orgId); await $assertOrgMembership(actor); const { rootCaCert, rootCaPrivateKeyBuffer } = await $getOrgRootCa(actor.orgId); - // Refuse to sign against an expired root: the resulting chain could never validate. if (new Date() >= rootCaCert.notAfter) { throw new BadRequestError({ message: "The organization's agent proxy root CA has expired and can no longer sign intermediate certificates." @@ -192,7 +182,7 @@ export const agentProxyCaServiceFactory = ({ const serialNumber = createSerialNumber(); const issuedAt = new Date(); const notBefore = new Date(issuedAt.getTime() - CLOCK_SKEW_MS); - // Clamp so an intermediate can never outlive the root it chains to. + // clamp so an intermediate can never outlive the root it chains to const requestedExpiration = new Date(issuedAt.getTime() + INTERMEDIATE_CA_TTL_MS); const expiration = requestedExpiration < rootCaCert.notAfter ? requestedExpiration : rootCaCert.notAfter; diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 79914beae6a..90cf31fc4d3 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -813,8 +813,6 @@ const buildCryptographicOperatorPermissionRules = () => { return rules; }; -// Grants Proxy on all proxied services (all environments, all paths). No secret read access. -// Intended for the agent machine identity. const buildAgentPermissionRules = () => { const { can, rules } = new AbilityBuilder>(createMongoAbility); @@ -823,8 +821,6 @@ const buildAgentPermissionRules = () => { return rules; }; -// Grants read access to all secret values (all environments, all paths). No proxied service permissions. -// Intended for the agent proxy machine identity, which fetches real credentials for header rewriting/substitution. const buildAgentProxyPermissionRules = () => { const { can, rules } = new AbilityBuilder>(createMongoAbility); diff --git a/backend/src/ee/services/proxied-service/proxied-service-dal.ts b/backend/src/ee/services/proxied-service/proxied-service-dal.ts index 04953b9b659..5def0600ac5 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-dal.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-dal.ts @@ -33,7 +33,6 @@ export type TProxiedServiceScoped = { updatedAt: Date; }; -// Shared shape for a scoped-with-env row selected off the ProxiedService + Environment + Folder join. const scopedSelectColumns = (db: TDbClient) => [ db.ref("id").withSchema(TableName.ProxiedService), db.ref("name").withSchema(TableName.ProxiedService), @@ -78,8 +77,7 @@ const mapRowToScope = (row: TScopedRow): TProxiedServiceWithScope => ({ name: row.envName, slug: row.envSlug }, - // NOTE: this is the folder's own name, not its full path. Callers that surface this to clients - // (e.g. the dashboard aggregate) must override it with the canonical secret path. + // folder's own name, not its full path; callers surfacing this must override with the canonical secret path folder: { path: row.folderName } @@ -88,7 +86,6 @@ const mapRowToScope = (row: TScopedRow): TProxiedServiceWithScope => ({ export const proxiedServiceDALFactory = (db: TDbClient) => { const orm = ormify(db, TableName.ProxiedService); - // proxied_services has no projectId column; it is derived via folder -> environment -> project. const findByFolderIds = async (folderIds: string[], tx?: Knex): Promise => { if (!folderIds.length) return []; const rows = await (tx || db.replicaNode())(TableName.ProxiedService) @@ -101,10 +98,6 @@ export const proxiedServiceDALFactory = (db: TDbClient) => { return rows.map(mapRowToScope); }; - // Multi-env dashboard listing: folder IDs span environments, so the same service name can appear - // once per environment. We DENSE_RANK by name and window on the rank (mirrors dynamic secrets) so - // limit/offset count UNIQUE NAMES and every env-row of a paged-in name stays together -- matching - // countByFolderIds (countDistinct name) and the router's unique-name limit accounting. const findDashboardByFolderIds = async ( { folderIds, @@ -126,7 +119,6 @@ export const proxiedServiceDALFactory = (db: TDbClient) => { const query = (tx || db.replicaNode())(TableName.ProxiedService) .whereIn(`${TableName.ProxiedService}.folderId`, folderIds) .where((bd) => { - // same ilike predicate as countByFolderIds so count and list never diverge if (search) { void bd.whereILike(`${TableName.ProxiedService}.name`, `%${sanitizeSqlLikeString(search)}%`); } @@ -174,7 +166,6 @@ export const proxiedServiceDALFactory = (db: TDbClient) => { return Number(result?.count ?? 0); }; - // Loads a service by id together with its derived project id and environment slug for scoped permission checks. const findByIdWithScope = async (serviceId: string, tx?: Knex): Promise => { const row = await (tx || db.replicaNode())(TableName.ProxiedService) .where(`${TableName.ProxiedService}.id`, serviceId) 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 dc0ab6e4218..01dd39d8121 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-schemas.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-schemas.ts @@ -9,14 +9,10 @@ import { ProxiedServiceSubstitutionSurface } from "./proxied-service-enums"; -// One host label: alphanumerics and internal hyphens, optionally a single leading "*." wildcard. -// RE2 (per codebase convention) for linear-time matching on user input. const HOST_LABELS_RE = new RE2(/^(?:\*\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/i); const PORT_RE = new RE2(/^\d+$/); -// Validates the comma-separated hostPattern grammar so malformed values fail at creation -// instead of silently never matching at proxy time. The matching grammar itself lives in the -// agent-proxy CLI (packages/agentproxy/match.go); keep the two in sync. +// matching grammar lives in the agent-proxy CLI (packages/agentproxy/match.go); keep the two in sync export const hostPatternSchema = z .string() .trim() @@ -35,7 +31,7 @@ export const hostPatternSchema = z } let hostPort = seg; const slashIdx = hostPort.indexOf("/"); - if (slashIdx !== -1) hostPort = hostPort.slice(0, slashIdx); // path portion is free-form + if (slashIdx !== -1) hostPort = hostPort.slice(0, slashIdx); let host = hostPort; const colonIdx = hostPort.lastIndexOf(":"); if (colonIdx !== -1) { @@ -66,7 +62,6 @@ const CredentialInputSchema = z }) .superRefine((cred, ctx) => { if (cred.role === ProxiedServiceCredentialRole.HeaderRewrite) { - // either a named header (optionally with a prefix) or a basic-auth purpose, not both if (cred.headerPurpose) { if (cred.headerName || cred.headerPrefix) { ctx.addIssue({ @@ -81,7 +76,6 @@ const CredentialInputSchema = z }); } } else if (!cred.placeholderKey || !cred.placeholderValue || !cred.substitutionSurfaces) { - // credential substitution ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Credential substitution requires placeholderKey, placeholderValue, and substitutionSurfaces" @@ -89,8 +83,6 @@ const CredentialInputSchema = z } }); -// Cross-credential rules that cannot be checked one row at a time: basic-auth pairing, -// unique header names / placeholders, and header-rewrite modes that cannot coexist. export const CredentialsArraySchema = CredentialInputSchema.array() .min(1, "At least one credential is required") .superRefine((credentials, ctx) => { @@ -153,7 +145,6 @@ export const CredentialsArraySchema = CredentialInputSchema.array() message: "Basic auth requires both a username and a password credential" }); } - // Basic auth already owns the Authorization header, so it cannot coexist with named header rewrites. if (usernameCount + passwordCount > 0 && headerNameCounts.size > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -162,7 +153,6 @@ export const CredentialsArraySchema = CredentialInputSchema.array() } }); -// Sanitized response shapes, shared by the CRUD router and the dashboard aggregate schema. export const SanitizedProxiedServiceCredentialSchema = ProxiedServiceCredentialsSchema.pick({ id: true, serviceId: true, 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 e09a22faa54..6a0868bc546 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-service.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-service.ts @@ -73,9 +73,6 @@ export const proxiedServiceServiceFactory = ({ } }; - // Validates that every referenced secret exists in the folder. Assumes the caller already holds - // ReadValue on the folder path (checked separately). Stale references are allowed to persist afterwards - // (secret rename/delete), but must resolve at creation/update time to catch typos early. const $validateSecretReferences = async (folderId: string, credentials: TProxiedServiceCredentialInput[]) => { const uniqueKeys = [...new Set(credentials.map((c) => c.secretKey))]; if (!uniqueKeys.length) return; @@ -93,9 +90,7 @@ export const proxiedServiceServiceFactory = ({ } }; - // Asserts ReadValue on each referenced secret individually (by name), not just on the folder path. - // A per-key check means a deny/inverted rule on a specific secret can't be bypassed by folder-wide - // ReadValue, so a caller can't wire a secret they were explicitly denied into a service they control. + // per-key ReadValue check: a deny rule on a specific secret can't be bypassed by folder-wide ReadValue const $assertCanReadReferencedSecrets = ( permission: MongoAbility, environment: string, @@ -112,7 +107,6 @@ export const proxiedServiceServiceFactory = ({ }); }; - // Resolves the canonical secret path for a service's folder so scoped (glob) permission checks are accurate. const $resolveSecretPath = async (projectId: string, folderId: string) => { const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(projectId, [folderId]); if (!folderWithPath) { @@ -140,7 +134,6 @@ export const proxiedServiceServiceFactory = ({ ProjectPermissionProxiedServiceActions.Create, subject(ProjectPermissionSub.ProxiedServices, { environment, secretPath: canonicalPath }) ); - // caller must be able to read each referenced secret they are wiring up (per-key, see helper) $assertCanReadReferencedSecrets(permission, environment, canonicalPath, credentials); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); @@ -183,7 +176,6 @@ export const proxiedServiceServiceFactory = ({ projectId }); - // every service in this list shares one folder, so evaluate access once const scopedSubject = subject(ProjectPermissionSub.ProxiedServices, { environment, secretPath: canonicalPath @@ -322,8 +314,6 @@ export const proxiedServiceServiceFactory = ({ } if (credentials) { - // same per-key guard as create: Edit-only actors must not reference secrets they cannot read - // and exfiltrate them via the proxy $assertCanReadReferencedSecrets(permission, service.environmentSlug, resolvedSecretPath, credentials); await $validateSecretReferences(service.folderId, credentials); } @@ -353,7 +343,6 @@ export const proxiedServiceServiceFactory = ({ updatedCredentials = await proxiedServiceCredentialDAL.findByServiceIds([serviceId], tx); } - // scope fields are stripped from the API response but let the router emit an audit log return { ...updated, credentials: updatedCredentials, @@ -388,10 +377,8 @@ export const proxiedServiceServiceFactory = ({ }) ); - // credentials cascade on serviceId FK await proxiedServiceDAL.deleteById(serviceId); const { environmentSlug, ...rest } = service; - // scope fields are stripped from the API response but let the router emit an audit log (same shape as updateById) return { ...rest, environment: environmentSlug, secretPath: resolvedSecretPath }; }; @@ -399,8 +386,6 @@ export const proxiedServiceServiceFactory = ({ { projectId, environments, secretPath, search }: TProxiedServiceDashboardCountDTO, actor: OrgServiceActor ) => { - // soft license gate: the dashboard aggregate runs alongside secrets/folders, so we return an - // empty result rather than throwing (a throw would break the whole overview for unlicensed orgs) const plan = await licenseService.getPlan(actor.orgId); if (!plan.secretsBrokering) return 0; @@ -438,7 +423,6 @@ export const proxiedServiceServiceFactory = ({ { projectId, environments, secretPath, search, orderDirection, limit, offset }: TProxiedServiceDashboardListDTO, actor: OrgServiceActor ) => { - // soft license gate (see getDashboardProxiedServiceCount) const plan = await licenseService.getPlan(actor.orgId); if (!plan.secretsBrokering) return []; @@ -466,9 +450,6 @@ export const proxiedServiceServiceFactory = ({ ); if (!allowedFolders.length) return []; - // Ranked/searched/paged in the DAL: limit & offset count UNIQUE NAMES across environments (see - // findDashboardByFolderIds), so pagination stays consistent with countByFolderIds and the router's - // unique-name limit accounting -- a service present in multiple envs is never split across pages. const services = await proxiedServiceDAL.findDashboardByFolderIds({ folderIds: allowedFolders.map((f) => f.id), search, @@ -484,8 +465,6 @@ export const proxiedServiceServiceFactory = ({ return acc; }, {}); - // DAL's folder.path carries the folder's own name, not its full path; stamp the canonical - // secret path so the frontend uses a correct secretPath for its scoped permission checks and pickers. return services.map((svc) => ({ ...svc, folder: { ...svc.folder, path: canonicalSecretPath }, diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 521067b937e..db14e9b60a2 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -293,7 +293,6 @@ export const SanitizedHoneyTokenSchema = HoneyTokensSchema.pick({ }) }); -// dashboard aggregate shape: shared base + credential picks, plus environment/folder for the UI export const SanitizedProxiedServiceSchema = SanitizedProxiedServiceBaseSchema.extend({ environment: z.object({ id: z.string(), diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx index d9d7335060e..67b09689fa3 100644 --- a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -60,7 +60,6 @@ const toDefaultValues = (svc?: TDashboardProxiedService): TProxiedServiceForm => hostPattern: "", isEnabled: true, headerMode: HeaderRewritingMode.Headers, - // pre-fill a bearer header on create (mockup behavior) headers: [{ secretKey: "", headerName: "Authorization", headerPrefix: "Bearer" }], substitutions: [] }; @@ -212,7 +211,6 @@ export const ProxiedServiceForm = ({ }); onComplete(); } catch (err) { - // surface the backend reason (e.g. "Referenced secret(s) not found", permission errors) const raw = (err as AxiosError<{ message?: string | { message?: string }[] }>)?.response?.data ?.message; const detail = Array.isArray(raw) @@ -281,7 +279,6 @@ export const ProxiedServiceForm = ({ - {/* Header Rewriting */}
@@ -431,7 +428,6 @@ export const ProxiedServiceForm = ({ )}
- {/* Credential Substitution */}
diff --git a/frontend/src/components/proxied-services/forms/SecretSelect.tsx b/frontend/src/components/proxied-services/forms/SecretSelect.tsx index c91922a74a5..443edb73db2 100644 --- a/frontend/src/components/proxied-services/forms/SecretSelect.tsx +++ b/frontend/src/components/proxied-services/forms/SecretSelect.tsx @@ -17,11 +17,9 @@ type Props = { placeholder?: string; }; -// isReadable drives whether the option can be picked: attaching a secret to a proxied service -// requires ReadValue on the backend, so a secret the caller can only describe is shown disabled. +// Attaching a secret requires ReadValue on the backend; describe-only secrets show disabled. type SecretOption = { label: string; value: string; isReadable: boolean }; -// Hoisted out of the component so it isn't recreated each render (react/no-unstable-nested-components). const formatSecretOption = (option: SecretOption) => option.isReadable ? (
@@ -42,8 +40,6 @@ const formatSecretOption = (option: SecretOption) => ); -// Picks a secret key from the current folder. Uses the searchable FilterableSelect so long secret -// lists stay usable. A stale reference (secret renamed/deleted) still renders its raw key. export const SecretSelect = ({ projectId, environment, @@ -65,7 +61,6 @@ export const SecretSelect = ({ const options: SecretOption[] = secrets.map((secret) => ({ label: secret.key, value: secret.key, - // the list endpoint only requires describe, so re-check ReadValue client-side (backend enforces it too) isReadable: hasSecretReadValueOrDescribePermission( permission, ProjectPermissionSecretActions.ReadValue, diff --git a/frontend/src/components/proxied-services/forms/SurfaceSelect.tsx b/frontend/src/components/proxied-services/forms/SurfaceSelect.tsx index 8b666838a04..cbfbbe26005 100644 --- a/frontend/src/components/proxied-services/forms/SurfaceSelect.tsx +++ b/frontend/src/components/proxied-services/forms/SurfaceSelect.tsx @@ -25,8 +25,6 @@ type Props = { isDisabled?: boolean; }; -// Multi-select for substitution surfaces: selected values show as removable chips in the trigger, -// the dropdown lists all surfaces as checkbox items. export const SurfaceSelect = ({ value, onChange, isDisabled }: Props) => { const toggle = (surface: ProxiedServiceSubstitutionSurface) => { onChange(value.includes(surface) ? value.filter((s) => s !== surface) : [...value, surface]); diff --git a/frontend/src/components/proxied-services/forms/schema.ts b/frontend/src/components/proxied-services/forms/schema.ts index 3359825bda3..6fa23dfa661 100644 --- a/frontend/src/components/proxied-services/forms/schema.ts +++ b/frontend/src/components/proxied-services/forms/schema.ts @@ -52,9 +52,7 @@ const hostPatternField = z }); }); -// Field-level schemas are permissive; required-ness is enforced conditionally in the -// top-level superRefine so that fields belonging to the inactive header mode (which are not -// rendered) don't block submission. +// 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(), headerName: z.string().trim(), @@ -99,7 +97,6 @@ export const proxiedServiceFormSchema = z }) .superRefine((form, ctx) => { if (form.headerMode === HeaderRewritingMode.Headers) { - // only validate the rows the user actually sees in Headers mode const seenHeaderNames = new Map(); form.headers.forEach((row, i) => { if (!row.secretKey) { @@ -127,7 +124,6 @@ export const proxiedServiceFormSchema = z seenHeaderNames.set(key, i); } }); - // the backend rejects a service with no credentials at all if (!form.headers.length && !form.substitutions.length) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -152,7 +148,6 @@ export const proxiedServiceFormSchema = z } } - // placeholder env var names must be unique: each maps to one env var the agent sees const seenPlaceholderKeys = new Map(); form.substitutions.forEach((row, i) => { const key = row.placeholderKey.trim(); diff --git a/frontend/src/hooks/api/proxiedServices/types.ts b/frontend/src/hooks/api/proxiedServices/types.ts index 8437b768e6f..be26cdddc7a 100644 --- a/frontend/src/hooks/api/proxiedServices/types.ts +++ b/frontend/src/hooks/api/proxiedServices/types.ts @@ -17,7 +17,6 @@ export type TProxiedServiceCredential = { substitutionSurfaces?: ProxiedServiceSubstitutionSurface[] | null; }; -// Shape returned by the DELETE endpoint (base schema, no credentials). export type TProxiedServiceBase = { id: string; name: string; @@ -32,7 +31,6 @@ export type TProxiedService = TProxiedServiceBase & { credentials: TProxiedServiceCredential[]; }; -// Shape returned by the dashboard aggregate endpoint (includes environment + folder path). export type TDashboardProxiedService = TProxiedService & { environment: { id: string; From 62dcb3501fb158ae77c3853b201d282748e62e90 Mon Sep 17 00:00:00 2001 From: Saif Date: Sat, 11 Jul 2026 14:12:02 +0530 Subject: [PATCH 21/43] fix: align proxied service form input heights with the secret picker --- .../components/proxied-services/forms/ProxiedServiceForm.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx index 67b09689fa3..00f90a325f6 100644 --- a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -313,7 +313,6 @@ export const ProxiedServiceForm = ({ {i === 0 && Name} Prefix} @@ -461,7 +459,6 @@ export const ProxiedServiceForm = ({ Environment Variable ( - + )} /> From 10a629f4c7b63261f4827eaf0372766febc0e6b8 Mon Sep 17 00:00:00 2001 From: Saif Date: Sat, 11 Jul 2026 14:34:40 +0530 Subject: [PATCH 22/43] refactor: replace header mode tabs with a switch button in proxied service form --- .../forms/ProxiedServiceForm.tsx | 130 +++++++++--------- 1 file changed, 67 insertions(+), 63 deletions(-) diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx index 00f90a325f6..4c6245b7b16 100644 --- a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -16,9 +16,6 @@ import { Input, SheetFooter, Switch, - Tabs, - TabsList, - TabsTrigger, Tooltip, TooltipContent, TooltipTrigger @@ -166,6 +163,7 @@ export const ProxiedServiceForm = ({ handleSubmit, watch, register, + setValue, formState: { errors, isSubmitting } } = useForm({ resolver: zodResolver(proxiedServiceFormSchema), @@ -280,23 +278,9 @@ export const ProxiedServiceForm = ({
-
-
-

Header Rewriting

-

Sets these headers on every request.

-
- ( - - - Headers - Basic Auth - - - )} - /> +
+

Header Rewriting

+

Sets these headers on every request.

{headerMode === HeaderRewritingMode.Headers ? ( @@ -365,7 +349,7 @@ export const ProxiedServiceForm = ({
))}
-
+
+
{headersRootError && {headersRootError}} ) : ( -
- - Username - - ( - - )} - /> - - - - - Password - - ( - - )} - /> - - - -
+ <> +
+ + Username + + ( + + )} + /> + + + + + Password + + ( + + )} + /> + + + +
+
+ +
+ )}
From 88a36452cf970e8cf91b1b5b491cfaddaa60e099 Mon Sep 17 00:00:00 2001 From: Saif Date: Sat, 11 Jul 2026 14:34:40 +0530 Subject: [PATCH 23/43] fix: refetch secrets in proxied service picker so deleted secrets don't linger --- .../src/components/proxied-services/forms/SecretSelect.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/proxied-services/forms/SecretSelect.tsx b/frontend/src/components/proxied-services/forms/SecretSelect.tsx index 443edb73db2..fc83183981d 100644 --- a/frontend/src/components/proxied-services/forms/SecretSelect.tsx +++ b/frontend/src/components/proxied-services/forms/SecretSelect.tsx @@ -55,7 +55,8 @@ export const SecretSelect = ({ projectId, environment, secretPath, - viewSecretValue: false + viewSecretValue: false, + options: { staleTime: 0 } }); const options: SecretOption[] = secrets.map((secret) => ({ From 124f8447b8d1f9ccf64c9e2fdfebfdbd81629107 Mon Sep 17 00:00:00 2001 From: Saif Date: Sat, 11 Jul 2026 14:50:46 +0530 Subject: [PATCH 24/43] improvement: clearer proxied service field hints and editable placeholder value --- .../forms/ProxiedServiceForm.tsx | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx index 4c6245b7b16..60035ee6858 100644 --- a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -262,14 +262,19 @@ export const ProxiedServiceForm = ({ - The hosts this service applies to. Match an exact host, a wildcard (*.stripe.com), - or a port/path (api.stripe.com:443/v1/*). Comma-separate multiple patterns. +

+ The hosts whose traffic this service brokers. Separate multiple with commas, for + example: +

+

+ api.stripe.com, *.github.com/v1/*, internal.corp.com:8443 +

@@ -460,7 +465,18 @@ export const ProxiedServiceForm = ({
- Environment Variable + + Environment Variable + + + + + + Infisical sets this environment variable on the agent for you, holding the + placeholder value. + + + - Placeholder Value + + Placeholder Value + + + + + + The fake value the agent sends in place of the real secret. The proxy + swaps it for the real credential on the wire. + + + - ( - - )} + + Date: Sat, 11 Jul 2026 15:50:31 +0530 Subject: [PATCH 25/43] improvement: consistent wording in proxied service substitution hints --- .../forms/ProxiedServiceForm.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx index 60035ee6858..e5aa0a75421 100644 --- a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -312,10 +312,7 @@ export const ProxiedServiceForm = ({ {i === 0 && Prefix} - + @@ -444,14 +441,14 @@ export const ProxiedServiceForm = ({ - The placeholder is delivered as an environment variable your application reads. - Your application sends that placeholder value in its request, and the proxy swaps - it for the real secret on the wire. + The placeholder is delivered as an environment variable the agent reads. The agent + sends that placeholder value in its request, and the proxy swaps it for the real + secret on the wire.

- Swap a placeholder in the request for the real credential, on the wire. + Swap a placeholder in the request for the real secret, on the wire.

@@ -494,8 +491,8 @@ export const ProxiedServiceForm = ({ - The fake value the agent sends in place of the real secret. The proxy - swaps it for the real credential on the wire. + The value the agent sends instead of the real secret; the proxy swaps it + on the wire. From 32ead6b4eccfece4ea5949ee0ce66a7bf649147a Mon Sep 17 00:00:00 2001 From: Saif Date: Sat, 11 Jul 2026 16:09:28 +0530 Subject: [PATCH 26/43] fix: honor tag-scoped secret deny rules when wiring proxied service credentials --- .../proxied-service-service.ts | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) 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 6a0868bc546..288dd084c94 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-service.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-service.ts @@ -73,7 +73,16 @@ export const proxiedServiceServiceFactory = ({ } }; - const $validateSecretReferences = async (folderId: string, credentials: TProxiedServiceCredentialInput[]) => { + // Validates every referenced secret exists and that the caller holds ReadValue on it. The check is + // per-key and tag-aware so a deny rule scoped by secret name or tag can't be bypassed to wire in a + // secret the caller can't read (the proxy would otherwise broker its value on their behalf). + const $assertReferencedSecretsReadable = async ( + permission: MongoAbility, + environment: string, + secretPath: string, + folderId: string, + credentials: TProxiedServiceCredentialInput[] + ) => { const uniqueKeys = [...new Set(credentials.map((c) => c.secretKey))]; if (!uniqueKeys.length) return; @@ -81,30 +90,23 @@ export const proxiedServiceServiceFactory = ({ folderId, uniqueKeys.map((key) => ({ key, type: SecretType.Shared })) ); - const foundKeys = new Set(found.map((s) => s.key)); - const missing = uniqueKeys.filter((key) => !foundKeys.has(key)); - if (missing.length) { - throw new BadRequestError({ - message: `Referenced secret(s) not found in folder: ${missing.join(", ")}` - }); - } - }; + const tagsByKey = new Map(found.map((s) => [s.key, s.tags?.map((t) => t.slug) ?? []])); - // per-key ReadValue check: a deny rule on a specific secret can't be bypassed by folder-wide ReadValue - const $assertCanReadReferencedSecrets = ( - permission: MongoAbility, - environment: string, - secretPath: string, - credentials: TProxiedServiceCredentialInput[] - ) => { - const uniqueKeys = [...new Set(credentials.map((c) => c.secretKey))]; uniqueKeys.forEach((secretName) => { throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { environment, secretPath, - secretName + secretName, + secretTags: tagsByKey.get(secretName) ?? [] }); }); + + const missing = uniqueKeys.filter((key) => !tagsByKey.has(key)); + if (missing.length) { + throw new BadRequestError({ + message: `Referenced secret(s) not found in folder: ${missing.join(", ")}` + }); + } }; const $resolveSecretPath = async (projectId: string, folderId: string) => { @@ -134,7 +136,6 @@ export const proxiedServiceServiceFactory = ({ ProjectPermissionProxiedServiceActions.Create, subject(ProjectPermissionSub.ProxiedServices, { environment, secretPath: canonicalPath }) ); - $assertCanReadReferencedSecrets(permission, environment, canonicalPath, credentials); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) { @@ -143,13 +144,13 @@ export const proxiedServiceServiceFactory = ({ }); } + await $assertReferencedSecretsReadable(permission, environment, canonicalPath, folder.id, credentials); + const existing = await proxiedServiceDAL.findOne({ folderId: folder.id, name }); if (existing) { throw new BadRequestError({ message: `A proxied service named "${name}" already exists in this folder` }); } - await $validateSecretReferences(folder.id, credentials); - return proxiedServiceDAL.transaction(async (tx) => { const service = await proxiedServiceDAL.create( { name, hostPattern, isEnabled: isEnabled ?? true, folderId: folder.id }, @@ -314,8 +315,13 @@ export const proxiedServiceServiceFactory = ({ } if (credentials) { - $assertCanReadReferencedSecrets(permission, service.environmentSlug, resolvedSecretPath, credentials); - await $validateSecretReferences(service.folderId, credentials); + await $assertReferencedSecretsReadable( + permission, + service.environmentSlug, + resolvedSecretPath, + service.folderId, + credentials + ); } const serviceUpdate = { From f7d2e8d4ac5c812b5ccfb8be22e0ca73ca9bc867 Mon Sep 17 00:00:00 2001 From: Saif Date: Sat, 11 Jul 2026 17:49:53 +0530 Subject: [PATCH 27/43] improvement: sentence-style credential substitution rows and wider proxied service sheet --- .../CreateProxiedServiceModal.tsx | 2 +- .../EditProxiedServiceModal.tsx | 2 +- .../forms/ProxiedServiceForm.tsx | 115 +++++++++--------- 3 files changed, 58 insertions(+), 61 deletions(-) diff --git a/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx b/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx index 08bd9f0bd50..e87dfd26ce9 100644 --- a/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx +++ b/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx @@ -19,7 +19,7 @@ export const CreateProxiedServiceModal = ({ }: Props) => { return ( - + Create Proxied Service diff --git a/frontend/src/components/proxied-services/EditProxiedServiceModal.tsx b/frontend/src/components/proxied-services/EditProxiedServiceModal.tsx index 6a3bf26bdcc..00f99c53118 100644 --- a/frontend/src/components/proxied-services/EditProxiedServiceModal.tsx +++ b/frontend/src/components/proxied-services/EditProxiedServiceModal.tsx @@ -20,7 +20,7 @@ export const EditProxiedServiceModal = ({ return ( - + Edit Proxied Service Update how the agent proxy brokers this service. diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx index e5aa0a75421..1c76e16bd93 100644 --- a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -459,66 +459,57 @@ export const ProxiedServiceForm = ({

)} {substitutionFields.fields.map((row, i) => ( -
-
- - - Environment Variable - - - - - - Infisical sets this environment variable on the agent for you, holding the - placeholder value. - - - - - - - - - - - Placeholder Value - - - - - - The value the agent sends instead of the real secret; the proxy swaps it - on the wire. - - - - - - - - +
+
+ Set + + + + + + + Infisical sets this environment variable on the agent for you, holding the + placeholder value. + + + to the placeholder + + + + + + + The value the agent sends instead of the real secret; the proxy swaps it on + the wire. + + substitutionFields.remove(i)} >
- - Replace In - +
+ and replace it in +
)} /> - - - - - Secret - +
+
+
+ with value of +
)} /> - - - +
+
+
))}
From f0abbd0a2877bd44424e6186230d11406a4f3850 Mon Sep 17 00:00:00 2001 From: Saif Date: Sat, 11 Jul 2026 19:25:09 +0530 Subject: [PATCH 28/43] feat: support secret imports and references for proxied service credentials Resolve credential secretKeys through the folder's secret imports (reusing fnSecretsV2FromImports) with ReadValue checked at the source, and surface imported secrets in the picker. Folder-local keys win over same-named imports. --- .../proxied-service-service.ts | 96 ++++++++++++--- backend/src/server/routes/index.ts | 4 + .../proxied-services/forms/SecretSelect.tsx | 115 +++++++++++++----- 3 files changed, 170 insertions(+), 45 deletions(-) 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 288dd084c94..b45c0742cef 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-service.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-service.ts @@ -1,7 +1,10 @@ import { ForbiddenError, MongoAbility, subject } from "@casl/ability"; import { ActionProjectType, SecretType } from "@app/db/schemas"; -import { throwIfMissingSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns"; +import { + hasSecretReadValueOrDescribePermission, + throwIfMissingSecretReadValueOrDescribePermission +} from "@app/ee/services/permission/permission-fns"; import { ProjectPermissionProxiedServiceActions, ProjectPermissionSecretActions, @@ -11,7 +14,13 @@ import { import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { prefixWithSlash, removeTrailingSlash } from "@app/lib/fn"; import { OrgServiceActor } from "@app/lib/types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TProjectFolderGrantDALFactory } from "@app/services/project-folder-grant/project-folder-grant-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { TSecretImportDALFactory } from "@app/services/secret-import/secret-import-dal"; +import { fnSecretsV2FromImports } from "@app/services/secret-import/secret-import-fns"; import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal"; import { TLicenseServiceFactory } from "../license/license-service"; @@ -37,9 +46,13 @@ type TProxiedServiceServiceFactoryDep = { proxiedServiceCredentialDAL: TProxiedServiceCredentialDALFactory; folderDAL: Pick< TSecretFolderDALFactory, - "findBySecretPath" | "findBySecretPathMultiEnv" | "findSecretPathByFolderIds" + "findBySecretPath" | "findBySecretPathMultiEnv" | "findSecretPathByFolderIds" | "findByManySecretPath" >; - secretV2BridgeDAL: Pick; + secretV2BridgeDAL: Pick; + secretImportDAL: Pick; + orgDAL: Pick; + projectFolderGrantDAL: Pick; + kmsService: Pick; permissionService: Pick; licenseService: Pick; }; @@ -61,6 +74,10 @@ export const proxiedServiceServiceFactory = ({ proxiedServiceCredentialDAL, folderDAL, secretV2BridgeDAL, + secretImportDAL, + orgDAL, + projectFolderGrantDAL, + kmsService, permissionService, licenseService }: TProxiedServiceServiceFactoryDep) => { @@ -75,9 +92,14 @@ export const proxiedServiceServiceFactory = ({ // Validates every referenced secret exists and that the caller holds ReadValue on it. The check is // per-key and tag-aware so a deny rule scoped by secret name or tag can't be bypassed to wire in a - // secret the caller can't read (the proxy would otherwise broker its value on their behalf). + // secret the caller can't read (the proxy would otherwise broker its value on their behalf). Keys not + // found in the folder are resolved through its secret imports with the same rules the secrets list API + // applies: ReadValue is checked at the import source (or against the importing folder for cross-project + // grants), and a secret the caller cannot read is treated as not found. const $assertReferencedSecretsReadable = async ( permission: MongoAbility, + projectId: string, + actorOrgId: string, environment: string, secretPath: string, folderId: string, @@ -92,19 +114,55 @@ export const proxiedServiceServiceFactory = ({ ); const tagsByKey = new Map(found.map((s) => [s.key, s.tags?.map((t) => t.slug) ?? []])); - uniqueKeys.forEach((secretName) => { - throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { - environment, - secretPath, - secretName, - secretTags: tagsByKey.get(secretName) ?? [] + uniqueKeys + .filter((key) => tagsByKey.has(key)) + .forEach((secretName) => { + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath, + secretName, + secretTags: tagsByKey.get(secretName) ?? [] + }); }); - }); - const missing = uniqueKeys.filter((key) => !tagsByKey.has(key)); + let missing = uniqueKeys.filter((key) => !tagsByKey.has(key)); + if (!missing.length) return; + + const secretImports = await secretImportDAL.findByFolderIds([folderId]); + const allowedImports = secretImports.filter(({ isReplication }) => !isReplication); + if (allowedImports.length) { + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + const importedGroups = await fnSecretsV2FromImports({ + secretImports: allowedImports, + folderDAL, + secretDAL: secretV2BridgeDAL, + secretImportDAL, + orgDAL, + kmsService, + viewSecretValue: false, + decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : ""), + hasSecretAccess: (importEnvironment, importSecretPath, importSecretKey, importSecretTags) => + hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment: importEnvironment, + secretPath: importSecretPath, + secretName: importSecretKey, + secretTags: importSecretTags + }), + importAccessScopeByFolderId: new Map([[folderId, { environment, secretPath }]]), + projectId, + projectFolderGrantDAL, + actorOrgId + }); + const importedKeys = new Set(importedGroups.flatMap((group) => group.secrets.map((s) => s.secretKey))); + missing = missing.filter((key) => !importedKeys.has(key)); + } + if (missing.length) { throw new BadRequestError({ - message: `Referenced secret(s) not found in folder: ${missing.join(", ")}` + message: `Referenced secret(s) not found in folder or its imports: ${missing.join(", ")}` }); } }; @@ -144,7 +202,15 @@ export const proxiedServiceServiceFactory = ({ }); } - await $assertReferencedSecretsReadable(permission, environment, canonicalPath, folder.id, credentials); + await $assertReferencedSecretsReadable( + permission, + projectId, + actor.orgId, + environment, + canonicalPath, + folder.id, + credentials + ); const existing = await proxiedServiceDAL.findOne({ folderId: folder.id, name }); if (existing) { @@ -317,6 +383,8 @@ export const proxiedServiceServiceFactory = ({ if (credentials) { await $assertReferencedSecretsReadable( permission, + service.projectId, + actor.orgId, service.environmentSlug, resolvedSecretPath, service.folderId, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 74fc31c3e83..361c2dac872 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2920,6 +2920,10 @@ export const registerRoutes = async ( proxiedServiceCredentialDAL, folderDAL, secretV2BridgeDAL, + secretImportDAL, + orgDAL, + projectFolderGrantDAL, + kmsService, permissionService, licenseService }); diff --git a/frontend/src/components/proxied-services/forms/SecretSelect.tsx b/frontend/src/components/proxied-services/forms/SecretSelect.tsx index fc83183981d..c8a48412f8a 100644 --- a/frontend/src/components/proxied-services/forms/SecretSelect.tsx +++ b/frontend/src/components/proxied-services/forms/SecretSelect.tsx @@ -1,9 +1,12 @@ +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 { useGetProjectSecrets } from "@app/hooks/api/secrets/queries"; +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 = { @@ -18,27 +21,31 @@ type Props = { }; // 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) => - option.isReadable ? ( +const formatSecretOption = (option: SecretOption) => { + const row = (
- + {option.isReadable ? ( + + ) : ( + + )} {option.label}
- ) : ( + ); + + if (option.isReadable) return row; + return ( - -
- - {option.label} -
-
+ {row} You need read access to this secret's value to use it in a proxied service.
); +}; export const SecretSelect = ({ projectId, @@ -51,28 +58,74 @@ export const SecretSelect = ({ placeholder = "Select a secret" }: Props) => { const { permission } = useProjectPermission(); - const { data: secrets = [], isPending } = useGetProjectSecrets({ - projectId, - environment, - secretPath, - viewSecretValue: false, - options: { staleTime: 0 } - }); - const options: SecretOption[] = secrets.map((secret) => ({ - label: secret.key, - value: secret.key, - isReadable: hasSecretReadValueOrDescribePermission( - permission, - ProjectPermissionSecretActions.ReadValue, - { - environment, - secretPath, - secretName: secret.key, - secretTags: secret.tags?.map((tag) => tag.slug) ?? [] + 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) ?? From da82771611dde0c1a58f757fc0a4d9e9dcdcb859 Mon Sep 17 00:00:00 2001 From: Saif Date: Sat, 11 Jul 2026 19:25:19 +0530 Subject: [PATCH 29/43] docs: expose proxied service endpoints in the API reference Unhide the CRUD routes, tag them, and add field descriptions so they render in the OpenAPI spec, plus the api-reference stub pages. --- .../ee/routes/v1/proxied-service-router.ts | 66 ++++++++++++------- .../proxied-service-schemas.ts | 30 ++++++--- backend/src/lib/api-docs/constants.ts | 51 ++++++++++++++ .../endpoints/proxied-services/create.mdx | 4 ++ .../endpoints/proxied-services/delete.mdx | 4 ++ .../endpoints/proxied-services/get.mdx | 4 ++ .../endpoints/proxied-services/list.mdx | 4 ++ .../endpoints/proxied-services/update.mdx | 4 ++ 8 files changed, 137 insertions(+), 30 deletions(-) create mode 100644 docs/api-reference/endpoints/proxied-services/create.mdx create mode 100644 docs/api-reference/endpoints/proxied-services/delete.mdx create mode 100644 docs/api-reference/endpoints/proxied-services/get.mdx create mode 100644 docs/api-reference/endpoints/proxied-services/list.mdx create mode 100644 docs/api-reference/endpoints/proxied-services/update.mdx diff --git a/backend/src/ee/routes/v1/proxied-service-router.ts b/backend/src/ee/routes/v1/proxied-service-router.ts index 00defeef117..1a5719486ab 100644 --- a/backend/src/ee/routes/v1/proxied-service-router.ts +++ b/backend/src/ee/routes/v1/proxied-service-router.ts @@ -7,6 +7,7 @@ import { ProxiedServiceWithCredentialsSchema, SanitizedProxiedServiceBaseSchema } from "@app/ee/services/proxied-service/proxied-service-schemas"; +import { ApiDocsTags, PROXIED_SERVICES } from "@app/lib/api-docs"; import { BadRequestError } from "@app/lib/errors"; import { isUuidV4 } from "@app/lib/validator"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -14,12 +15,6 @@ import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -const ScopeQuerySchema = z.object({ - projectId: z.string().trim().min(1), - environment: z.string().trim().min(1), - secretPath: z.string().trim().default("/") -}); - export const registerProxiedServiceRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -27,14 +22,17 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = config: { rateLimit: writeLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.ProxiedServices], + description: "Create a proxied service", body: z.object({ - projectId: z.string().trim().min(1), - environment: z.string().trim().min(1), - secretPath: z.string().trim().default("/"), - name: slugSchema({ field: "name" }), - hostPattern: hostPatternSchema, - isEnabled: z.boolean().optional(), - credentials: CredentialsArraySchema + projectId: z.string().trim().min(1).describe(PROXIED_SERVICES.CREATE.projectId), + environment: z.string().trim().min(1).describe(PROXIED_SERVICES.CREATE.environment), + secretPath: z.string().trim().default("/").describe(PROXIED_SERVICES.CREATE.secretPath), + name: slugSchema({ field: "name" }).describe(PROXIED_SERVICES.CREATE.name), + hostPattern: hostPatternSchema.describe(PROXIED_SERVICES.CREATE.hostPattern), + isEnabled: z.boolean().optional().describe(PROXIED_SERVICES.CREATE.isEnabled), + credentials: CredentialsArraySchema.describe(PROXIED_SERVICES.CREATE.credentials) }), response: { 200: z.object({ service: ProxiedServiceWithCredentialsSchema }) @@ -67,7 +65,15 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = config: { rateLimit: readLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - querystring: ScopeQuerySchema, + hide: false, + tags: [ApiDocsTags.ProxiedServices], + description: + "List proxied services in a folder. Returns the services the caller can read or proxy through; the canProxy field indicates whether the caller can route traffic through each service.", + querystring: z.object({ + projectId: z.string().trim().min(1).describe(PROXIED_SERVICES.LIST.projectId), + environment: z.string().trim().min(1).describe(PROXIED_SERVICES.LIST.environment), + secretPath: z.string().trim().default("/").describe(PROXIED_SERVICES.LIST.secretPath) + }), response: { 200: z.object({ services: ProxiedServiceWithCredentialsSchema.extend({ canProxy: z.boolean() }).array() @@ -85,8 +91,18 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = config: { rateLimit: readLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - params: z.object({ serviceIdOrName: z.string().trim().min(1) }), - querystring: ScopeQuerySchema.partial(), + hide: false, + tags: [ApiDocsTags.ProxiedServices], + description: + "Get a proxied service by ID, or by name when the projectId and environment query params are provided", + params: z.object({ + serviceIdOrName: z.string().trim().min(1).describe(PROXIED_SERVICES.GET.serviceIdOrName) + }), + querystring: z.object({ + projectId: z.string().trim().min(1).describe(PROXIED_SERVICES.GET.projectId).optional(), + environment: z.string().trim().min(1).describe(PROXIED_SERVICES.GET.environment).optional(), + secretPath: z.string().trim().describe(PROXIED_SERVICES.GET.secretPath).optional() + }), response: { 200: z.object({ service: ProxiedServiceWithCredentialsSchema.extend({ canProxy: z.boolean() }) @@ -119,12 +135,15 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = config: { rateLimit: writeLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - params: z.object({ serviceId: z.string().uuid() }), + hide: false, + tags: [ApiDocsTags.ProxiedServices], + description: "Update a proxied service", + params: z.object({ serviceId: z.string().uuid().describe(PROXIED_SERVICES.UPDATE.serviceId) }), body: z.object({ - name: slugSchema({ field: "name" }).optional(), - hostPattern: hostPatternSchema.optional(), - isEnabled: z.boolean().optional(), - credentials: CredentialsArraySchema.optional() + name: slugSchema({ field: "name" }).optional().describe(PROXIED_SERVICES.UPDATE.name), + hostPattern: hostPatternSchema.optional().describe(PROXIED_SERVICES.UPDATE.hostPattern), + isEnabled: z.boolean().optional().describe(PROXIED_SERVICES.UPDATE.isEnabled), + credentials: CredentialsArraySchema.optional().describe(PROXIED_SERVICES.UPDATE.credentials) }), response: { 200: z.object({ service: ProxiedServiceWithCredentialsSchema }) @@ -161,7 +180,10 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = config: { rateLimit: writeLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - params: z.object({ serviceId: z.string().uuid() }), + hide: false, + tags: [ApiDocsTags.ProxiedServices], + description: "Delete a proxied service", + params: z.object({ serviceId: z.string().uuid().describe(PROXIED_SERVICES.DELETE.serviceId) }), response: { 200: z.object({ service: SanitizedProxiedServiceBaseSchema }) } 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 01dd39d8121..4a18d4816bb 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-schemas.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-schemas.ts @@ -2,6 +2,7 @@ import RE2 from "re2"; import { z } from "zod"; import { ProxiedServiceCredentialsSchema, ProxiedServicesSchema } from "@app/db/schemas"; +import { PROXIED_SERVICES } from "@app/lib/api-docs"; import { ProxiedServiceCredentialRole, @@ -51,14 +52,27 @@ export const hostPatternSchema = z const CredentialInputSchema = z .object({ - secretKey: z.string().trim().min(1).max(255), - role: z.nativeEnum(ProxiedServiceCredentialRole), - headerName: z.string().trim().min(1).max(255).optional(), - headerPrefix: z.string().trim().max(255).optional(), - headerPurpose: z.nativeEnum(ProxiedServiceHeaderPurpose).optional(), - placeholderKey: z.string().trim().min(1).max(255).optional(), - placeholderValue: z.string().trim().min(1).max(255).optional(), - substitutionSurfaces: z.array(z.nativeEnum(ProxiedServiceSubstitutionSurface)).nonempty().optional() + secretKey: z.string().trim().min(1).max(255).describe(PROXIED_SERVICES.CREDENTIAL.secretKey), + 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), + headerPurpose: z + .nativeEnum(ProxiedServiceHeaderPurpose) + .optional() + .describe(PROXIED_SERVICES.CREDENTIAL.headerPurpose), + placeholderKey: z.string().trim().min(1).max(255).optional().describe(PROXIED_SERVICES.CREDENTIAL.placeholderKey), + placeholderValue: z + .string() + .trim() + .min(1) + .max(255) + .optional() + .describe(PROXIED_SERVICES.CREDENTIAL.placeholderValue), + substitutionSurfaces: z + .array(z.nativeEnum(ProxiedServiceSubstitutionSurface)) + .nonempty() + .optional() + .describe(PROXIED_SERVICES.CREDENTIAL.substitutionSurfaces) }) .superRefine((cred, ctx) => { if (cred.role === ProxiedServiceCredentialRole.HeaderRewrite) { diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index f879496e131..3405713f655 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -54,6 +54,7 @@ export enum ApiDocsTags { DynamicSecrets = "Dynamic Secrets", SecretImports = "Secret Imports", SecretRotations = "Secret Rotations", + ProxiedServices = "Proxied Services", IdentitySpecificPrivilegesV1 = "Identity Specific Privileges", IdentitySpecificPrivilegesV2 = "Identity Specific Privileges V2", AppConnections = "App Connections", @@ -1603,6 +1604,56 @@ export const DYNAMIC_SECRET_LEASES = { } } } as const; + +export const PROXIED_SERVICES = { + CREATE: { + projectId: "The ID of the project to create the proxied service in.", + environment: "The slug of the environment to create the proxied service in.", + secretPath: "The secret path (folder) to create the proxied service in.", + name: "The name of the proxied service.", + hostPattern: + "One or more comma-separated host patterns the service applies to, e.g. 'api.stripe.com, *.stripe.com'. Each pattern is host[:port][/path]; a '*.' wildcard matches exactly one label.", + isEnabled: "Whether the proxied service is enabled. The agent proxy skips disabled services.", + credentials: "The credentials the agent proxy applies to requests matching the host pattern." + }, + LIST: { + projectId: "The ID of the project to list proxied services from.", + environment: "The slug of the environment to list proxied services from.", + secretPath: "The secret path (folder) to list proxied services from." + }, + GET: { + serviceIdOrName: + "The ID of the proxied service, or its name when the projectId and environment query params are provided.", + projectId: "The ID of the project the proxied service is in. Required when fetching by name.", + environment: "The slug of the environment the proxied service is in. Required when fetching by name.", + secretPath: "The secret path (folder) the proxied service is in." + }, + UPDATE: { + serviceId: "The ID of the proxied service to update.", + name: "The new name of the proxied service.", + hostPattern: "The new comma-separated host patterns.", + isEnabled: "Whether the proxied service is enabled. The agent proxy skips disabled services.", + credentials: + "The new credentials. When provided, the entire credentials collection is replaced; when omitted, existing credentials are left unchanged." + }, + DELETE: { + 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.", + 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'.", + headerPurpose: + "For HTTP basic auth: 'username' or 'password'. The agent proxy combines the pair into a single 'Authorization: Basic' header. Cannot be combined with headerName or headerPrefix.", + placeholderKey: "For credential substitution: the environment variable name the agent receives.", + placeholderValue: + "For credential substitution: the placeholder value the agent proxy swaps for the real secret value on the wire.", + substitutionSurfaces: + "For credential substitution: which request surfaces are scanned for the placeholder. Allowed values: 'header', 'path', 'query', 'body'." + } +} as const; + export const SECRET_TAGS = { LIST: { projectId: "The ID of the project to list tags from." diff --git a/docs/api-reference/endpoints/proxied-services/create.mdx b/docs/api-reference/endpoints/proxied-services/create.mdx new file mode 100644 index 00000000000..d1286c6b52d --- /dev/null +++ b/docs/api-reference/endpoints/proxied-services/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/proxied-services" +--- diff --git a/docs/api-reference/endpoints/proxied-services/delete.mdx b/docs/api-reference/endpoints/proxied-services/delete.mdx new file mode 100644 index 00000000000..4cf8e1171c8 --- /dev/null +++ b/docs/api-reference/endpoints/proxied-services/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/proxied-services/{serviceId}" +--- diff --git a/docs/api-reference/endpoints/proxied-services/get.mdx b/docs/api-reference/endpoints/proxied-services/get.mdx new file mode 100644 index 00000000000..16a564a6f7d --- /dev/null +++ b/docs/api-reference/endpoints/proxied-services/get.mdx @@ -0,0 +1,4 @@ +--- +title: "Get" +openapi: "GET /api/v1/proxied-services/{serviceIdOrName}" +--- diff --git a/docs/api-reference/endpoints/proxied-services/list.mdx b/docs/api-reference/endpoints/proxied-services/list.mdx new file mode 100644 index 00000000000..40d31f4df02 --- /dev/null +++ b/docs/api-reference/endpoints/proxied-services/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/proxied-services" +--- diff --git a/docs/api-reference/endpoints/proxied-services/update.mdx b/docs/api-reference/endpoints/proxied-services/update.mdx new file mode 100644 index 00000000000..6a0ca74b44f --- /dev/null +++ b/docs/api-reference/endpoints/proxied-services/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/proxied-services/{serviceId}" +--- From 0c96fc01cc43aecd8119c17c2fa97aee97171c38 Mon Sep 17 00:00:00 2001 From: Saif Date: Sat, 11 Jul 2026 19:25:20 +0530 Subject: [PATCH 30/43] docs: add Agent Proxy platform and CLI documentation --- docs/cli/commands/agent-proxy.mdx | 246 ++++++++++++++++++ docs/docs.json | 20 ++ .../platform/agent-proxy/overview.mdx | 94 +++++++ .../platform/agent-proxy/proxied-services.mdx | 142 ++++++++++ .../platform/agent-proxy/quickstart.mdx | 117 +++++++++ .../platform/agent-proxy/security.mdx | 115 ++++++++ 6 files changed, 734 insertions(+) create mode 100644 docs/cli/commands/agent-proxy.mdx create mode 100644 docs/documentation/platform/agent-proxy/overview.mdx create mode 100644 docs/documentation/platform/agent-proxy/proxied-services.mdx create mode 100644 docs/documentation/platform/agent-proxy/quickstart.mdx create mode 100644 docs/documentation/platform/agent-proxy/security.mdx diff --git a/docs/cli/commands/agent-proxy.mdx b/docs/cli/commands/agent-proxy.mdx new file mode 100644 index 00000000000..12a4ecc9e8d --- /dev/null +++ b/docs/cli/commands/agent-proxy.mdx @@ -0,0 +1,246 @@ +--- +title: "infisical secrets agent-proxy" +description: "Run the Infisical Agent Proxy and connect agents to it" +--- + + + + ```bash + infisical secrets agent-proxy start [options] + + # Example + infisical secrets agent-proxy start --port 17322 + ``` + + + + + ```bash + infisical secrets agent-proxy connect [options] -- [agent start command] + + # Example + infisical secrets agent-proxy connect --proxy=proxy:17322 --env=prod --path=/ai-agents -- claude + ``` + + + + +## Description + +Run the [Infisical Agent Proxy](/documentation/platform/agent-proxy/overview): `start` launches the proxy that brokers real credentials onto agent traffic on the wire, and `connect` launches an agent behind it with the proxy routing, CA trust, and dummy placeholder credentials already set up. + +Both subcommands authenticate with a [machine identity](/documentation/platform/identities/machine-identities) via [Universal Auth](/documentation/platform/identities/universal-auth). Use separate identities for the proxy and for each agent; see the [Quickstart](/documentation/platform/agent-proxy/quickstart) for the recommended roles. + +## Subcommands & flags + + + Start the agent proxy, an HTTP(S) forward proxy that brokers credentials for the agents that connect to it. Requires machine identity credentials with read access to the secrets your proxied services reference (the built-in **Agent Proxy** role). + + Agents reach HTTPS services through standard `CONNECT` tunnels and plain-HTTP services through regular forward-proxy requests; credentials are brokered on both. Requests for `https://` URLs sent as plain forward-proxy requests (rather than `CONNECT`) are rejected so the proxy can never be used to downgrade TLS. + +```bash +$ infisical secrets agent-proxy start + +# Example +$ infisical secrets agent-proxy start --port 17322 --unmatched-host=block +``` + +### Environment variables + + + The Universal Auth credentials of the agent proxy's machine identity. Alternative to passing `--client-id` and `--client-secret`. + + ```bash + # Example + export INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= + export INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= + ``` + + + + Point the CLI to your Infisical instance (for example `https://eu.infisical.com` for EU Cloud, or your self-hosted URL). Alternative to the `--domain` flag. + + ```bash + # Example + export INFISICAL_DOMAIN=https://eu.infisical.com + ``` + + +### Flags + + + Port for the agent proxy to listen on. + + ```bash + # Example + infisical secrets agent-proxy start --port 18000 + ``` + + Default value: `17322` + + + + Policy for requests to hosts with no matching proxied service: `allow` forwards them untouched with no credentials applied (the normal mode: documentation, package registries, and services the agent authenticates to itself pass straight through); `block` rejects them with `403`, restricting agents to the services you have defined. + + ```bash + # Example + infisical secrets agent-proxy start --unmatched-host=block + ``` + + Default value: `allow` + + + `block` blocks every host without a matching proxied service, including your Infisical instance itself. Since agent traffic routes through the proxy, Infisical CLI commands run from inside the agent (using the `INFISICAL_TOKEN` from its environment) will also be rejected in this mode. + + + + + Seconds between permission and credential refreshes for active agents. Changes to proxied services, permissions, and secret values (for example, after a rotation) take effect within one interval. + + ```bash + # Example + infisical secrets agent-proxy start --poll-interval 30 + ``` + + Default value: `60` + + + + Universal Auth credentials for the agent proxy's machine identity. Alternative to the `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` / `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` environment variables. + + ```bash + # Example + infisical secrets agent-proxy start --client-id= --client-secret= + ``` + + + + + Set up the environment and launch an agent behind the agent proxy. Everything after `--` is the agent's own start command. The wrapper authenticates the agent's machine identity, then starts the agent process with: + + - `HTTPS_PROXY` / `HTTP_PROXY` pointing at the agent proxy, plus `NO_PROXY` (always includes `localhost,127.0.0.1`, merged with any `NO_PROXY` already in your environment and the `--no-proxy` flag). + - The organization's root CA written to `~/.infisical/agent-proxy/mitm-ca.pem` and trusted via `SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, and `DENO_CERT`. + - Dummy placeholder environment variables for credential-substitution services the agent has **Proxy** access to. + - Real values for regular secrets the agent has **Read Value** on in the scoped folder, including secrets imported into it (similar to `infisical run`). This is opt-in; the built-in Agent role grants no read access, and brokered credentials never appear in the agent's environment. + - `INFISICAL_TOKEN` set to the agent's access token, so the agent can run Infisical CLI commands itself. + + The machine identity credentials (client ID, client secret, and any access token) are stripped from the child environment. The wrapper forwards signals to the agent process and exits with its exit code. + +```bash +$ infisical secrets agent-proxy connect --proxy=: --env= -- [agent start command] + +# Example +$ infisical secrets agent-proxy connect --proxy=proxy:17322 --projectId= --env=prod --path=/ai-agents -- claude +``` + +### Environment variables + + + The Universal Auth credentials of the agent's machine identity. Alternative to passing `--client-id` and `--client-secret`. + + ```bash + # Example + export INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= + export INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= + ``` + + + + The project to fetch proxied services and secrets from. Alternative to the `--projectId` flag or running inside a directory with an `.infisical.json` file (created by `infisical init`). + + ```bash + # Example + export INFISICAL_PROJECT_ID= + ``` + + + + Point the CLI to your Infisical instance. Alternative to the `--domain` flag. + + ```bash + # Example + export INFISICAL_DOMAIN=https://eu.infisical.com + ``` + + +### Flags + + + Address of the agent proxy as `host:port`. Required. + + ```bash + # Example + infisical secrets agent-proxy connect --proxy=proxy.internal:17322 --env=prod -- claude + ``` + + + + The environment slug to fetch proxied services and secrets from (for example `dev`, `staging`, `prod`). Falls back to the environment in `.infisical.json` if present; required otherwise. + + ```bash + # Example + infisical secrets agent-proxy connect --proxy=proxy:17322 --env=staging -- codex + ``` + + + + The secret path (folder) to scope to. Proxied services and secrets are fetched from this folder. + + ```bash + # Example + infisical secrets agent-proxy connect --proxy=proxy:17322 --env=prod --path=/ai-agents -- claude + ``` + + Default value: `/` + + + + Additional comma-separated hosts that should bypass the proxy. Always merged with `localhost,127.0.0.1` and any `NO_PROXY` already set in your environment. + + ```bash + # Example + infisical secrets agent-proxy connect --proxy=proxy:17322 --env=prod --no-proxy=internal.corp.com -- claude + ``` + + + + The project to fetch proxied services and secrets from. Falls back to the `INFISICAL_PROJECT_ID` environment variable, then to `.infisical.json`. + + ```bash + # Example + infisical secrets agent-proxy connect --proxy=proxy:17322 --env=prod --projectId= -- claude + ``` + + + + Universal Auth credentials for the agent's machine identity. Alternative to the `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` / `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` environment variables. + + ```bash + # Example + infisical secrets agent-proxy connect --proxy=proxy:17322 --env=prod --client-id= --client-secret= -- claude + ``` + + + + Authenticate with a pre-fetched machine identity access token instead of client credentials. + + ```bash + # Example + infisical secrets agent-proxy connect --proxy=proxy:17322 --env=prod --token= -- claude + ``` + + + + + + + Point the CLI to your Infisical instance (for example `https://eu.infisical.com` for EU Cloud, or your self-hosted URL). Can also be set via the `INFISICAL_DOMAIN` environment variable or the `domain` field in `.infisical.json`. Required for non-US Cloud users. + + ```bash + # Example + infisical secrets agent-proxy start --domain=https://your-instance.com + ``` + + Default value: `https://app.infisical.com` + + diff --git a/docs/docs.json b/docs/docs.json index 36ed0fe3cad..2e60e97fc97 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -602,6 +602,15 @@ ] } ] + }, + { + "group": "Agent Proxy", + "pages": [ + "documentation/platform/agent-proxy/overview", + "documentation/platform/agent-proxy/quickstart", + "documentation/platform/agent-proxy/proxied-services", + "documentation/platform/agent-proxy/security" + ] } ] }, @@ -1127,6 +1136,7 @@ "cli/commands/init", "cli/commands/run", "cli/commands/secrets", + "cli/commands/agent-proxy", "cli/commands/dynamic-secrets", "cli/commands/pam", "cli/commands/gateway", @@ -2512,6 +2522,16 @@ "api-reference/endpoints/dynamic-secrets/get-lease" ] }, + { + "group": "Proxied Services", + "pages": [ + "api-reference/endpoints/proxied-services/create", + "api-reference/endpoints/proxied-services/update", + "api-reference/endpoints/proxied-services/delete", + "api-reference/endpoints/proxied-services/get", + "api-reference/endpoints/proxied-services/list" + ] + }, { "group": "Secret Rotations", "pages": [ diff --git a/docs/documentation/platform/agent-proxy/overview.mdx b/docs/documentation/platform/agent-proxy/overview.mdx new file mode 100644 index 00000000000..bb234f0adda --- /dev/null +++ b/docs/documentation/platform/agent-proxy/overview.mdx @@ -0,0 +1,94 @@ +--- +title: "Agent Proxy" +sidebarTitle: "Overview" +description: "Let AI agents and untrusted code execution environments call authenticated APIs without ever holding real credentials." +--- + +You want an AI agent to do real work: push code, file tickets, call external APIs. That work needs credentials, and the **Infisical Agent Proxy** lets the agent do all of it without ever being handed one. + +It works like this: the agent makes an API call with no real credential (or a dummy placeholder, where one is expected). On its way out, the request passes through the agent proxy. The proxy attaches the real credential and forwards the request to the API. The API sees a normal authenticated call. The agent never saw the key, so there is nothing to leak. + +A credential that enters an untrusted code execution environment is exposed in more ways than one: + +- A prompt injection can talk the agent into sending its keys to an attacker (**credential exfiltration**). +- Anything in the agent's environment can end up in its context window, and from there in LLM provider logs and transcripts, or even training data. +- Keys leak sideways into shell history, log files, and error reports as the agent works. +- A stolen key works from anywhere until someone notices and rotates it. + +Brokered credentials sidestep all of it: they cannot be exfiltrated, logged, or memorized from inside the agent, they only work through the proxy for the services you configured, and an agent's access can be revoked centrally at any time. + + + The agent proxy is not limited to AI agents. Any untrusted code execution environment that respects an HTTP proxy can run behind it, with no code changes. + + +## How It Works + +```mermaid +flowchart LR + A[Agent] -->|request without real credentials| B[Agent Proxy] + B -->|real credentials applied on the wire| C[External Service] + B <-->|fetches configs + secrets| D[Infisical] +``` + +1. **Keep your secrets in Infisical**, exactly as you do today. Next to them, define [proxied services](/documentation/platform/agent-proxy/proxied-services): small configs that say, for example, "requests to `api.stripe.com` get `STRIPE_API_KEY` as a Bearer token". +2. **Run the agent proxy** with one command: `infisical secrets agent-proxy start`. +3. **Launch your agent through the wrapper**: `infisical secrets agent-proxy connect -- claude`. The wrapper points the agent's traffic at the proxy and starts it. The agent has no real credentials anywhere in its environment, and no idea any of this is happening. + +Now the agent calls `api.stripe.com`. The proxy adds the real `STRIPE_API_KEY` and forwards the request. Stripe sees a normal authenticated call. + +## The Whole Platform Comes With It + +Standalone credential proxies manage their own keys, users, and permissions in isolation, so you end up running a second secrets manager just for your agents. The agent proxy is different: brokered credentials are ordinary Infisical secrets, so everything the platform does applies to them with zero extra setup. + + + + Rotate brokered credentials on a schedule. The proxy applies the new value within a minute, with no agent restart and no agent awareness. + + + Generate short-lived, per-use credentials on demand for databases, clouds, Kubernetes, and more, so there is no long-lived key to steal in the first place. + + + Require a review before a brokered credential changes, exactly like a code change. + + + Every create, update, and delete of a proxied service is logged with who did it and which secrets it references. + + + Scope each agent identity to exactly the environments, paths, and services it should reach. + + + Every brokered credential keeps its full version history, with point-in-time recovery. + + + +## What You Work With + + + + A small config that lives in a folder next to your secrets. It says which hosts it covers, which secrets to use, and how to apply them (set a header, or swap a placeholder). You manage it in the secrets dashboard like any other resource. + + + An HTTP(S) forward proxy built into the Infisical CLI (`infisical secrets agent-proxy start`). It runs in your private network, close to your agents, and applies credentials to their outbound traffic. It authenticates to Infisical with its own machine identity, which needs read access to the referenced secrets. A single agent proxy can serve many agents at once while keeping them isolated from each other. + + + Any program that respects an HTTP proxy: Claude Code, Codex, OpenClaw, or any other untrusted code execution environment. Each agent authenticates with its own machine identity and is launched through `infisical secrets agent-proxy connect`, which sets up the proxy routing and trust environment before starting the agent process. + + + + + The Agent Proxy is distinct from other similarly named Infisical components: the [Infisical Agent](/integrations/platforms/infisical-agent) is a daemon that fetches secrets to disk for applications, the [Infisical Proxy](/integrations/platforms/infisical-proxy) is a caching proxy for the Infisical API itself, and [Gateways](/documentation/platform/gateways/overview) let Infisical reach into private networks. Those all deliver secrets or connectivity to software you trust. The Agent Proxy does the opposite: it exists so that software you do not trust never receives a secret at all. + + +## Next Steps + + + + Broker your first credential to an agent in a few minutes. + + + Host patterns, header rewriting, and credential substitution. + + + Agent authentication, isolation, certificates, and high availability. + + diff --git a/docs/documentation/platform/agent-proxy/proxied-services.mdx b/docs/documentation/platform/agent-proxy/proxied-services.mdx new file mode 100644 index 00000000000..877f66ab8a8 --- /dev/null +++ b/docs/documentation/platform/agent-proxy/proxied-services.mdx @@ -0,0 +1,142 @@ +--- +title: "Proxied Services" +description: "Define how the agent proxy applies credentials to traffic bound for an external service." +--- + +A proxied service tells the agent proxy how to handle traffic to a specific external service. It lives in a folder alongside your secrets, references those secrets by key name, and defines which hosts it applies to and how each credential is placed into the outbound request. + +You manage proxied services in the secrets dashboard: open a folder, click the dropdown arrow next to **Add Secret**, and select **Add Proxied Service**. They appear in the folder view next to your secrets. + +## Configuration + + + A slug (lowercase letters, numbers, hyphens) that identifies the service, such as `stripe-api`. Names are unique within a folder. + + + + One or more comma-separated patterns describing which hosts this service applies to. See [Host Patterns](#host-patterns) below. + + + + Toggle a service off without deleting it. A disabled service is skipped entirely: its placeholder environment variables are not set, no credentials are applied, and traffic to its hosts falls back to the [unmatched host policy](/documentation/platform/agent-proxy/security#unmatched-hosts). + + +A service then defines one or more credentials in two sections, **Header Rewriting** and **Credential Substitution**. Both are independent; use either or both. + +## Host Patterns + +A host pattern routes requests to the right service. Each pattern is a `host[:port][/path]`, and multiple patterns can be combined with commas: + +```bash +api.stripe.com # exact host +*.github.com # wildcard: matches api.github.com, not a.b.github.com +api.stripe.com:443 # specific port +api.stripe.com/v1/* # path prefix +internal.corp.com:3000/api/* # port and path +api.stripe.com, dashboard.stripe.com # multiple patterns, matches either +``` + +A few rules to keep in mind: + +- A `*.` wildcard matches exactly **one** label: `*.github.com` matches `api.github.com` but not `a.b.github.com` or bare `github.com`. +- Host matching is case-insensitive. A pattern without a port matches any port. +- Do not include a scheme (`https://`); patterns are hosts, not URLs. + +When a request could match more than one service, the agent proxy picks the best match: + +1. Exact host beats wildcard host. +2. A pattern with a specific port beats one that matches any port. +3. The longest matching path prefix wins. +4. If still tied, the service whose name sorts first alphabetically wins. + + + Host patterns are for routing only. Proxied services are also scoped to their folder: an agent connected with `--env=prod --path=/ai-agents` only ever matches services defined in that folder, so the same hostname can be configured differently in different folders, environments, or projects. + + +## Header Rewriting + +Header rewriting adds or replaces an HTTP header on the outbound request. The agent does not need to send any credential; if it sends a made-up one, the header is overwritten with the real value. This covers most APIs: + +| Auth style | Configuration | Resulting header | +| --- | --- | --- | +| Bearer token | Header name `Authorization`, prefix `Bearer`, one secret | `Authorization: Bearer ` | +| API key header | Header name `x-api-key`, no prefix, one secret | `x-api-key: ` | +| Basic auth | Two secrets, one as **Username** and one as **Password** | `Authorization: Basic base64(username:password)` | +| Custom | Any header name and optional prefix | `: ` | + +A service can rewrite multiple distinct headers (for example, an API key header plus a tenant header), but each header name can only be set by one credential. To build one header value out of several secrets, or to use a secret that lives in another folder or environment, create a secret in the service's folder whose value uses [secret references](/documentation/platform/secret-reference) (for example `${prod.shared.API_KEY}`), and point the credential at it. References are resolved to their final value before the proxy applies the credential. + +## Credential Substitution + +With credential substitution, the agent is handed a dummy placeholder value and the agent proxy swaps it for the real credential wherever it appears in the request. It is useful in two situations: + +- The API carries the credential somewhere other than a header. Telegram, for example, puts the bot token in the URL path: `api.telegram.org/bot/sendMessage`. +- The agent has to see a value before it will make the call at all, headers included. Some HTTP clients validate that a credential is set, and agents often refuse to attempt a request without a real-looking key. The placeholder satisfies them, and the proxy swaps in the real value on the way out. + + + The environment variable name the agent receives, such as `TELEGRAM_BOT_TOKEN`. When an agent connects, `infisical secrets agent-proxy connect` sets this variable to the placeholder, so the agent uses it like a normal credential. + + + + The dummy value that appears on the wire. A distinctive random string is generated for you, but you can override it, for example when the agent's HTTP client validates the credential's format. Keep it distinctive so the swap never matches unrelated request content. + + + + The request surfaces the agent proxy scans for the placeholder: `path`, `query`, `header`, and/or `body`. Scoping is the security boundary: the proxy only substitutes in the surfaces you list. + + + + The secret (from the same folder) whose real value replaces the placeholder. + + +A few behaviors worth knowing: + +- Replacement is a literal string swap of every occurrence of the placeholder in the selected surfaces. +- Request bodies are scanned up to 10 MiB; larger bodies, and bodies with a `Content-Encoding` (compressed payloads), are forwarded unchanged without substitution. +- WebSocket traffic is not supported; substitution applies to regular HTTP(S) requests. + + + If a placeholder environment variable has the same name as a regular secret the agent can read, the real secret value wins and the CLI logs a warning. Rename one of the two to avoid the ambiguity. + + +## Secret References + +Credentials reference secrets **by key name, from the same folder as the service**. This keeps the relationship self-healing: + +- At creation time, Infisical validates that each referenced secret exists and that you can read its value. Typos fail immediately instead of silently at proxy time. +- The key is looked up in the folder first, then through the folder's [secret imports](/documentation/platform/secret-reference). A folder-local secret always wins over an imported one with the same key. For imported secrets, read permission is checked where the secret actually lives, so an import cannot widen anyone's access. +- To compose one value out of several secrets, or pull one in without an import, create a secret in this folder whose value uses [secret references](/documentation/platform/secret-reference). References are resolved before the proxy applies the value. +- If a referenced secret is later renamed or deleted, the reference goes stale: the agent proxy logs a warning and skips that credential (requests still go through, just without it). Recreating a secret with the same key restores the credential automatically. +- Deleting the folder deletes the proxied services in it. + + + Creating or updating a proxied service requires **Read Value** permission on each secret it references. This prevents someone with edit access to proxied services from wiring in a secret they are not allowed to read and exfiltrating it through the proxy. + + +## 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: + +```bash +curl -X POST https://app.infisical.com/api/v1/proxied-services \ + -H "Authorization: Bearer $INFISICAL_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "projectId": "", + "environment": "prod", + "secretPath": "/ai-agents", + "name": "telegram-bot-api", + "hostPattern": "api.telegram.org", + "credentials": [ + { + "secretKey": "TELEGRAM_BOT_TOKEN", + "role": "credential-substitution", + "placeholderKey": "TELEGRAM_BOT_TOKEN", + "placeholderValue": "placeholder_tg_bot", + "substitutionSurfaces": ["path"] + } + ] + }' +``` + +Header-rewrite credentials use `"role": "header-rewrite"` with `headerName` and an optional `headerPrefix`, or `headerPurpose` (`username`/`password`) for basic auth. diff --git a/docs/documentation/platform/agent-proxy/quickstart.mdx b/docs/documentation/platform/agent-proxy/quickstart.mdx new file mode 100644 index 00000000000..aca99fa7e61 --- /dev/null +++ b/docs/documentation/platform/agent-proxy/quickstart.mdx @@ -0,0 +1,117 @@ +--- +title: "Agent Proxy Quickstart" +sidebarTitle: "Quickstart" +description: "Broker your first credential to an agent through the Infisical Agent Proxy." +--- + +This guide walks through brokering a credential end to end: you will create a proxied service, start an agent proxy, and launch an agent whose API calls get real credentials applied on the wire, without the agent ever holding them. + +## Prerequisites + +- An Infisical project (Secrets Management) with the secrets you want to broker. +- The [Infisical CLI](/cli/overview) installed on the machines that will run the agent proxy and the agent. + + + + In your project, pick or create a folder for your agent workloads (for example `/ai-agents` in the `prod` environment) and add the secrets the agents will need, such as `STRIPE_API_KEY`. These are regular Infisical secrets; nothing about them changes when they are brokered. + + + In the same folder, click the dropdown arrow next to **Add Secret** and select **Add Proxied Service**. + + + A slug that identifies the service, such as `stripe-api`. + + + The host(s) this service applies to, such as `api.stripe.com`. See [host patterns](/documentation/platform/agent-proxy/proxied-services#host-patterns) for wildcards, ports, and paths. + + + Under **Header Rewriting**, keep the pre-filled `Authorization` header with the `Bearer` prefix and select `STRIPE_API_KEY` as the value. This tells the agent proxy to set `Authorization: Bearer ` on every request to `api.stripe.com`. + + See [Proxied Services](/documentation/platform/agent-proxy/proxied-services) for basic auth, custom headers, and credential substitution. + + + Create two [machine identities](/documentation/platform/identities/machine-identities) with [Universal Auth](/documentation/platform/identities/universal-auth) and add both to your project: + + 1. **Agent proxy identity**: assign it the built-in **Agent Proxy** role. It fetches the real credential values, so it needs read access to the referenced secrets. + 2. **Agent identity**: assign it the built-in **Agent** role. It can route traffic through proxied services but cannot read any secret values. + + + The built-in roles grant broad access across all environments and paths for a quick start. For production, consider [custom roles](/documentation/platform/access-controls/role-based-access-controls) scoped to specific environments and paths. + + + + On the machine that will run the agent proxy (ideally in the same private network as your agents, but not on the same machine), authenticate with the agent proxy identity and start it: + + ```bash + export INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= + export INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= + + infisical secrets agent-proxy start + ``` + + The proxy authenticates to Infisical, sets up its certificate chain, and listens on port `17322` by default. See the [CLI reference](/cli/commands/agent-proxy) for flags such as `--port` and `--unmatched-host`. + + + If you are self-hosting Infisical or using EU Cloud, point the CLI at your instance with `--domain` or the `INFISICAL_DOMAIN` environment variable (for example `https://eu.infisical.com`). + + + + On the agent machine, authenticate with the agent identity and launch the agent through the `connect` wrapper. Everything after `--` is the agent's own start command: + + ```bash + export INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= + export INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= + ``` + + + + ```bash + infisical secrets agent-proxy connect --proxy=:17322 \ + --projectId= --env=prod --path=/ai-agents -- claude + ``` + + + ```bash + infisical secrets agent-proxy connect --proxy=:17322 \ + --projectId= --env=prod --path=/ai-agents -- codex + ``` + + + ```bash + infisical secrets agent-proxy connect --proxy=:17322 \ + --projectId= --env=prod --path=/ai-agents -- + ``` + + + + Before starting the agent, the wrapper: + + - Routes the agent's traffic through the agent proxy (`HTTPS_PROXY`, `HTTP_PROXY`). + - Downloads your organization's root CA certificate and points the common trust variables (`SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and others) at it, so the agent's HTTP clients accept the proxy's certificates. + - Sets dummy placeholder environment variables for any credential-substitution services. + - Sets environment variables with real values for regular secrets the agent identity has **Read Value** on in the folder (including secrets imported into it), similar to `infisical run`. + + + Real values in the agent's environment are deliberate and opt-in. The built-in Agent role grants no secret read access, so nothing real lands in the environment unless an admin explicitly grants **Read Value** on specific secrets. Brokered credentials never appear in the agent's environment; the proxy only attaches them on the wire. + + + The project can also be set via `INFISICAL_PROJECT_ID` or an `.infisical.json` file (created by `infisical init`) instead of `--projectId`. + + + You can watch the proxy attach a credential by calling an echo service that reflects request headers back. Add a secret `TEST_API_KEY` to the folder, create a proxied service for it (host pattern `postman-echo.com`, header `Authorization` with prefix `Bearer`, secret `TEST_API_KEY`), then run a plain `curl` through the wrapper: + + ```bash + infisical secrets agent-proxy connect --proxy=:17322 \ + --projectId= --env=prod --path=/ai-agents -- \ + curl https://postman-echo.com/headers + ``` + + The echoed headers include `"authorization": "Bearer "`. `curl` sent no credential; the proxy attached it on the way out. The same happens to `api.stripe.com` with the service from step 2. Requests to hosts with no proxied service are forwarded untouched by default (see [unmatched hosts](/documentation/platform/agent-proxy/security#unmatched-hosts)). + + + +## Next Steps + +- Configure [host patterns, header rewriting, and credential substitution](/documentation/platform/agent-proxy/proxied-services) in depth. +- Understand the [isolation and trust model](/documentation/platform/agent-proxy/security) behind the proxy. +- Browse every flag in the [CLI reference](/cli/commands/agent-proxy). diff --git a/docs/documentation/platform/agent-proxy/security.mdx b/docs/documentation/platform/agent-proxy/security.mdx new file mode 100644 index 00000000000..3d43c490d89 --- /dev/null +++ b/docs/documentation/platform/agent-proxy/security.mdx @@ -0,0 +1,115 @@ +--- +title: "Security & Architecture" +description: "How the agent proxy isolates agents, manages trust, and stays available." +--- + +This page covers what is happening under the hood of the agent proxy: how every request is authenticated, how agents stay isolated from each other and from real credentials, how TLS interception works, and how the proxy behaves at scale. You do not need any of this to get started (see the [Quickstart](/documentation/platform/agent-proxy/quickstart)), but it is worth reading before running the proxy in production. + +## Isolation Model + +The proxy has no built-in scope: no project, environment, or services are configured on it. It discovers everything per agent. When an agent connects, the proxy uses the agent's token to look up which proxied services that agent may use in the agent's folder scope, then fetches the real secret values with its own machine identity. This means one proxy instance can serve many agents across projects and environments while keeping them isolated: an agent can never receive credentials it was not granted **Proxy** access to, even on a shared proxy. + +Deploy the proxy in the same private network as your agents to keep per-request latency low, but on a **separate machine**. Running it on a different host is what guarantees untrusted code cannot reach into the proxy's memory and read the real credentials it holds. + +## Agent Authentication + +The security boundary of the agent proxy is **identity**, not certificates. Certificates only make TLS interception possible; what an agent can reach is decided entirely by machine identity permissions: + +- Every proxied request carries the agent's short-lived machine identity token. A request without a valid token is rejected (`407`) before anything else happens. +- The proxy grants nothing on its own. For each agent it asks Infisical which proxied services that agent's identity holds the **Proxy** permission on, within the agent's exact project, environment, and folder scope. Credentials are applied only for those services; everything else does not exist as far as that agent is concerned. +- The agent's identity cannot read secret values. Only the proxy's own identity can, and it lives on a separate machine. Even if an agent is tricked into leaking its token, that token grants no ability to fetch a secret; it can only route traffic through the same services the agent could already reach. +- Authorization is re-checked continuously. The proxy re-validates each active agent's permissions every poll interval (60 seconds by default) and fails closed: revoke the identity, its role, or its Proxy grant, and the proxy drops the cached credentials and stops applying them. + +## Connections + +Agents do not talk to the proxy directly; they are launched through `infisical secrets agent-proxy connect -- `, which prepares the environment. Each proxied request carries the agent's Infisical token and folder scope in the standard proxy authorization mechanism, which is how the proxy knows which agent is asking and which folder's services apply. + +A few connection-level behaviors to be aware of: + +- Both HTTPS and plain-HTTP services are brokered. HTTPS traffic arrives as standard `CONNECT` tunnels; plain `http://` traffic arrives as regular forward-proxy requests (useful for internal services without TLS). Requests for `https://` URLs sent as plain forward-proxy requests are rejected so the proxy can never be used to downgrade TLS. +- `NO_PROXY` in the agent's environment always includes `localhost,127.0.0.1`, so local traffic bypasses the proxy. Additional bypass hosts can be added via the `--no-proxy` flag on `connect` or an existing `NO_PROXY` variable. +- Requests from an agent whose token has expired or been revoked fail closed: the proxy drops that agent's cached credentials as soon as it notices, and stops applying them. + +## Certificates & TLS Interception + +For the proxy to read and modify HTTPS requests, agents must trust the certificates it presents. This is trust plumbing rather than access control: the chain exists so the proxy can open TLS traffic that agents deliberately send it, and holding a certificate grants no access to any secret. It has three tiers, and the sensitive part never leaves Infisical: + +```mermaid +flowchart LR + A["Root CA
(in Infisical, per org)"] -->|signs| B["Intermediate CA
(in agent proxy memory)"] + B -->|signs| C["Leaf certificates
(one per hostname)"] +``` + +The root CA is generated automatically per organization and stored encrypted in Infisical; its private key never leaves the server, and all signing happens server-side. At startup, the proxy generates a keypair locally and has Infisical sign it into a short-lived intermediate certificate (7 days, re-signed automatically before expiry) that can mint leaf certificates but no further CAs. Leaf certificates (valid 24 hours, cached in memory) are minted locally per hostname, for the exact hostname the agent requested, with no Infisical round-trip. + +On the agent machine, the `connect` wrapper downloads the root CA to `~/.infisical/agent-proxy/mitm-ca.pem` and points the standard trust environment variables (`SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `DENO_CERT`) at it. The proxy's connection to the real service is standard HTTPS with normal certificate verification, so real credentials always travel encrypted. + +Because the root CA is shared across your organization, treat trusting it as an org-level decision: any machine identity in the org can obtain an intermediate signed by it. What each agent can actually access is still governed by [agent authentication](#agent-authentication), never by the certificate chain. + + + The initial `CONNECT` handshake between the agent and the proxy, including the agent's token, travels in plaintext (the tunneled request content itself is encrypted). Requests to plain-HTTP services travel entirely in plaintext, including any credentials the proxy attaches. Run the proxy, agents, and any plain-HTTP upstream services within a trusted private network. + + +## Permissions + +Proxied services have their own project-level permission subject, so you can control who manages them and which identities can route traffic through them. + +| Action | Description | Admin | Member | Viewer | +| --- | --- | --- | --- | --- | +| **Read** | View proxied services and their configuration | Yes | Yes | Yes | +| **Create** | Create new proxied services | Yes | No | No | +| **Edit** | Update host patterns, credentials, or enable/disable | Yes | No | No | +| **Delete** | Delete proxied services | Yes | No | No | +| **Proxy** | Route traffic through the service and have credentials applied | Yes | No | No | + +The **Proxy** action is intended for agent machine identities, not humans. An identity with Proxy on a service gets the service's credentials applied to its traffic without ever being able to read the secret values. + +### Built-in Roles + +Two built-in project roles make setup quick. Both grant broad access across all environments and paths; create a [custom role](/documentation/platform/access-controls/role-based-access-controls) instead if you need tighter scoping. + +| Role | Slug | Intended for | What it grants | +| --- | --- | --- | --- | +| **Agent** | `agent` | The agent machine identity | Proxy on all proxied services. No secret read access. | +| **Agent Proxy** | `agent-proxy` | The agent proxy machine identity | Read access to all secret values, so it can fetch the real credentials it applies. No proxied service permissions. | + + + The Agent role intentionally has 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. + + +## Caching and Polling + +The proxy keeps everything it needs in memory, so steady-state requests involve no Infisical calls: + +- **First request from an agent**: the proxy discovers all proxied services in the agent's scope and fetches the referenced secret values. This first request is slightly slower; every subsequent request from that agent, to any host, is served from cache. +- **Refresh**: every 60 seconds (configurable via `--poll-interval`), the proxy re-fetches services, permissions, and secret values for active agents. Changes such as a rotated secret, an edited service, or a revoked permission take effect within one poll interval, with no agent restart. +- **Eviction**: an agent idle for about 10 minutes has its cache dropped and polling stopped. Its next request simply triggers a fresh discovery. + +## Unmatched Hosts + +When an agent requests a host no proxied service matches, the `--unmatched-host` flag decides what happens: + +- `allow` (default): the request is forwarded untouched, with no credentials applied. This is the normal mode, because much of an agent's traffic does not need a brokered credential at all: reading documentation, cloning public repos, installing packages, or calling services the agent legitimately authenticates to itself (for example, with a real secret in its environment because its identity has **Read Value** on it). All of that flows through untouched, while matched hosts still get credentials applied. +- `block`: the request is rejected with `403`. Use this to restrict agents to an allowlist of exactly the services you have defined. + + + `block` applies to every host without a matching proxied service, including your Infisical instance itself. Since agent traffic routes through the proxy, Infisical CLI commands run from inside the agent (using the `INFISICAL_TOKEN` from its environment) will also be rejected in this mode. + + +## High Availability + +Run multiple proxy instances with the same machine identity behind a TCP load balancer. Instances coordinate through Infisical rather than with each other: + +- All instances chain to the same per-organization root CA, so agents trust any instance. Each instance signs its own intermediate. +- Each instance keeps its own agent cache. If the load balancer moves an agent to a new instance, that instance does one fresh discovery and then serves from cache. +- Each instance polls Infisical independently. After a secret rotation, instances may briefly apply different values until their next poll (up to one poll interval). + +## Troubleshooting + +| Symptom | Meaning | +| --- | --- | +| `403` on a request | The proxy runs with `--unmatched-host=block` and the host matched no proxied service in the agent's scope (disabled services do not match either). | +| `407 Proxy Authentication Required` | The request reached the proxy without agent credentials. Launch the agent through `infisical secrets agent-proxy connect` rather than pointing `HTTPS_PROXY` at the proxy manually. | +| `502` on a request | The proxy could not reach the upstream service, or could not resolve the agent's permissions (for example, an expired agent token). | +| Certificate errors in the agent | The agent's HTTP client is not picking up the CA trust variables. Most standard clients (curl, Node.js, Python requests) do; check how your client is configured to trust custom CAs. | +| `proxied service ... references missing secret` in proxy logs | A referenced secret was renamed or deleted after the service was created. The credential is skipped until the reference is fixed or the secret is recreated with the same key. | From 3cfb964a7a6b62dd6310234a3617c4b462e762ee Mon Sep 17 00:00:00 2001 From: Saif Date: Sat, 11 Jul 2026 20:18:24 +0530 Subject: [PATCH 31/43] docs: refine agent proxy docs Simplify language, add network placement and identity-count guidance, document import/reference reuse patterns, and align tone across pages. --- docs/cli/commands/agent-proxy.mdx | 2 +- .../platform/agent-proxy/overview.mdx | 4 +- .../platform/agent-proxy/proxied-services.mdx | 33 +++++++---- .../platform/agent-proxy/quickstart.mdx | 2 +- .../platform/agent-proxy/security.mdx | 55 +++++++++++++------ 5 files changed, 63 insertions(+), 33 deletions(-) diff --git a/docs/cli/commands/agent-proxy.mdx b/docs/cli/commands/agent-proxy.mdx index 12a4ecc9e8d..a588b54df65 100644 --- a/docs/cli/commands/agent-proxy.mdx +++ b/docs/cli/commands/agent-proxy.mdx @@ -124,7 +124,7 @@ $ infisical secrets agent-proxy start --port 17322 --unmatched-host=block - Real values for regular secrets the agent has **Read Value** on in the scoped folder, including secrets imported into it (similar to `infisical run`). This is opt-in; the built-in Agent role grants no read access, and brokered credentials never appear in the agent's environment. - `INFISICAL_TOKEN` set to the agent's access token, so the agent can run Infisical CLI commands itself. - The machine identity credentials (client ID, client secret, and any access token) are stripped from the child environment. The wrapper forwards signals to the agent process and exits with its exit code. + The client ID and client secret used to authenticate are stripped from the child environment. The wrapper forwards signals to the agent process and exits with its exit code. ```bash $ infisical secrets agent-proxy connect --proxy=: --env= -- [agent start command] diff --git a/docs/documentation/platform/agent-proxy/overview.mdx b/docs/documentation/platform/agent-proxy/overview.mdx index bb234f0adda..75e11e9825d 100644 --- a/docs/documentation/platform/agent-proxy/overview.mdx +++ b/docs/documentation/platform/agent-proxy/overview.mdx @@ -32,7 +32,7 @@ flowchart LR 1. **Keep your secrets in Infisical**, exactly as you do today. Next to them, define [proxied services](/documentation/platform/agent-proxy/proxied-services): small configs that say, for example, "requests to `api.stripe.com` get `STRIPE_API_KEY` as a Bearer token". 2. **Run the agent proxy** with one command: `infisical secrets agent-proxy start`. -3. **Launch your agent through the wrapper**: `infisical secrets agent-proxy connect -- claude`. The wrapper points the agent's traffic at the proxy and starts it. The agent has no real credentials anywhere in its environment, and no idea any of this is happening. +3. **Launch your agent through the wrapper**: `infisical secrets agent-proxy connect -- claude`. The wrapper points the agent's traffic at the proxy and starts it. The agent holds none of the brokered credentials, and has no idea any of this is happening. Now the agent calls `api.stripe.com`. The proxy adds the real `STRIPE_API_KEY` and forwards the request. Stripe sees a normal authenticated call. @@ -51,7 +51,7 @@ Standalone credential proxies manage their own keys, users, and permissions in i Require a review before a brokered credential changes, exactly like a code change. - Every create, update, and delete of a proxied service is logged with who did it and which secrets it references. + A complete trail of everything around your proxied services, from configuration changes to activity. Scope each agent identity to exactly the environments, paths, and services it should reach. diff --git a/docs/documentation/platform/agent-proxy/proxied-services.mdx b/docs/documentation/platform/agent-proxy/proxied-services.mdx index 877f66ab8a8..5914a53f6cf 100644 --- a/docs/documentation/platform/agent-proxy/proxied-services.mdx +++ b/docs/documentation/platform/agent-proxy/proxied-services.mdx @@ -3,7 +3,7 @@ title: "Proxied Services" description: "Define how the agent proxy applies credentials to traffic bound for an external service." --- -A proxied service tells the agent proxy how to handle traffic to a specific external service. It lives in a folder alongside your secrets, references those secrets by key name, and defines which hosts it applies to and how each credential is placed into the outbound request. +A proxied service tells the agent proxy how to handle traffic to a specific external service. It lives in a folder alongside your secrets, references those secrets by key name, and defines which hosts it applies to and how each secret is placed into the outbound request. You manage proxied services in the secrets dashboard: open a folder, click the dropdown arrow next to **Add Secret**, and select **Add Proxied Service**. They appear in the folder view next to your secrets. @@ -21,7 +21,7 @@ You manage proxied services in the secrets dashboard: open a folder, click the d Toggle a service off without deleting it. A disabled service is skipped entirely: its placeholder environment variables are not set, no credentials are applied, and traffic to its hosts falls back to the [unmatched host policy](/documentation/platform/agent-proxy/security#unmatched-hosts). -A service then defines one or more credentials in two sections, **Header Rewriting** and **Credential Substitution**. Both are independent; use either or both. +A service then applies one or more secrets in two ways, **Header Rewriting** and **Credential Substitution**. Both are independent; use either or both. ## Host Patterns @@ -64,7 +64,7 @@ Header rewriting adds or replaces an HTTP header on the outbound request. The ag | Basic auth | Two secrets, one as **Username** and one as **Password** | `Authorization: Basic base64(username:password)` | | Custom | Any header name and optional prefix | `: ` | -A service can rewrite multiple distinct headers (for example, an API key header plus a tenant header), but each header name can only be set by one credential. To build one header value out of several secrets, or to use a secret that lives in another folder or environment, create a secret in the service's folder whose value uses [secret references](/documentation/platform/secret-reference) (for example `${prod.shared.API_KEY}`), and point the credential at it. References are resolved to their final value before the proxy applies the credential. +A service can rewrite multiple distinct headers (for example, an API key header plus a tenant header), but each header name can only be set by one secret. To build one header value out of several secrets, or use a secret kept elsewhere, see [reusing secrets from other folders and environments](#reusing-secrets-from-other-folders-and-environments). ## Credential Substitution @@ -78,7 +78,7 @@ With credential substitution, the agent is handed a dummy placeholder value and - The dummy value that appears on the wire. A distinctive random string is generated for you, but you can override it, for example when the agent's HTTP client validates the credential's format. Keep it distinctive so the swap never matches unrelated request content. + The dummy value that appears on the wire. A distinctive random string is generated for you, but you can override it, for example when the agent's HTTP client validates the credential's format. @@ -99,20 +99,31 @@ A few behaviors worth knowing: If a placeholder environment variable has the same name as a regular secret the agent can read, the real secret value wins and the CLI logs a warning. Rename one of the two to avoid the ambiguity. -## Secret References +## Using Secrets -Credentials reference secrets **by key name, from the same folder as the service**. This keeps the relationship self-healing: +A proxied service uses secrets **by key name, from its own folder**. This keeps the relationship self-healing: -- At creation time, Infisical validates that each referenced secret exists and that you can read its value. Typos fail immediately instead of silently at proxy time. -- The key is looked up in the folder first, then through the folder's [secret imports](/documentation/platform/secret-reference). A folder-local secret always wins over an imported one with the same key. For imported secrets, read permission is checked where the secret actually lives, so an import cannot widen anyone's access. -- To compose one value out of several secrets, or pull one in without an import, create a secret in this folder whose value uses [secret references](/documentation/platform/secret-reference). References are resolved before the proxy applies the value. -- If a referenced secret is later renamed or deleted, the reference goes stale: the agent proxy logs a warning and skips that credential (requests still go through, just without it). Recreating a secret with the same key restores the credential automatically. +- At creation time, Infisical validates that each secret exists and that you can read its value, so typos are caught the moment you save. +- If a secret is later renamed or deleted, the proxied service skips it (the agent proxy logs a warning and the request still goes through, just without that value). Recreating the secret with the same key restores it automatically. - Deleting the folder deletes the proxied services in it. - Creating or updating a proxied service requires **Read Value** permission on each secret it references. This prevents someone with edit access to proxied services from wiring in a secret they are not allowed to read and exfiltrating it through the proxy. + Creating or updating a proxied service requires **Read Value** permission on each secret it uses. This prevents someone with edit access to proxied services from wiring in a secret they are not allowed to read and exfiltrating it through the proxy. +### Reusing secrets from other folders and environments + +You cannot point a proxied service at another folder directly, but you can bring an outside value into its folder so the service can use it. There are two ways, both using features that already exist in Infisical: + +| Approach | What you do | Example | +| --- | --- | --- | +| **Imported secret** | [Import](/documentation/platform/secret-reference) another folder into the service's folder, then use the imported key by name | Import `/shared-creds`, then use its `SHARED_API_KEY` from the service's folder | +| **Composed with references** | Create a secret whose value is one or more [secret references](/documentation/platform/secret-reference), then use that secret. Lets you combine several secrets, including ones from different environments | A secret set to `${dev.gateway.KEY}.${staging.gateway.KEY}.${prod.gateway.KEY}` resolves to the three values joined together | + +A folder-local secret always wins over an imported one with the same key. Read permission on an imported secret is checked where it actually lives, so an import cannot widen anyone's access. + +Either way, the value is fully resolved before the proxy applies it. + ## 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: diff --git a/docs/documentation/platform/agent-proxy/quickstart.mdx b/docs/documentation/platform/agent-proxy/quickstart.mdx index aca99fa7e61..75e7619ec80 100644 --- a/docs/documentation/platform/agent-proxy/quickstart.mdx +++ b/docs/documentation/platform/agent-proxy/quickstart.mdx @@ -36,7 +36,7 @@ This guide walks through brokering a credential end to end: you will create a pr 2. **Agent identity**: assign it the built-in **Agent** role. It can route traffic through proxied services but cannot read any secret values. - The built-in roles grant broad access across all environments and paths for a quick start. For production, consider [custom roles](/documentation/platform/access-controls/role-based-access-controls) scoped to specific environments and paths. + The built-in roles grant broad access across all environments and paths for a quick start. For production, consider [custom roles](/documentation/platform/access-controls/role-based-access-controls) scoped to specific environments and paths. Running more than one agent? See [how many machine identities you need](/documentation/platform/agent-proxy/security#how-many-machine-identities-do-you-need). diff --git a/docs/documentation/platform/agent-proxy/security.mdx b/docs/documentation/platform/agent-proxy/security.mdx index 3d43c490d89..a31423de35a 100644 --- a/docs/documentation/platform/agent-proxy/security.mdx +++ b/docs/documentation/platform/agent-proxy/security.mdx @@ -7,9 +7,19 @@ This page covers what is happening under the hood of the agent proxy: how every ## Isolation Model -The proxy has no built-in scope: no project, environment, or services are configured on it. It discovers everything per agent. When an agent connects, the proxy uses the agent's token to look up which proxied services that agent may use in the agent's folder scope, then fetches the real secret values with its own machine identity. This means one proxy instance can serve many agents across projects and environments while keeping them isolated: an agent can never receive credentials it was not granted **Proxy** access to, even on a shared proxy. +The proxy is pinned to one organization, the one its own machine identity belongs to, but nothing narrower: no project, environment, path, or services are configured on it. It discovers those per agent. When an agent connects, the proxy uses the agent's token to look up which proxied services that agent may use in the agent's folder scope, then fetches the real secret values with its own machine identity. This means one proxy instance can serve many agents across projects and environments in the org while keeping them isolated: an agent can never receive credentials it was not granted **Proxy** access to, even on a shared proxy. -Deploy the proxy in the same private network as your agents to keep per-request latency low, but on a **separate machine**. Running it on a different host is what guarantees untrusted code cannot reach into the proxy's memory and read the real credentials it holds. +## Network Placement + +The agent proxy is built for private-network deployment. A well-placed proxy looks like this: + +- **Inside your private network, off the public internet.** The proxy's listening port should only ever be reachable from within your network. +- **Reachable only by your agent machines.** Restrict inbound access to the proxy port to the hosts that run agents, using your usual controls (security groups, firewall rules, network policies). +- **On its own host.** Same network as the agents for low per-request latency, but a separate machine, which keeps the real credentials fully isolated from the agents. +- **Alongside any plain-HTTP upstreams.** Internal services the proxy reaches over plain HTTP belong inside the same trusted network. +- **Credentials where they are used.** Provision the proxy identity's client credentials only on the proxy host, and each agent identity's only on its agent machine. + +Outbound, the proxy only needs to reach your Infisical instance and the APIs your proxied services define. ## Agent Authentication @@ -44,11 +54,9 @@ The root CA is generated automatically per organization and stored encrypted in On the agent machine, the `connect` wrapper downloads the root CA to `~/.infisical/agent-proxy/mitm-ca.pem` and points the standard trust environment variables (`SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `DENO_CERT`) at it. The proxy's connection to the real service is standard HTTPS with normal certificate verification, so real credentials always travel encrypted. -Because the root CA is shared across your organization, treat trusting it as an org-level decision: any machine identity in the org can obtain an intermediate signed by it. What each agent can actually access is still governed by [agent authentication](#agent-authentication), never by the certificate chain. - - - The initial `CONNECT` handshake between the agent and the proxy, including the agent's token, travels in plaintext (the tunneled request content itself is encrypted). Requests to plain-HTTP services travel entirely in plaintext, including any credentials the proxy attaches. Run the proxy, agents, and any plain-HTTP upstream services within a trusted private network. - + + Place the proxy according to the [network placement](#network-placement) guidance above; it is built for private-network deployment. + ## Permissions @@ -58,7 +66,7 @@ Proxied services have their own project-level permission subject, so you can con | --- | --- | --- | --- | --- | | **Read** | View proxied services and their configuration | Yes | Yes | Yes | | **Create** | Create new proxied services | Yes | No | No | -| **Edit** | Update host patterns, credentials, or enable/disable | Yes | No | No | +| **Edit** | Update a service's host patterns, secrets, or enabled state | Yes | No | No | | **Delete** | Delete proxied services | Yes | No | No | | **Proxy** | Route traffic through the service and have credentials applied | Yes | No | No | @@ -77,6 +85,26 @@ Two built-in project roles make setup quick. Both grant broad access across all The Agent role intentionally has 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. +### Custom Roles + +The built-in roles grant the right permissions but across all environments and paths. To scope tighter, build a [custom role](/documentation/platform/access-controls/role-based-access-controls) instead. This is the minimum each identity needs: + +| 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. | + +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. + +### 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: + +- **One for the agent proxy.** A single proxy identity serves any number of agents, and multiple proxy instances behind a load balancer share the same one. +- **One per distinct agent scope.** Agents that should have exactly the same access can share an identity: a fleet of interchangeable workers doing the same job is one scope, so one identity is fine. Agents that reach different services, environments, or paths each get their own. + +Sharing one identity between agents that need *different* access also works, but it means granting that identity the union of everything any of them needs: each agent then carries access it does not use, a compromised agent exposes the whole union, revoking the identity cuts off every agent at once, and audit logs cannot tell the agents apart. + ## Caching and Polling The proxy keeps everything it needs in memory, so steady-state requests involve no Infisical calls: @@ -84,6 +112,7 @@ The proxy keeps everything it needs in memory, so steady-state requests involve - **First request from an agent**: the proxy discovers all proxied services in the agent's scope and fetches the referenced secret values. This first request is slightly slower; every subsequent request from that agent, to any host, is served from cache. - **Refresh**: every 60 seconds (configurable via `--poll-interval`), the proxy re-fetches services, permissions, and secret values for active agents. Changes such as a rotated secret, an edited service, or a revoked permission take effect within one poll interval, with no agent restart. - **Eviction**: an agent idle for about 10 minutes has its cache dropped and polling stopped. Its next request simply triggers a fresh discovery. +- **API load**: polling grows with the number of active agents. With a large fleet or a short `--poll-interval`, factor in your instance's API rate limits; a longer interval reduces load at the cost of slower propagation. ## Unmatched Hosts @@ -103,13 +132,3 @@ Run multiple proxy instances with the same machine identity behind a TCP load ba - All instances chain to the same per-organization root CA, so agents trust any instance. Each instance signs its own intermediate. - Each instance keeps its own agent cache. If the load balancer moves an agent to a new instance, that instance does one fresh discovery and then serves from cache. - Each instance polls Infisical independently. After a secret rotation, instances may briefly apply different values until their next poll (up to one poll interval). - -## Troubleshooting - -| Symptom | Meaning | -| --- | --- | -| `403` on a request | The proxy runs with `--unmatched-host=block` and the host matched no proxied service in the agent's scope (disabled services do not match either). | -| `407 Proxy Authentication Required` | The request reached the proxy without agent credentials. Launch the agent through `infisical secrets agent-proxy connect` rather than pointing `HTTPS_PROXY` at the proxy manually. | -| `502` on a request | The proxy could not reach the upstream service, or could not resolve the agent's permissions (for example, an expired agent token). | -| Certificate errors in the agent | The agent's HTTP client is not picking up the CA trust variables. Most standard clients (curl, Node.js, Python requests) do; check how your client is configured to trust custom CAs. | -| `proxied service ... references missing secret` in proxy logs | A referenced secret was renamed or deleted after the service was created. The credential is skipped until the reference is fixed or the secret is recreated with the same key. | From 802e5114d810cf41f996253bc6e6689dbdfe62f4 Mon Sep 17 00:00:00 2001 From: Saif Date: Mon, 13 Jul 2026 13:27:34 +0530 Subject: [PATCH 32/43] feat: accept IPv6 proxied service host patterns and add host pattern tests --- .../proxied-service-schemas.test.ts | 122 ++++++++++++++++++ .../proxied-service-schemas.ts | 29 ++++- 2 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 backend/src/ee/services/proxied-service/proxied-service-schemas.test.ts 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 new file mode 100644 index 00000000000..35fec81fd12 --- /dev/null +++ b/backend/src/ee/services/proxied-service/proxied-service-schemas.test.ts @@ -0,0 +1,122 @@ +import { 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. +const parse = (value: string) => hostPatternSchema.safeParse(value); +const firstError = (value: string) => { + const result = parse(value); + return result.success ? undefined : result.error.issues[0]?.message; +}; + +describe("hostPatternSchema", () => { + describe("string-level constraints", () => { + it("rejects an empty string", () => { + expect(parse("").success).toBe(false); + expect(firstError("")).toBe("Host pattern is required"); + }); + + it("rejects a whitespace-only string", () => { + expect(parse(" ").success).toBe(false); + }); + + it("accepts a long pattern within the 255-char limit", () => { + expect(parse(`${"a".repeat(240)}.example.com`).success).toBe(true); + }); + + it("rejects a pattern longer than 255 chars", () => { + expect(parse(`${"a".repeat(300)}.com`).success).toBe(false); + }); + }); + + describe("valid patterns", () => { + it.each([ + "api.stripe.com", // exact host + "localhost", // single label + "*.github.com", // wildcard (one subdomain level) + "127.0.0.1", // IPv4 + "10.0.0.1", // private IPv4 + "my-api.example.com", // internal hyphen + "api2.example.com", // digits in label + "API.Stripe.COM", // case-insensitive + "api.stripe.com:443", // with port + "api.stripe.com:1", // min port + "127.0.0.1:65535", // max port + "api.stripe.com/v1/*", // with path + "internal.corp.com:3000/api/*", // port + path + "api.stripe.com/weird!!/path", // path content is free-form + "api.stripe.com, dashboard.stripe.com", // multiple, comma-separated + "*.stripe.com, *.github.com", // multiple wildcards + "*.stripe.com:443/v1/*", // wildcard + port + path + " api.stripe.com , b.example.com ", // segment whitespace trimmed + "[::1]", // bracketed IPv6 + "[2001:db8::1]:8443", // bracketed IPv6 + port + "[fe80::1]/v1/*", // bracketed IPv6 + path + "api.stripe.com:07" // leading-zero port is accepted + ])("accepts %s", (value) => { + expect(parse(value).success).toBe(true); + }); + }); + + describe("invalid patterns", () => { + it.each([ + "-api.com", // leading hyphen + "api-.com", // trailing hyphen + "api_v1.com", // underscore + "api..com", // empty label + "api.com.", // trailing dot + "*", // bare wildcard + "*api.com", // wildcard not followed by dot + "a*.com", // mid-label wildcard + "*.*.com", // wildcard label + ":443", // empty host + "/v1/*", // path only, empty host + "api b.com", // internal space + "api.stripe.com:0", // port below range + "api.stripe.com:65536", // port above range + "api.stripe.com:abc", // non-numeric port + "api.stripe.com:-1", // negative port + "api.stripe.com:", // empty port + "::1", // bare (unbracketed) IPv6 + "[::1", // unclosed bracket + "[not-an-ip]", // brackets without a valid IPv6 + "[::1]:70000", // bracketed IPv6 with out-of-range port + "[::1]foo", // garbage after the closing bracket (not a :port) + "[::1]:abc", // non-numeric port after bracket + "api.stripe.com:443:8080", // double colon / port + "[127.0.0.1]", // IPv4 inside brackets (brackets are IPv6-only) + ",api.stripe.com" // leading comma produces an empty entry + ])("rejects %s", (value) => { + expect(parse(value).success).toBe(false); + }); + }); + + describe("reports the specific failing rule", () => { + it("empty entry (trailing comma)", () => { + expect(firstError("api.stripe.com,")).toBe("Host pattern has an empty entry"); + }); + + it("empty entry (double comma)", () => { + expect(firstError("api.stripe.com,,b.com")).toBe("Host pattern has an empty entry"); + }); + + it("scheme included", () => { + expect(firstError("https://api.stripe.com")).toContain("must not include a scheme"); + }); + + it("invalid port", () => { + expect(firstError("api.stripe.com:abc")).toContain("has an invalid port"); + }); + + it("invalid hostname", () => { + expect(firstError("api.stripe.com, bad_host")).toContain("is not a valid host pattern"); + }); + + it("unclosed IPv6 bracket", () => { + expect(firstError("[::1")).toContain("unclosed IPv6 bracket"); + }); + + it("invalid IPv6 address", () => { + expect(firstError("[not-an-ip]")).toContain("is not a valid IPv6 address"); + }); + }); +}); 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 4a18d4816bb..8f05845882e 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-schemas.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-schemas.ts @@ -12,6 +12,12 @@ import { const HOST_LABELS_RE = new RE2(/^(?:\*\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/i); const PORT_RE = new RE2(/^\d+$/); +const IPV6_SCHEMA = z.string().ip({ version: "v6" }); + +const isValidPort = (portStr: string) => { + const port = Number(portStr); + return PORT_RE.test(portStr) && port >= 1 && port <= 65535; +}; // matching grammar lives in the agent-proxy CLI (packages/agentproxy/match.go); keep the two in sync export const hostPatternSchema = z @@ -33,13 +39,30 @@ export const hostPatternSchema = z let hostPort = seg; const slashIdx = hostPort.indexOf("/"); if (slashIdx !== -1) hostPort = hostPort.slice(0, slashIdx); + + // bracketed IPv6 (e.g. [::1] or [2001:db8::1]:8443); brackets disambiguate the port colon + if (hostPort.startsWith("[")) { + const closingIdx = hostPort.indexOf("]"); + if (closingIdx === -1) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `"${seg}" has an unclosed IPv6 bracket` }); + return; + } + if (!IPV6_SCHEMA.safeParse(hostPort.slice(1, closingIdx)).success) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `"${seg}" is not a valid IPv6 address` }); + return; + } + const afterBracket = hostPort.slice(closingIdx + 1); + if (afterBracket && (!afterBracket.startsWith(":") || !isValidPort(afterBracket.slice(1)))) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `"${seg}" has an invalid port` }); + } + return; + } + let host = hostPort; const colonIdx = hostPort.lastIndexOf(":"); if (colonIdx !== -1) { - const portStr = hostPort.slice(colonIdx + 1); host = hostPort.slice(0, colonIdx); - const port = Number(portStr); - if (!PORT_RE.test(portStr) || port < 1 || port > 65535) { + if (!isValidPort(hostPort.slice(colonIdx + 1))) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `"${seg}" has an invalid port` }); return; } From 84b6e8f82a2df3b57999445dc02703a1cf90409e Mon Sep 17 00:00:00 2001 From: Saif Date: Mon, 13 Jul 2026 14:25:15 +0530 Subject: [PATCH 33/43] refactor: split proxied service get endpoint into by-id and by-name routes --- .../ee/routes/v1/proxied-service-router.ts | 68 ++++++++++++------- .../proxied-service-schemas.ts | 4 ++ backend/src/lib/api-docs/constants.ts | 8 +-- .../endpoints/proxied-services/get-by-id.mdx | 4 ++ .../proxied-services/get-by-name.mdx | 4 ++ .../endpoints/proxied-services/get.mdx | 4 -- docs/docs.json | 3 +- 7 files changed, 60 insertions(+), 35 deletions(-) create mode 100644 docs/api-reference/endpoints/proxied-services/get-by-id.mdx create mode 100644 docs/api-reference/endpoints/proxied-services/get-by-name.mdx delete mode 100644 docs/api-reference/endpoints/proxied-services/get.mdx diff --git a/backend/src/ee/routes/v1/proxied-service-router.ts b/backend/src/ee/routes/v1/proxied-service-router.ts index 1a5719486ab..87ded535f5a 100644 --- a/backend/src/ee/routes/v1/proxied-service-router.ts +++ b/backend/src/ee/routes/v1/proxied-service-router.ts @@ -4,12 +4,11 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { CredentialsArraySchema, hostPatternSchema, + ProxiedServiceWithCanProxySchema, ProxiedServiceWithCredentialsSchema, SanitizedProxiedServiceBaseSchema } from "@app/ee/services/proxied-service/proxied-service-schemas"; import { ApiDocsTags, PROXIED_SERVICES } from "@app/lib/api-docs"; -import { BadRequestError } from "@app/lib/errors"; -import { isUuidV4 } from "@app/lib/validator"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -76,7 +75,7 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = }), response: { 200: z.object({ - services: ProxiedServiceWithCredentialsSchema.extend({ canProxy: z.boolean() }).array() + services: ProxiedServiceWithCanProxySchema.array() }) } }, @@ -87,44 +86,61 @@ export const registerProxiedServiceRouter = async (server: FastifyZodProvider) = server.route({ method: "GET", - url: "/:serviceIdOrName", + url: "/:serviceId", config: { rateLimit: readLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, tags: [ApiDocsTags.ProxiedServices], - description: - "Get a proxied service by ID, or by name when the projectId and environment query params are provided", + description: "Get a proxied service by ID", params: z.object({ - serviceIdOrName: z.string().trim().min(1).describe(PROXIED_SERVICES.GET.serviceIdOrName) + serviceId: z.string().uuid().describe(PROXIED_SERVICES.GET.serviceId) + }), + response: { + 200: z.object({ + service: ProxiedServiceWithCanProxySchema + }) + } + }, + handler: async (req) => { + const service = await server.services.proxiedService.getById({ serviceId: req.params.serviceId }, req.permission); + return { service }; + } + }); + + server.route({ + method: "GET", + url: "/slug/:name", + config: { rateLimit: readLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProxiedServices], + description: "Get a proxied service by name", + params: z.object({ + name: slugSchema({ field: "name" }).describe(PROXIED_SERVICES.GET.name) }), querystring: z.object({ - projectId: z.string().trim().min(1).describe(PROXIED_SERVICES.GET.projectId).optional(), - environment: z.string().trim().min(1).describe(PROXIED_SERVICES.GET.environment).optional(), - secretPath: z.string().trim().describe(PROXIED_SERVICES.GET.secretPath).optional() + projectId: z.string().trim().min(1).describe(PROXIED_SERVICES.GET.projectId), + environment: z.string().trim().min(1).describe(PROXIED_SERVICES.GET.environment), + secretPath: z.string().trim().default("/").describe(PROXIED_SERVICES.GET.secretPath) }), response: { 200: z.object({ - service: ProxiedServiceWithCredentialsSchema.extend({ canProxy: z.boolean() }) + service: ProxiedServiceWithCanProxySchema }) } }, handler: async (req) => { - const { serviceIdOrName } = req.params; - const { projectId, environment, secretPath } = req.query; - if (projectId && environment) { - const service = await server.services.proxiedService.getByName( - { projectId, environment, secretPath: secretPath ?? "/", name: serviceIdOrName }, - req.permission - ); - return { service }; - } - if (!isUuidV4(serviceIdOrName)) { - throw new BadRequestError({ - message: "projectId and environment query params are required when fetching a proxied service by name" - }); - } - const service = await server.services.proxiedService.getById({ serviceId: serviceIdOrName }, req.permission); + const service = await server.services.proxiedService.getByName( + { + projectId: req.query.projectId, + environment: req.query.environment, + secretPath: req.query.secretPath, + name: req.params.name + }, + req.permission + ); return { service }; } }); 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 8f05845882e..c414c9e78dc 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-schemas.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-schemas.ts @@ -216,3 +216,7 @@ export const SanitizedProxiedServiceBaseSchema = ProxiedServicesSchema.pick({ export const ProxiedServiceWithCredentialsSchema = SanitizedProxiedServiceBaseSchema.extend({ credentials: SanitizedProxiedServiceCredentialSchema.array() }); + +export const ProxiedServiceWithCanProxySchema = ProxiedServiceWithCredentialsSchema.extend({ + canProxy: z.boolean() +}); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 3405713f655..74e4bdd48fc 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1622,10 +1622,10 @@ export const PROXIED_SERVICES = { secretPath: "The secret path (folder) to list proxied services from." }, GET: { - serviceIdOrName: - "The ID of the proxied service, or its name when the projectId and environment query params are provided.", - projectId: "The ID of the project the proxied service is in. Required when fetching by name.", - environment: "The slug of the environment the proxied service is in. Required when fetching by name.", + serviceId: "The ID of the proxied service.", + name: "The name of the proxied service.", + projectId: "The ID of the project the proxied service is in.", + environment: "The slug of the environment the proxied service is in.", secretPath: "The secret path (folder) the proxied service is in." }, UPDATE: { diff --git a/docs/api-reference/endpoints/proxied-services/get-by-id.mdx b/docs/api-reference/endpoints/proxied-services/get-by-id.mdx new file mode 100644 index 00000000000..7cea5ec638c --- /dev/null +++ b/docs/api-reference/endpoints/proxied-services/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/proxied-services/{serviceId}" +--- diff --git a/docs/api-reference/endpoints/proxied-services/get-by-name.mdx b/docs/api-reference/endpoints/proxied-services/get-by-name.mdx new file mode 100644 index 00000000000..80b61a5c2fe --- /dev/null +++ b/docs/api-reference/endpoints/proxied-services/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/proxied-services/slug/{name}" +--- diff --git a/docs/api-reference/endpoints/proxied-services/get.mdx b/docs/api-reference/endpoints/proxied-services/get.mdx deleted file mode 100644 index 16a564a6f7d..00000000000 --- a/docs/api-reference/endpoints/proxied-services/get.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Get" -openapi: "GET /api/v1/proxied-services/{serviceIdOrName}" ---- diff --git a/docs/docs.json b/docs/docs.json index 2e60e97fc97..8e1c4a205af 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -2528,7 +2528,8 @@ "api-reference/endpoints/proxied-services/create", "api-reference/endpoints/proxied-services/update", "api-reference/endpoints/proxied-services/delete", - "api-reference/endpoints/proxied-services/get", + "api-reference/endpoints/proxied-services/get-by-id", + "api-reference/endpoints/proxied-services/get-by-name", "api-reference/endpoints/proxied-services/list" ] }, From f2927dbe6b91ddf73a82fcb63929213f3255eb82 Mon Sep 17 00:00:00 2001 From: Saif Date: Mon, 13 Jul 2026 16:01:48 +0530 Subject: [PATCH 34/43] refactor: validate proxied service secret references via getSecrets --- .../proxied-service-service.ts | 148 ++++++------------ backend/src/server/routes/index.ts | 6 +- .../platform/agent-proxy/security.mdx | 18 +-- 3 files changed, 62 insertions(+), 110 deletions(-) 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 b45c0742cef..08ee61bc00f 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-service.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-service.ts @@ -1,27 +1,16 @@ -import { ForbiddenError, MongoAbility, subject } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; -import { ActionProjectType, SecretType } from "@app/db/schemas"; -import { - hasSecretReadValueOrDescribePermission, - throwIfMissingSecretReadValueOrDescribePermission -} from "@app/ee/services/permission/permission-fns"; +import { ActionProjectType } from "@app/db/schemas"; import { ProjectPermissionProxiedServiceActions, - ProjectPermissionSecretActions, - 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 { TKmsServiceFactory } from "@app/services/kms/kms-service"; -import { KmsDataKey } from "@app/services/kms/kms-types"; -import { TOrgDALFactory } from "@app/services/org/org-dal"; -import { TProjectFolderGrantDALFactory } from "@app/services/project-folder-grant/project-folder-grant-dal"; +import { PersonalOverridesBehavior, SecretImportReferencesBehavior } from "@app/services/secret/secret-types"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; -import { TSecretImportDALFactory } from "@app/services/secret-import/secret-import-dal"; -import { fnSecretsV2FromImports } from "@app/services/secret-import/secret-import-fns"; -import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal"; +import { TSecretV2BridgeServiceFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-service"; import { TLicenseServiceFactory } from "../license/license-service"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; @@ -48,11 +37,7 @@ type TProxiedServiceServiceFactoryDep = { TSecretFolderDALFactory, "findBySecretPath" | "findBySecretPathMultiEnv" | "findSecretPathByFolderIds" | "findByManySecretPath" >; - secretV2BridgeDAL: Pick; - secretImportDAL: Pick; - orgDAL: Pick; - projectFolderGrantDAL: Pick; - kmsService: Pick; + secretV2BridgeService: Pick; permissionService: Pick; licenseService: Pick; }; @@ -73,11 +58,7 @@ export const proxiedServiceServiceFactory = ({ proxiedServiceDAL, proxiedServiceCredentialDAL, folderDAL, - secretV2BridgeDAL, - secretImportDAL, - orgDAL, - projectFolderGrantDAL, - kmsService, + secretV2BridgeService, permissionService, licenseService }: TProxiedServiceServiceFactoryDep) => { @@ -90,79 +71,64 @@ export const proxiedServiceServiceFactory = ({ } }; - // Validates every referenced secret exists and that the caller holds ReadValue on it. The check is - // per-key and tag-aware so a deny rule scoped by secret name or tag can't be bypassed to wire in a - // secret the caller can't read (the proxy would otherwise broker its value on their behalf). Keys not - // found in the folder are resolved through its secret imports with the same rules the secrets list API - // applies: ReadValue is checked at the import source (or against the importing folder for cross-project - // grants), and a secret the caller cannot read is treated as not found. + // Requires ReadValue (not just DescribeSecret) on each referenced secret: the proxy brokers the value on + // the caller's behalf, so attaching a secret they can't read would let them exfiltrate it on the wire. const $assertReferencedSecretsReadable = async ( - permission: MongoAbility, + actor: OrgServiceActor, projectId: string, - actorOrgId: string, environment: string, secretPath: string, - folderId: string, credentials: TProxiedServiceCredentialInput[] ) => { const uniqueKeys = [...new Set(credentials.map((c) => c.secretKey))]; if (!uniqueKeys.length) return; - const found = await secretV2BridgeDAL.findBySecretKeys( - folderId, - uniqueKeys.map((key) => ({ key, type: SecretType.Shared })) - ); - const tagsByKey = new Map(found.map((s) => [s.key, s.tags?.map((t) => t.slug) ?? []])); - - uniqueKeys - .filter((key) => tagsByKey.has(key)) - .forEach((secretName) => { - throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { - environment, - secretPath, - secretName, - secretTags: tagsByKey.get(secretName) ?? [] - }); - }); + // viewSecretValue must be true so secretValueHidden reflects real ReadValue access; values are discarded. + const { secrets, imports } = await secretV2BridgeService.getSecrets({ + actor: actor.type, + actorId: actor.id, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + projectId, + environment, + path: secretPath, + keys: uniqueKeys, + includeImports: true, + recursive: false, + viewSecretValue: true, + throwOnMissingReadValuePermission: false, + expandSecretReferences: false, + expandPersonalOverrides: false, + personalOverridesBehavior: PersonalOverridesBehavior.NeverInclude, + secretImportReferencesBehavior: SecretImportReferencesBehavior.Relative + }); - let missing = uniqueKeys.filter((key) => !tagsByKey.has(key)); - if (!missing.length) return; + // Folder secrets take precedence over imports at resolution time, so a key hidden in the folder is not + // readable even if an import would expose it. Imports only return keys the caller can read. + const readableFolderKeys = new Set(secrets.filter((s) => !s.secretValueHidden).map((s) => s.secretKey)); + const describeOnlyFolderKeys = new Set(secrets.filter((s) => s.secretValueHidden).map((s) => s.secretKey)); + const readableImportKeys = new Set(imports.flatMap((group) => group.secrets.map((s) => s.secretKey))); + + const notReadable: string[] = []; + const notFound: string[] = []; + uniqueKeys.forEach((key) => { + if (readableFolderKeys.has(key)) return; + if (describeOnlyFolderKeys.has(key)) { + notReadable.push(key); + return; + } + if (readableImportKeys.has(key)) return; + notFound.push(key); + }); - const secretImports = await secretImportDAL.findByFolderIds([folderId]); - const allowedImports = secretImports.filter(({ isReplication }) => !isReplication); - if (allowedImports.length) { - const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.SecretManager, - projectId - }); - const importedGroups = await fnSecretsV2FromImports({ - secretImports: allowedImports, - folderDAL, - secretDAL: secretV2BridgeDAL, - secretImportDAL, - orgDAL, - kmsService, - viewSecretValue: false, - decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : ""), - hasSecretAccess: (importEnvironment, importSecretPath, importSecretKey, importSecretTags) => - hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { - environment: importEnvironment, - secretPath: importSecretPath, - secretName: importSecretKey, - secretTags: importSecretTags - }), - importAccessScopeByFolderId: new Map([[folderId, { environment, secretPath }]]), - projectId, - projectFolderGrantDAL, - actorOrgId + if (notReadable.length) { + throw new ForbiddenRequestError({ + message: `You do not have permission to read the value of secret(s): ${notReadable.join(", ")}` }); - const importedKeys = new Set(importedGroups.flatMap((group) => group.secrets.map((s) => s.secretKey))); - missing = missing.filter((key) => !importedKeys.has(key)); } - - if (missing.length) { + if (notFound.length) { throw new BadRequestError({ - message: `Referenced secret(s) not found in folder or its imports: ${missing.join(", ")}` + message: `Referenced secret(s) not found in folder or its imports: ${notFound.join(", ")}` }); } }; @@ -202,15 +168,7 @@ export const proxiedServiceServiceFactory = ({ }); } - await $assertReferencedSecretsReadable( - permission, - projectId, - actor.orgId, - environment, - canonicalPath, - folder.id, - credentials - ); + await $assertReferencedSecretsReadable(actor, projectId, environment, canonicalPath, credentials); const existing = await proxiedServiceDAL.findOne({ folderId: folder.id, name }); if (existing) { @@ -382,12 +340,10 @@ export const proxiedServiceServiceFactory = ({ if (credentials) { await $assertReferencedSecretsReadable( - permission, + actor, service.projectId, - actor.orgId, service.environmentSlug, resolvedSecretPath, - service.folderId, credentials ); } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 361c2dac872..65ec398ff50 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2919,11 +2919,7 @@ export const registerRoutes = async ( proxiedServiceDAL, proxiedServiceCredentialDAL, folderDAL, - secretV2BridgeDAL, - secretImportDAL, - orgDAL, - projectFolderGrantDAL, - kmsService, + secretV2BridgeService, permissionService, licenseService }); diff --git a/docs/documentation/platform/agent-proxy/security.mdx b/docs/documentation/platform/agent-proxy/security.mdx index a31423de35a..02d9a9b5e1e 100644 --- a/docs/documentation/platform/agent-proxy/security.mdx +++ b/docs/documentation/platform/agent-proxy/security.mdx @@ -60,17 +60,17 @@ On the agent machine, the `connect` wrapper downloads the root CA to `~/.infisic ## Permissions -Proxied services have their own project-level permission subject, so you can control who manages them and which identities can route traffic through them. +Proxied services have their own project-level permission subject, with five actions: -| Action | Description | Admin | Member | Viewer | -| --- | --- | --- | --- | --- | -| **Read** | View proxied services and their configuration | Yes | Yes | Yes | -| **Create** | Create new proxied services | Yes | No | No | -| **Edit** | Update a service's host patterns, secrets, or enabled state | Yes | No | No | -| **Delete** | Delete proxied services | Yes | No | No | -| **Proxy** | Route traffic through the service and have credentials applied | Yes | No | No | +| Action | What it allows | +| --- | --- | +| **Read** | View proxied services and their configuration | +| **Create** | Create new proxied services | +| **Edit** | Update a service's host patterns, secrets, or enabled state | +| **Delete** | Delete proxied services | +| **Proxy** | Route traffic through the service and have secrets applied | -The **Proxy** action is intended for agent machine identities, not humans. An identity with Proxy on a service gets the service's credentials applied to its traffic without ever being able to read the secret values. +**Proxy** is meant for agent machine identities: an identity with Proxy gets a service's secrets applied to its traffic without ever being able to read the values. ### Built-in Roles From 40f455fc7032ba3a1daff30451bc62b204a90dbb Mon Sep 17 00:00:00 2001 From: Saif Date: Mon, 13 Jul 2026 16:38:43 +0530 Subject: [PATCH 35/43] fix: re-validate retained proxied service credentials on every update --- .../proxied-service-service.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) 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 08ee61bc00f..127bb71e257 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-service.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-service.ts @@ -78,7 +78,7 @@ export const proxiedServiceServiceFactory = ({ projectId: string, environment: string, secretPath: string, - credentials: TProxiedServiceCredentialInput[] + credentials: { secretKey: string }[] ) => { const uniqueKeys = [...new Set(credentials.map((c) => c.secretKey))]; if (!uniqueKeys.length) return; @@ -338,15 +338,17 @@ export const proxiedServiceServiceFactory = ({ } } - if (credentials) { - await $assertReferencedSecretsReadable( - actor, - service.projectId, - service.environmentSlug, - resolvedSecretPath, - credentials - ); - } + // Validate the effective credentials on every update, not just when they're being replaced: an editor + // without ReadValue could otherwise reroute (change hostPattern) or enable a service while retaining + // credentials they can't read, and have the broker apply those values to a host they control. + const effectiveCredentials = credentials ?? (await proxiedServiceCredentialDAL.findByServiceIds([service.id])); + await $assertReferencedSecretsReadable( + actor, + service.projectId, + service.environmentSlug, + resolvedSecretPath, + effectiveCredentials + ); const serviceUpdate = { ...(name !== undefined ? { name } : {}), From fcfa601f166c65eb14320a39d3c464d717caf6f9 Mon Sep 17 00:00:00 2001 From: Saif Date: Mon, 13 Jul 2026 17:46:53 +0530 Subject: [PATCH 36/43] fix: expand secret references when validating proxied service credentials --- .../ee/services/proxied-service/proxied-service-service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 127bb71e257..bde65525af7 100644 --- a/backend/src/ee/services/proxied-service/proxied-service-service.ts +++ b/backend/src/ee/services/proxied-service/proxied-service-service.ts @@ -84,6 +84,10 @@ export const proxiedServiceServiceFactory = ({ if (!uniqueKeys.length) return; // viewSecretValue must be true so secretValueHidden reflects real ReadValue access; values are discarded. + // expandSecretReferences must be true to mirror what the broker fetches at runtime: it resolves each + // referenced value with expansion on, so the referenced value the broker would send may itself pull in + // another secret (${OTHER}). Expanding here under the caller's permissions makes getSecrets throw if they + // can't read a transitively-referenced secret, blocking them from attaching a value they can't fully read. const { secrets, imports } = await secretV2BridgeService.getSecrets({ actor: actor.type, actorId: actor.id, @@ -97,7 +101,7 @@ export const proxiedServiceServiceFactory = ({ recursive: false, viewSecretValue: true, throwOnMissingReadValuePermission: false, - expandSecretReferences: false, + expandSecretReferences: true, expandPersonalOverrides: false, personalOverridesBehavior: PersonalOverridesBehavior.NeverInclude, secretImportReferencesBehavior: SecretImportReferencesBehavior.Relative From 7c40d2b32113bee2eb56b7ffe5308962620d1563 Mon Sep 17 00:00:00 2001 From: Saif Date: Tue, 14 Jul 2026 00:12:33 +0530 Subject: [PATCH 37/43] docs: document connect --allow-readable-brokered-secrets guardrail --- docs/cli/commands/agent-proxy.mdx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/cli/commands/agent-proxy.mdx b/docs/cli/commands/agent-proxy.mdx index a588b54df65..91e5dc1ec23 100644 --- a/docs/cli/commands/agent-proxy.mdx +++ b/docs/cli/commands/agent-proxy.mdx @@ -121,7 +121,7 @@ $ infisical secrets agent-proxy start --port 17322 --unmatched-host=block - `HTTPS_PROXY` / `HTTP_PROXY` pointing at the agent proxy, plus `NO_PROXY` (always includes `localhost,127.0.0.1`, merged with any `NO_PROXY` already in your environment and the `--no-proxy` flag). - The organization's root CA written to `~/.infisical/agent-proxy/mitm-ca.pem` and trusted via `SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, and `DENO_CERT`. - Dummy placeholder environment variables for credential-substitution services the agent has **Proxy** access to. - - Real values for regular secrets the agent has **Read Value** on in the scoped folder, including secrets imported into it (similar to `infisical run`). This is opt-in; the built-in Agent role grants no read access, and brokered credentials never appear in the agent's environment. + - Real values for regular secrets the agent has **Read Value** on in the scoped folder, including secrets imported into it (similar to `infisical run`). This is opt-in; the built-in Agent role grants no read access, and brokered credentials never appear in the agent's environment. If the agent can read a secret that a proxied service brokers to it, `connect` refuses to start, since the agent would receive the real value directly and bypass the proxy; fix the permissions or pass `--allow-readable-brokered-secrets` to override. - `INFISICAL_TOKEN` set to the agent's access token, so the agent can run Infisical CLI commands itself. The client ID and client secret used to authenticate are stripped from the child environment. The wrapper forwards signals to the agent process and exits with its exit code. @@ -229,6 +229,17 @@ $ infisical secrets agent-proxy connect --proxy=proxy:17322 --projectId= -- claude ``` + + + Start even if the agent can **Read Value** on a secret that a proxied service brokers to it. By default `connect` refuses to start in that case, since the agent would receive the real value directly and bypass the proxy, defeating the point of brokering it. This is a misconfiguration guardrail, not a security boundary: the real fix is to not grant the agent read access to brokered secrets. Use this flag only for the rare intentional case. + + ```bash + # Example + infisical secrets agent-proxy connect --proxy=proxy:17322 --env=prod --allow-readable-brokered-secrets -- claude + ``` + + Default value: `false` + From bcd07089767f55554d9e0ccc3197aa4512df5e56 Mon Sep 17 00:00:00 2001 From: Saif Date: Tue, 14 Jul 2026 04:27:15 +0530 Subject: [PATCH 38/43] feat(ui): refine proxied service form, icon, and overview row --- .../CreateProxiedServiceModal.tsx | 4 +- .../EditProxiedServiceModal.tsx | 2 +- .../forms/ProxiedServiceForm.tsx | 115 +++++++++--------- .../src/components/v3/generic/Field/Field.tsx | 14 ++- frontend/src/index.css | 2 +- .../AddResourceButtons/AddResourceButtons.tsx | 4 +- .../ProxiedServiceTableRow.tsx | 44 ++++--- .../ResourceCount/ResourceCount.tsx | 4 +- .../ResourceFilter/ResourceFilter.tsx | 4 +- 9 files changed, 108 insertions(+), 85 deletions(-) diff --git a/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx b/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx index e87dfd26ce9..248c76861cf 100644 --- a/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx +++ b/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx @@ -19,11 +19,11 @@ export const CreateProxiedServiceModal = ({ }: Props) => { return ( - + Create Proxied Service - Define a service the agent proxy can broker credentials for. + Define a service the agent proxy can broker secrets for. - + Edit Proxied Service Update how the agent proxy brokers this service. diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx index 1c76e16bd93..8a7567b8bf4 100644 --- a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -12,10 +12,15 @@ import { FieldDescription, FieldError, FieldLabel, + FieldTitle, IconButton, Input, SheetFooter, Switch, + Tabs, + TabsContent, + TabsList, + TabsTrigger, Tooltip, TooltipContent, TooltipTrigger @@ -227,24 +232,6 @@ export const ProxiedServiceForm = ({ return (
- ( - - - Enabled - - - - )} - /> - Service Name @@ -282,14 +269,44 @@ export const ProxiedServiceForm = ({ + ( + + + Enabled + + When off, the proxy stops brokering this service's traffic. + + + + + )} + /> +
-
-

Header Rewriting

-

Sets these headers on every request.

-
+ setValue("headerMode", value as HeaderRewritingMode)} + > +
+
+

Header Rewrites

+

Sets these headers on every request.

+
+ + Custom Headers + Basic Auth + +
- {headerMode === HeaderRewritingMode.Headers ? ( - <> +
{headerFields.fields.length === 0 && (

@@ -363,20 +380,12 @@ export const ProxiedServiceForm = ({ Add Header -

{headersRootError && {headersRootError}} - - ) : ( - <> -
+ + + +
Username @@ -418,24 +427,14 @@ export const ProxiedServiceForm = ({
-
- -
- - )} +
+
-

Credential Substitution

+

Secret Substitution

@@ -461,7 +460,7 @@ export const ProxiedServiceForm = ({ {substitutionFields.fields.map((row, i) => (
Set @@ -507,6 +506,12 @@ export const ProxiedServiceForm = ({
+
and replace it in
@@ -517,6 +522,7 @@ export const ProxiedServiceForm = ({ )} /> +
@@ -536,16 +542,9 @@ export const ProxiedServiceForm = ({ /> )} /> +
-
))}
diff --git a/frontend/src/components/v3/generic/Field/Field.tsx b/frontend/src/components/v3/generic/Field/Field.tsx index 07686082f42..3565b8ffe96 100644 --- a/frontend/src/components/v3/generic/Field/Field.tsx +++ b/frontend/src/components/v3/generic/Field/Field.tsx @@ -208,9 +208,19 @@ function FieldError({ return null; } - const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()]; + // Ignore entries without a message: callers often pass a fixed-shape array with `undefined` slots for + // fields that currently have no error, and those must not count toward the single-vs-list decision. + const uniqueErrors = [ + ...new Map( + errors.filter((error) => error?.message).map((error) => [error?.message, error]) + ).values() + ]; - if (uniqueErrors?.length === 1) { + if (!uniqueErrors.length) { + return null; + } + + if (uniqueErrors.length === 1) { return uniqueErrors[0]?.message; } diff --git a/frontend/src/index.css b/frontend/src/index.css index cf998956e24..8c5fd69f6d7 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -100,7 +100,7 @@ --color-import: #457d91; --color-secret-rotation: #4c6081; --color-override: #694c81; - --color-proxied-service: #4c7da1; + --color-proxied-service: #7d4ca9; /*legacy color schema */ --color-org-v1: #30b3ff; diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/AddResourceButtons/AddResourceButtons.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/AddResourceButtons/AddResourceButtons.tsx index efa6c413057..5437a7a1dc7 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/AddResourceButtons/AddResourceButtons.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/AddResourceButtons/AddResourceButtons.tsx @@ -1,6 +1,6 @@ import { - ArrowRightLeftIcon, ChevronDown, + ChevronsLeftRightEllipsisIcon, ClipboardPasteIcon, FingerprintIcon, FolderIcon, @@ -172,7 +172,7 @@ export function AddResourceButtons({ onClick={onAddProxiedService} isDisabled={!isSingleEnvSelected || !isAllowed} > - + Add Proxied Service diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx index d73ebfe2d13..5f3cb79eafc 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx @@ -1,5 +1,11 @@ import { subject } from "@casl/ability"; -import { ArrowRightLeftIcon, ChevronDownIcon, EditIcon, Trash2Icon } from "lucide-react"; +import { + BanIcon, + ChevronDownIcon, + ChevronsLeftRightEllipsisIcon, + EditIcon, + Trash2Icon +} from "lucide-react"; import { twMerge } from "tailwind-merge"; import { ProjectPermissionCan } from "@app/components/permissions"; @@ -118,19 +124,27 @@ export const ProxiedServiceTableRow = ({ ); const renderInlineDetails = (proxiedService: TDashboardProxiedService) => ( -
- - - {proxiedService.hostPattern} - - - {!proxiedService.isEnabled && Disabled} -
+ <> + + {proxiedService.hostPattern} + +
+ {!proxiedService.isEnabled && ( + + + Disabled + + )} +
+ ); return ( @@ -149,7 +163,7 @@ export const ProxiedServiceTableRow = ({ {!isSingleEnvView && isExpanded ? ( ) : ( - + )}
- + {proxiedServiceCount}
diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/ResourceFilter/ResourceFilter.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/ResourceFilter/ResourceFilter.tsx index 159ab2447dd..5296cc9fa9f 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/ResourceFilter/ResourceFilter.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/ResourceFilter/ResourceFilter.tsx @@ -1,5 +1,5 @@ import { - ArrowRightLeftIcon, + ChevronsLeftRightEllipsisIcon, FilterIcon, FingerprintIcon, FolderIcon, @@ -49,7 +49,7 @@ const OVERVIEW_RESOURCE_TYPES: ResourceTypeOption[] = [ { type: "proxiedService", label: "Proxied Services", - icon: + icon: }, { type: "secret", label: "Secrets", icon: } ]; From 4f928b3d1fb5529dd99dfde605736a98ab66f2a9 Mon Sep 17 00:00:00 2001 From: Saif Date: Tue, 14 Jul 2026 04:47:49 +0530 Subject: [PATCH 39/43] feat(ui): refine secret substitution card (semantic layout, section intro, tooltips) --- .../forms/ProxiedServiceForm.tsx | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx index 8a7567b8bf4..8b26f261aab 100644 --- a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -433,34 +433,25 @@ export const ProxiedServiceForm = ({
-
-

Secret Substitution

- - - - - - The placeholder is delivered as an environment variable the agent reads. The agent - sends that placeholder value in its request, and the proxy swaps it for the real - secret on the wire. - - -
+

Secret Substitution

- Swap a placeholder in the request for the real secret, on the wire. + When an agent launches with{" "} + infisical secrets agent-proxy connect, + Infisical sets each placeholder below as an environment variable on it. The agent sends the + placeholder in its requests, and the proxy swaps it for the real secret on the wire.

-
+
{substitutionFields.fields.length === 0 && ( -

+

No substitutions added. Click below to add. -

+
)} {substitutionFields.fields.map((row, i) => (
Set @@ -491,8 +482,8 @@ export const ProxiedServiceForm = ({ - The value the agent sends instead of the real secret; the proxy swaps it on - the wire. + The value the agent sends instead of the real secret; the proxy swaps it on the + wire. +
and replace it in
@@ -525,6 +517,7 @@ export const ProxiedServiceForm = ({
+
with value of
From 0e389bf49f5f99c42616a03a05047e8676427ea2 Mon Sep 17 00:00:00 2001 From: Saif Date: Tue, 14 Jul 2026 04:47:49 +0530 Subject: [PATCH 40/43] docs: use placeholder in agent-proxy --proxy examples --- docs/cli/commands/agent-proxy.mdx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/cli/commands/agent-proxy.mdx b/docs/cli/commands/agent-proxy.mdx index 91e5dc1ec23..850f91d6e5f 100644 --- a/docs/cli/commands/agent-proxy.mdx +++ b/docs/cli/commands/agent-proxy.mdx @@ -19,7 +19,7 @@ description: "Run the Infisical Agent Proxy and connect agents to it" infisical secrets agent-proxy connect [options] -- [agent start command] # Example - infisical secrets agent-proxy connect --proxy=proxy:17322 --env=prod --path=/ai-agents -- claude + infisical secrets agent-proxy connect --proxy=:17322 --env=prod --path=/ai-agents -- claude ``` @@ -130,7 +130,7 @@ $ infisical secrets agent-proxy start --port 17322 --unmatched-host=block $ infisical secrets agent-proxy connect --proxy=: --env= -- [agent start command] # Example -$ infisical secrets agent-proxy connect --proxy=proxy:17322 --projectId= --env=prod --path=/ai-agents -- claude +$ infisical secrets agent-proxy connect --proxy=:17322 --projectId= --env=prod --path=/ai-agents -- claude ``` ### Environment variables @@ -170,7 +170,7 @@ $ infisical secrets agent-proxy connect --proxy=proxy:17322 --projectId=:17322 --env=prod -- claude ``` @@ -179,7 +179,7 @@ $ infisical secrets agent-proxy connect --proxy=proxy:17322 --projectId=:17322 --env=staging -- codex ``` @@ -188,7 +188,7 @@ $ infisical secrets agent-proxy connect --proxy=proxy:17322 --projectId=:17322 --env=prod --path=/ai-agents -- claude ``` Default value: `/` @@ -199,7 +199,7 @@ $ infisical secrets agent-proxy connect --proxy=proxy:17322 --projectId=:17322 --env=prod --no-proxy=internal.corp.com -- claude ``` @@ -208,7 +208,7 @@ $ infisical secrets agent-proxy connect --proxy=proxy:17322 --projectId= -- claude + infisical secrets agent-proxy connect --proxy=:17322 --env=prod --projectId= -- claude ``` @@ -217,7 +217,7 @@ $ infisical secrets agent-proxy connect --proxy=proxy:17322 --projectId= --client-secret= -- claude + infisical secrets agent-proxy connect --proxy=:17322 --env=prod --client-id= --client-secret= -- claude ``` @@ -226,7 +226,7 @@ $ infisical secrets agent-proxy connect --proxy=proxy:17322 --projectId= -- claude + infisical secrets agent-proxy connect --proxy=:17322 --env=prod --token= -- claude ``` @@ -235,7 +235,7 @@ $ infisical secrets agent-proxy connect --proxy=proxy:17322 --projectId=:17322 --env=prod --allow-readable-brokered-secrets -- claude ``` Default value: `false` From 95513ba27200d377b556b0ec4d880fc02d5ca6bf Mon Sep 17 00:00:00 2001 From: Saif Date: Tue, 14 Jul 2026 05:13:16 +0530 Subject: [PATCH 41/43] style: fix prettier formatting in proxied service form --- .../proxied-services/forms/ProxiedServiceForm.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx index 8b26f261aab..b284ada37b3 100644 --- a/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -436,9 +436,12 @@ export const ProxiedServiceForm = ({

Secret Substitution

When an agent launches with{" "} - infisical secrets agent-proxy connect, - Infisical sets each placeholder below as an environment variable on it. The agent sends the - placeholder in its requests, and the proxy swaps it for the real secret on the wire. + + infisical secrets agent-proxy connect + + , Infisical sets each placeholder below as an environment variable on it. The agent + sends the placeholder in its requests, and the proxy swaps it for the real secret on + the wire.

@@ -482,8 +485,8 @@ export const ProxiedServiceForm = ({ - The value the agent sends instead of the real secret; the proxy swaps it on the - wire. + The value the agent sends instead of the real secret; the proxy swaps it on + the wire. Date: Tue, 14 Jul 2026 15:21:13 +0530 Subject: [PATCH 42/43] docs: include --projectId in the agent-proxy connect example --- docs/cli/commands/agent-proxy.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/commands/agent-proxy.mdx b/docs/cli/commands/agent-proxy.mdx index 850f91d6e5f..9d158c2e767 100644 --- a/docs/cli/commands/agent-proxy.mdx +++ b/docs/cli/commands/agent-proxy.mdx @@ -19,7 +19,7 @@ description: "Run the Infisical Agent Proxy and connect agents to it" infisical secrets agent-proxy connect [options] -- [agent start command] # Example - infisical secrets agent-proxy connect --proxy=:17322 --env=prod --path=/ai-agents -- claude + infisical secrets agent-proxy connect --proxy=:17322 --projectId= --env=prod --path=/ai-agents -- claude ``` From 8306e0e5b48581af29b117f20044701dcc76b4b1 Mon Sep 17 00:00:00 2001 From: Saif Date: Tue, 14 Jul 2026 15:26:33 +0530 Subject: [PATCH 43/43] docs: include --projectId in agent-proxy connect usage line --- docs/cli/commands/agent-proxy.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/commands/agent-proxy.mdx b/docs/cli/commands/agent-proxy.mdx index 9d158c2e767..488032d41ec 100644 --- a/docs/cli/commands/agent-proxy.mdx +++ b/docs/cli/commands/agent-proxy.mdx @@ -127,7 +127,7 @@ $ infisical secrets agent-proxy start --port 17322 --unmatched-host=block The client ID and client secret used to authenticate are stripped from the child environment. The wrapper forwards signals to the agent process and exits with its exit code. ```bash -$ infisical secrets agent-proxy connect --proxy=: --env= -- [agent start command] +$ infisical secrets agent-proxy connect --proxy=: --projectId= --env= -- [agent start command] # Example $ infisical secrets agent-proxy connect --proxy=:17322 --projectId= --env=prod --path=/ai-agents -- claude