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/@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/20260710162607_create-proxied-services.ts b/backend/src/db/migrations/20260710162607_create-proxied-services.ts new file mode 100644 index 00000000000..3260bf33871 --- /dev/null +++ b/backend/src/db/migrations/20260710162607_create-proxied-services.ts @@ -0,0 +1,82 @@ +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"); + + 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"); + + t.string("secretKey").notNullable(); + t.string("role").notNullable(); + + t.string("headerName"); + t.string("headerPrefix"); + t.string("headerPurpose"); + + t.string("placeholderKey"); + t.string("placeholderValue"); + t.specificType("substitutionSurfaces", "text[]"); + + 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..1dec2487ba6 --- /dev/null +++ b/backend/src/ee/routes/v1/agent-proxy-ca-router.ts @@ -0,0 +1,65 @@ +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({ + method: "GET", + url: "/", + config: { rateLimit: readLimit }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + response: { + 200: z.object({ + certificate: z.string(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm), + 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.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) => { + 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/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..87ded535f5a --- /dev/null +++ b/backend/src/ee/routes/v1/proxied-service-router.ts @@ -0,0 +1,228 @@ +import { z } from "zod"; + +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 { 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"; + +export const registerProxiedServiceRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + 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).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 }) + } + }, + 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 }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { rateLimit: readLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + 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: ProxiedServiceWithCanProxySchema.array() + }) + } + }, + handler: async (req) => { + return server.services.proxiedService.list(req.query, req.permission); + } + }); + + server.route({ + method: "GET", + 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", + params: z.object({ + 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), + 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: ProxiedServiceWithCanProxySchema + }) + } + }, + handler: async (req) => { + 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 }; + } + }); + + server.route({ + method: "PATCH", + url: "/:serviceId", + config: { rateLimit: writeLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + 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().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 }) + } + }, + handler: async (req) => { + const service = await server.services.proxiedService.updateById( + { 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 }; + } + }); + + server.route({ + method: "DELETE", + url: "/:serviceId", + config: { rateLimit: writeLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + 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 }) + } + }, + handler: async (req) => { + const service = await server.services.proxiedService.deleteById( + { 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.environment, + secretPath: service.secretPath + } + } + }); + 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..61c5bc25c83 --- /dev/null +++ b/backend/src/ee/services/agent-proxy-ca/agent-proxy-ca-service.ts @@ -0,0 +1,219 @@ +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 } 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; +const ROOT_CA_VALIDITY_YEARS = 10; +const CLOCK_SKEW_MS = 5 * 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." + }); + } + }; + + 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 + }); + }; + + 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)]); + 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 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: rootCaNotBefore, + 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 }; + }; + + const getRootCa = async (actor: OrgServiceActor) => { + await $checkLicense(actor.orgId); + await $assertOrgMembership(actor); + + const { config, rootCaCert } = await $getOrgRootCa(actor.orgId); + return { + certificate: rootCaCert.toString("pem"), + // 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 + }; + }; + + const signIntermediate = async (actor: OrgServiceActor, publicKeyPem: string) => { + await $checkLicense(actor.orgId); + await $assertOrgMembership(actor); + + const { rootCaCert, rootCaPrivateKeyBuffer } = await $getOrgRootCa(actor.orgId); + + 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; + 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 public key: must be an ECDSA P-256 public key in PEM format" }); + } + + 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 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, + notAfter: expiration, + signingKey: importedRootCaPrivateKey, + publicKey: intermediatePublicKey, + signingAlgorithm: alg, + extensions: [ + // 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) + ] + }); + + 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/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index d56c6ecfe9a..931ba707b22 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -822,6 +822,12 @@ export enum EventType { 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", + SIGN_AGENT_PROXY_INTERMEDIATE_CA = "sign-agent-proxy-intermediate-ca", + // Dynamic Secret Leases CREATE_DYNAMIC_SECRET_LEASE = "create-dynamic-secret-lease", DELETE_DYNAMIC_SECRET_LEASE = "delete-dynamic-secret-lease", @@ -6929,6 +6935,50 @@ interface ListDynamicSecretsEvent { }; } +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 SignAgentProxyIntermediateCaEvent { + type: EventType.SIGN_AGENT_PROXY_INTERMEDIATE_CA; + metadata: { + serialNumber: string; + expiration: string; + }; +} + interface ListDynamicSecretLeasesEvent { type: EventType.LIST_DYNAMIC_SECRET_LEASES; metadata: { @@ -7961,6 +8011,10 @@ export type Event = | DeleteDynamicSecretEvent | GetDynamicSecretEvent | ListDynamicSecretsEvent + | CreateProxiedServiceEvent + | UpdateProxiedServiceEvent + | DeleteProxiedServiceEvent + | SignAgentProxyIntermediateCaEvent | ListDynamicSecretLeasesEvent | CreateDynamicSecretLeaseEvent | DeleteDynamicSecretLeaseEvent 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..90cf31fc4d3 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,25 @@ const buildCryptographicOperatorPermissionRules = () => { return rules; }; +const buildAgentPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); + + can(ProjectPermissionProxiedServiceActions.Proxy, ProjectPermissionSub.ProxiedServices); + + return rules; +}; + +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 +844,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..5def0600ac5 --- /dev/null +++ b/backend/src/ee/services/proxied-service/proxied-service-dal.ts @@ -0,0 +1,211 @@ +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; + +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; +}; + +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 + }, + // folder's own name, not its full path; callers surfacing this must override with the canonical secret path + folder: { + path: row.folderName + } +}); + +export const proxiedServiceDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ProxiedService); + + 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(scopedSelectColumns(db)); + + return rows.map(mapRowToScope); + }; + + 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) => { + 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( + ...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`) + ) + .orderBy(`${TableName.ProxiedService}.name`, orderDirection); + + 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) => { + if (!folderIds.length) return 0; + const query = (tx || db.replicaNode())(TableName.ProxiedService).whereIn( + `${TableName.ProxiedService}.folderId`, + folderIds + ); + + if (search) { + void query.whereILike(`${TableName.ProxiedService}.name`, `%${sanitizeSqlLikeString(search)}%`); + } + + const [result] = await query.countDistinct<{ count: string | number }>({ + count: `${TableName.ProxiedService}.name` + }); + return Number(result?.count ?? 0); + }; + + 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, + findDashboardByFolderIds, + 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..f890202c80f --- /dev/null +++ b/backend/src/ee/services/proxied-service/proxied-service-enums.ts @@ -0,0 +1,17 @@ +export enum ProxiedServiceCredentialRole { + HeaderRewrite = "header-rewrite", + // referenced via z.nativeEnum, not by name; do not delete + 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-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 new file mode 100644 index 00000000000..c414c9e78dc --- /dev/null +++ b/backend/src/ee/services/proxied-service/proxied-service-schemas.ts @@ -0,0 +1,222 @@ +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, + ProxiedServiceHeaderPurpose, + ProxiedServiceSubstitutionSurface +} from "./proxied-service-enums"; + +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 + .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); + + // 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) { + host = hostPort.slice(0, colonIdx); + if (!isValidPort(hostPort.slice(colonIdx + 1))) { + 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).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) { + 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) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Credential substitution requires placeholderKey, placeholderValue, and substitutionSurfaces" + }); + } + }); + +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" + }); + } + 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" + }); + } + }); + +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() +}); + +export const ProxiedServiceWithCanProxySchema = ProxiedServiceWithCredentialsSchema.extend({ + canProxy: z.boolean() +}); diff --git a/backend/src/ee/services/proxied-service/proxied-service-service.ts b/backend/src/ee/services/proxied-service/proxied-service-service.ts new file mode 100644 index 00000000000..bde65525af7 --- /dev/null +++ b/backend/src/ee/services/proxied-service/proxied-service-service.ts @@ -0,0 +1,521 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { ActionProjectType } from "@app/db/schemas"; +import { + ProjectPermissionProxiedServiceActions, + 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 { PersonalOverridesBehavior, SecretImportReferencesBehavior } from "@app/services/secret/secret-types"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { TSecretV2BridgeServiceFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-service"; + +import { 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" | "findByManySecretPath" + >; + secretV2BridgeService: 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, + secretV2BridgeService, + 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." + }); + } + }; + + // 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 ( + actor: OrgServiceActor, + projectId: string, + environment: string, + secretPath: string, + credentials: { secretKey: string }[] + ) => { + const uniqueKeys = [...new Set(credentials.map((c) => c.secretKey))]; + 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, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + projectId, + environment, + path: secretPath, + keys: uniqueKeys, + includeImports: true, + recursive: false, + viewSecretValue: true, + throwOnMissingReadValuePermission: false, + expandSecretReferences: true, + expandPersonalOverrides: false, + personalOverridesBehavior: PersonalOverridesBehavior.NeverInclude, + secretImportReferencesBehavior: SecretImportReferencesBehavior.Relative + }); + + // 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); + }); + + if (notReadable.length) { + throw new ForbiddenRequestError({ + message: `You do not have permission to read the value of secret(s): ${notReadable.join(", ")}` + }); + } + if (notFound.length) { + throw new BadRequestError({ + message: `Referenced secret(s) not found in folder or its imports: ${notFound.join(", ")}` + }); + } + }; + + 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 }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) { + throw new BadRequestError({ + message: `Could not find folder with path "${secretPath}" in environment "${environment}"` + }); + } + + await $assertReferencedSecretsReadable(actor, projectId, environment, canonicalPath, 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` }); + } + + 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 + }); + + 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` }); + } + } + + // 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 } : {}), + ...(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; + if (credentials) { + await proxiedServiceCredentialDAL.deleteByServiceId(serviceId, tx); + updatedCredentials = credentials.length + ? await proxiedServiceCredentialDAL.insertMany( + credentials.map((c) => toCredentialRow(serviceId, c)), + tx + ) + : []; + } else { + updatedCredentials = await proxiedServiceCredentialDAL.findByServiceIds([serviceId], tx); + } + + return { + ...updated, + credentials: updatedCredentials, + projectId: service.projectId, + environment: service.environmentSlug, + secretPath: resolvedSecretPath + }; + }); + }; + + 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 + }) + ); + + await proxiedServiceDAL.deleteById(serviceId); + const { environmentSlug, ...rest } = service; + return { ...rest, environment: environmentSlug, secretPath: resolvedSecretPath }; + }; + + const getDashboardProxiedServiceCount = async ( + { projectId, environments, secretPath, search }: TProxiedServiceDashboardCountDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + if (!plan.secretsBrokering) return 0; + + 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, orderDirection, limit, offset }: TProxiedServiceDashboardListDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + if (!plan.secretsBrokering) return []; + + 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 []; + + 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) => { + acc[cred.serviceId] = acc[cred.serviceId] || []; + acc[cred.serviceId].push(cred); + return acc; + }, {}); + + return services.map((svc) => ({ + ...svc, + folder: { ...svc.folder, path: canonicalSecretPath }, + 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..4232f2e620f --- /dev/null +++ b/backend/src/ee/services/proxied-service/proxied-service-types.ts @@ -0,0 +1,76 @@ +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; + // accepted for parity with the dashboard router's query param; the DAL always orders by name + 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..74e4bdd48fc 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", @@ -1469,6 +1470,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 +1489,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; @@ -1601,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: { + 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: { + 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/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index a1f765e37f5..65ec398ff50 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, + secretV2BridgeService, + 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..db14e9b60a2 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -16,6 +16,10 @@ import { 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"; @@ -289,6 +293,18 @@ export const SanitizedHoneyTokenSchema = HoneyTokensSchema.pick({ }) }); +export const SanitizedProxiedServiceSchema = SanitizedProxiedServiceBaseSchema.extend({ + environment: z.object({ + id: z.string(), + name: z.string(), + slug: z.string() + }), + folder: z.object({ + path: z.string() + }), + credentials: SanitizedProxiedServiceCredentialSchema.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/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-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/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}" +--- diff --git a/docs/cli/commands/agent-proxy.mdx b/docs/cli/commands/agent-proxy.mdx new file mode 100644 index 00000000000..488032d41ec --- /dev/null +++ b/docs/cli/commands/agent-proxy.mdx @@ -0,0 +1,257 @@ +--- +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=:17322 --projectId= --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. 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. + +```bash +$ 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 +``` + +### 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=: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=: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=: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=: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=: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=: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=:17322 --env=prod --token= -- 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=:17322 --env=prod --allow-readable-brokered-secrets -- claude + ``` + + Default value: `false` + + + + + + + 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..8e1c4a205af 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,17 @@ "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-by-id", + "api-reference/endpoints/proxied-services/get-by-name", + "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..75e11e9825d --- /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 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. + +## 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. + + + 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. + + + 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..5914a53f6cf --- /dev/null +++ b/docs/documentation/platform/agent-proxy/proxied-services.mdx @@ -0,0 +1,153 @@ +--- +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 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. + +## 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 applies one or more secrets in two ways, **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 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 + +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. + + + + 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. + + +## Using Secrets + +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 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 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: + +```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..75e7619ec80 --- /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. Running more than one agent? See [how many machine identities you need](/documentation/platform/agent-proxy/security#how-many-machine-identities-do-you-need). + + + + 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..02d9a9b5e1e --- /dev/null +++ b/docs/documentation/platform/agent-proxy/security.mdx @@ -0,0 +1,134 @@ +--- +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 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. + +## 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 + +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. + + + Place the proxy according to the [network placement](#network-placement) guidance above; it is built for private-network deployment. + + +## Permissions + +Proxied services have their own project-level permission subject, with five actions: + +| 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 | + +**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 + +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. + + +### 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: + +- **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 + +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). diff --git a/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx b/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx new file mode 100644 index 00000000000..248c76861cf --- /dev/null +++ b/frontend/src/components/proxied-services/CreateProxiedServiceModal.tsx @@ -0,0 +1,39 @@ +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 ( + + + + Create Proxied Service + + Define a service the agent proxy can broker secrets 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..6039c178f5e --- /dev/null +++ b/frontend/src/components/proxied-services/DeleteProxiedServiceModal.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from "react"; +import { Trash2Icon } from "lucide-react"; + +import { createNotification } from "@app/components/notifications"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + 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"; + +type Props = { + proxiedService?: TDashboardProxiedService; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +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 }); + 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. + + +
{ + 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/EditProxiedServiceModal.tsx b/frontend/src/components/proxied-services/EditProxiedServiceModal.tsx new file mode 100644 index 00000000000..bb817b32e40 --- /dev/null +++ b/frontend/src/components/proxied-services/EditProxiedServiceModal.tsx @@ -0,0 +1,39 @@ +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..b284ada37b3 --- /dev/null +++ b/frontend/src/components/proxied-services/forms/ProxiedServiceForm.tsx @@ -0,0 +1,578 @@ +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"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Field, + FieldContent, + FieldDescription, + FieldError, + FieldLabel, + FieldTitle, + IconButton, + Input, + SheetFooter, + Switch, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Tooltip, + TooltipContent, + TooltipTrigger +} 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, + 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, + // omit rather than send null: the API field is optional and rejects null + headerPrefix: h.headerPrefix || undefined + }); + }); + } + + 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, + setValue, + 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" }); + + // 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 { + if (isEdit && proxiedService) { + await updateProxiedService.mutateAsync({ + serviceId: proxiedService.id, + 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 (err) { + 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${detail ? `: ${detail}` : ""}`, + type: "error" + }); + } + }; + + return ( +
+
+ + Service Name + + + Lowercase letters, numbers, and hyphens only. + + + + + + + Host Pattern + + + + + +

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

+

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

+
+
+
+ + + + +
+ + ( + + + Enabled + + When off, the proxy stops brokering this service's traffic. + + + + + )} + /> + +
+ setValue("headerMode", value as HeaderRewritingMode)} + > +
+
+

Header Rewrites

+

Sets these headers on every request.

+
+ + Custom Headers + Basic Auth + +
+ + +
+ {headerFields.fields.length === 0 && ( +

+ No headers added. Click below to add. +

+ )} + {headerFields.fields.map((row, i) => ( +
+ + {i === 0 && Name} + + + + + + + {i === 0 && Prefix} + + + + + + {i === 0 && Value} + + ( + + )} + /> + + + + headerFields.remove(i)} + > + + +
+ ))} +
+
+ +
+ {headersRootError && {headersRootError}} +
+ + +
+ + Username + + ( + + )} + /> + + + + + Password + + ( + + )} + /> + + + +
+
+
+
+ +
+
+

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. +

+
+ +
+ {substitutionFields.fields.length === 0 && ( +
+ No substitutions added. Click below to add. +
+ )} + {substitutionFields.fields.map((row, i) => ( +
+
+ 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)} + > + + +
+ + +
+ 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..c8a48412f8a --- /dev/null +++ b/frontend/src/components/proxied-services/forms/SecretSelect.tsx @@ -0,0 +1,149 @@ +import { useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { KeyIcon, LockIcon } from "lucide-react"; + +import { FilterableSelect, Tooltip, TooltipContent, TooltipTrigger } from "@app/components/v3"; +import { useProjectPermission } from "@app/context"; +import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; +import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries"; +import { SecretV3RawResponse } from "@app/hooks/api/secrets/types"; +import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; + +type Props = { + projectId: string; + environment: string; + secretPath: string; + value?: string; + onChange: (value: string) => void; + isDisabled?: boolean; + isError?: boolean; + placeholder?: string; +}; + +// Attaching a secret requires ReadValue on the backend; describe-only secrets show disabled. +// Imported secrets are selectable too (the backend resolves them through the folder's imports). +type SecretOption = { label: string; value: string; isReadable: boolean }; + +const formatSecretOption = (option: SecretOption) => { + const row = ( +
+ {option.isReadable ? ( + + ) : ( + + )} + {option.label} +
+ ); + + if (option.isReadable) return row; + return ( + + {row} + + You need read access to this secret's value to use it in a proxied service. + + + ); +}; + +export const SecretSelect = ({ + projectId, + environment, + secretPath, + value, + onChange, + isDisabled, + isError, + placeholder = "Select a secret" +}: Props) => { + const { permission } = useProjectPermission(); + + const buildOptions = useCallback( + (response: SecretV3RawResponse) => { + const options: SecretOption[] = []; + const seen = new Set(); + + response.secrets.forEach((secret) => { + if (seen.has(secret.secretKey)) return; + seen.add(secret.secretKey); + options.push({ + label: secret.secretKey, + value: secret.secretKey, + isReadable: hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment, + secretPath, + secretName: secret.secretKey, + secretTags: secret.tags?.map((tag) => tag.slug) ?? [] + } + ) + }); + }); + + // folder-local keys win over imported ones; among imports, later imports take priority + const imports = response.imports ?? []; + for (let i = imports.length - 1; i >= 0; i -= 1) { + const group = imports[i]; + group.secrets.forEach((secret) => { + if (seen.has(secret.secretKey)) return; + seen.add(secret.secretKey); + options.push({ + label: secret.secretKey, + value: secret.secretKey, + isReadable: hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment: group.environment, + secretPath: group.secretPath, + secretName: secret.secretKey, + secretTags: secret.tags?.map((tag) => tag.slug) ?? [] + } + ) + }); + }); + } + + return options; + }, + [permission, environment, secretPath] + ); + + const { data: options = [], isPending } = useQuery({ + enabled: Boolean(projectId && environment), + staleTime: 0, + queryKey: secretKeys.getProjectSecret({ + projectId, + environment, + secretPath, + viewSecretValue: false + }), + queryFn: () => + fetchProjectSecrets({ projectId, environment, secretPath, viewSecretValue: false }), + select: buildOptions + }); + + // a stale/selected key not in the fetched list stays visible and selectable so the reference persists + const selected = + options.find((option) => option.value === value) ?? + (value ? { label: value, value, isReadable: true } : null); + + return ( + !(option as SecretOption).isReadable} + onChange={(newValue) => onChange((newValue as SecretOption | null)?.value ?? "")} + getOptionLabel={(option) => option.label} + getOptionValue={(option) => option.value} + formatOptionLabel={formatSecretOption} + /> + ); +}; diff --git a/frontend/src/components/proxied-services/forms/SurfaceSelect.tsx b/frontend/src/components/proxied-services/forms/SurfaceSelect.tsx new file mode 100644 index 00000000000..cbfbbe26005 --- /dev/null +++ b/frontend/src/components/proxied-services/forms/SurfaceSelect.tsx @@ -0,0 +1,72 @@ +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; +}; + +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..6fa23dfa661 --- /dev/null +++ b/frontend/src/components/proxied-services/forms/schema.ts @@ -0,0 +1,166 @@ +import { z } from "zod"; + +import { ProxiedServiceSubstitutionSurface } from "@app/hooks/api/proxiedServices/enums"; + +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` + }); + } + }); + }); + +// 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(), + headerPrefix: z.string().trim().optional() +}); + +const basicAuthSchema = z.object({ + usernameSecretKey: z.string().trim(), + passwordSecretKey: z.string().trim() +}); + +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: hostPatternField, + 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) { + const seenHeaderNames = new Map(); + form.headers.forEach((row, i) => { + if (!row.secretKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Select a secret", + path: ["headers", i, "secretKey"] + }); + } + if (!row.headerName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + 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); + } + }); + 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({ + 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"] + }); + } + } + + 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/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/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/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/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 f9d62d0b987..f713c756ba6 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -405,6 +405,10 @@ export const eventToNameMap: { [K in EventType]: string } = { [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.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 8739be061f6..cbf730a9e2a 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -398,6 +398,12 @@ export enum EventType { 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", + SIGN_AGENT_PROXY_INTERMEDIATE_CA = "sign-agent-proxy-intermediate-ca", + // Dynamic Secret Leases CREATE_DYNAMIC_SECRET_LEASE = "create-dynamic-secret-lease", DELETE_DYNAMIC_SECRET_LEASE = "delete-dynamic-secret-lease", diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx index 09dd9dfc59a..acf1d919bef 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 }); @@ -280,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, @@ -296,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 @@ -319,6 +326,7 @@ export const useGetProjectSecretsDetails = ( includeDynamicSecrets, includeSecretRotations, includeHoneyTokens, + includeProxiedServices, tags }: TGetDashboardProjectSecretsDetailsDTO, options?: Omit< @@ -358,6 +366,7 @@ export const useGetProjectSecretsDetails = ( includeDynamicSecrets, includeSecretRotations, includeHoneyTokens, + includeProxiedServices, tags }), queryFn: async () => { @@ -376,6 +385,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..2d6376569ad --- /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, + TProxiedServiceBase, + 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({ + mutationFn: async ({ serviceId, ...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: TProxiedServiceBase }>( + `/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..fbb5a709619 --- /dev/null +++ b/frontend/src/hooks/api/proxiedServices/queries.tsx @@ -0,0 +1,3 @@ +export const proxiedServiceKeys = { + all: ["proxiedServices"] as const +}; diff --git a/frontend/src/hooks/api/proxiedServices/types.ts b/frontend/src/hooks/api/proxiedServices/types.ts new file mode 100644 index 00000000000..be26cdddc7a --- /dev/null +++ b/frontend/src/hooks/api/proxiedServices/types.ts @@ -0,0 +1,76 @@ +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 TProxiedServiceBase = { + id: string; + name: string; + hostPattern: string; + isEnabled: boolean; + folderId: string; + createdAt: string; + updatedAt: string; +}; + +export type TProxiedService = TProxiedServiceBase & { + credentials: TProxiedServiceCredential[]; +}; + +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; + name?: string; + hostPattern?: string; + isEnabled?: boolean; + credentials?: TProxiedServiceCredentialInput[]; +}; + +export type TDeleteProxiedServiceDTO = { + serviceId: 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/index.css b/frontend/src/index.css index bf1efa7b00f..8c5fd69f6d7 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: #7d4ca9; /*legacy color schema */ --color-org-v1: #30b3ff; 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..b7aafd6cc5b 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,10 +688,12 @@ const OverviewPageContent = () => { dynamicSecrets, secretRotations, honeyTokens, + proxiedServices, totalFolderCount, totalSecretCount, totalDynamicSecretCount, totalSecretRotationCount, + totalProxiedServiceCount, totalImportCount, totalCount = 0, totalUniqueFoldersInPage, @@ -687,6 +702,7 @@ const OverviewPageContent = () => { totalUniqueDynamicSecretsInPage, totalUniqueSecretRotationsInPage, totalUniqueHoneyTokensInPage, + totalUniqueProxiedServicesInPage, importedByEnvs, usedBySecretSyncs } = overview ?? {}; @@ -732,6 +748,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 +876,9 @@ const OverviewPageContent = () => { "addDynamicSecret", "addSecretRotation", "addHoneyToken", + "addProxiedService", + "editProxiedService", + "deleteProxiedService", "editSecretRotation", "rotateSecretRotation", "viewSecretRotationGeneratedCredentials", @@ -2465,6 +2487,7 @@ const OverviewPageContent = () => { dynamicSecretNames.length === 0 && secretRotationNames.length === 0 && honeyTokenNames.length === 0 && + proxiedServiceNames.length === 0 && secretImportNames.length === 0 && !isOverviewLoading; @@ -2683,6 +2706,16 @@ 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} @@ -3317,6 +3350,22 @@ const OverviewPageContent = () => { } /> ))} + {proxiedServiceNames.map((proxiedServiceName, index) => ( + + handlePopUpOpen("editProxiedService", proxiedService) + } + onDelete={(proxiedService) => + handlePopUpOpen("deleteProxiedService", proxiedService) + } + /> + ))} {mergedSecKeys.map((key, index) => ( { (totalUniqueSecretsInPage || 0) - (totalUniqueSecretImportsInPage || 0) - (totalUniqueSecretRotationsInPage || 0) - - (totalUniqueHoneyTokensInPage || 0), + (totalUniqueHoneyTokensInPage || 0) - + (totalUniqueProxiedServicesInPage || 0), 0 )} /> @@ -3377,6 +3427,7 @@ const OverviewPageContent = () => { folderCount={totalFolderCount} importCount={totalImportCount} secretRotationCount={totalSecretRotationCount} + proxiedServiceCount={totalProxiedServiceCount} /> } count={totalCount} @@ -3624,6 +3675,24 @@ const OverviewPageContent = () => { 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} + /> void; @@ -33,6 +35,7 @@ type Props = { onAddDyanamicSecret: () => void; onAddSecretRotation: () => void; onAddHoneyToken: () => void; + onAddProxiedService: () => void; onAddSecretImport: () => void; onImportSecrets: () => void; onReplicateSecrets: () => void; @@ -54,6 +57,7 @@ export function AddResourceButtons({ onAddDyanamicSecret, onAddSecretRotation, onAddHoneyToken, + onAddProxiedService, onAddSecretImport, onImportSecrets, onReplicateSecrets, @@ -157,6 +161,29 @@ export function AddResourceButtons({ )} + + {(isAllowed) => ( + + + + + Add Proxied Service + + + + {!isAllowed + ? "Access Restricted" + : "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..5f3cb79eafc --- /dev/null +++ b/frontend/src/pages/secret-manager/OverviewPage/components/ProxiedServiceTableRow/ProxiedServiceTableRow.tsx @@ -0,0 +1,252 @@ +import { subject } from "@casl/ability"; +import { + BanIcon, + ChevronDownIcon, + ChevronsLeftRightEllipsisIcon, + 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/ResourceCount/ResourceCount.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/ResourceCount/ResourceCount.tsx index a82277fe2cd..642a4e0c9bc 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 { + ChevronsLeftRightEllipsisIcon, + 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 && ( 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..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,4 +1,5 @@ import { + ChevronsLeftRightEllipsisIcon, 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/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";