diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 917caa95a08..0a67636cfde 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -98,6 +98,7 @@ import { TAllowedFields } from "@app/services/identity-ldap-auth/identity-ldap-a import { TIdentityOciAuthServiceFactory } from "@app/services/identity-oci-auth/identity-oci-auth-service"; import { TIdentityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; +import { TIdentitySpiffeAuthServiceFactory } from "@app/services/identity-spiffe-auth/identity-spiffe-auth-service"; import { TIdentityTlsCertAuthServiceFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-types"; import { TIdentityTokenAuthServiceFactory } from "@app/services/identity-token-auth/identity-token-auth-service"; import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; @@ -287,6 +288,7 @@ declare module "fastify" { identityOciAuth: TIdentityOciAuthServiceFactory; identityOidcAuth: TIdentityOidcAuthServiceFactory; identityJwtAuth: TIdentityJwtAuthServiceFactory; + identitySpiffeAuth: TIdentitySpiffeAuthServiceFactory; identityLdapAuth: TIdentityLdapAuthServiceFactory; accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; accessApprovalRequest: TAccessApprovalRequestServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 7fa629333a8..4b08f28d84f 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -641,6 +641,11 @@ import { TIdentityLdapAuthsInsert, TIdentityLdapAuthsUpdate } from "@app/db/schemas/identity-ldap-auths"; +import { + TIdentitySpiffeAuths, + TIdentitySpiffeAuthsInsert, + TIdentitySpiffeAuthsUpdate +} from "@app/db/schemas/identity-spiffe-auths"; import { TMicrosoftTeamsIntegrations, TMicrosoftTeamsIntegrationsInsert, @@ -1070,6 +1075,11 @@ declare module "knex/types/tables" { TIdentityLdapAuthsInsert, TIdentityLdapAuthsUpdate >; + [TableName.IdentitySpiffeAuth]: KnexOriginal.CompositeTableType< + TIdentitySpiffeAuths, + TIdentitySpiffeAuthsInsert, + TIdentitySpiffeAuthsUpdate + >; [TableName.IdentityUaClientSecret]: KnexOriginal.CompositeTableType< TIdentityUaClientSecrets, TIdentityUaClientSecretsInsert, diff --git a/backend/src/db/migrations/20260305201413_add-spiffe-machine-auth.ts b/backend/src/db/migrations/20260305201413_add-spiffe-machine-auth.ts new file mode 100644 index 00000000000..8029d6c26e8 --- /dev/null +++ b/backend/src/db/migrations/20260305201413_add-spiffe-machine-auth.ts @@ -0,0 +1,37 @@ +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.IdentitySpiffeAuth))) { + await knex.schema.createTable(TableName.IdentitySpiffeAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("trustDomain").notNullable(); + t.string("allowedSpiffeIds").notNullable(); + t.string("allowedAudiences").notNullable(); + t.string("configurationType").notNullable(); + t.binary("encryptedCaBundleJwks").nullable(); + t.string("bundleEndpointUrl").nullable(); + t.string("bundleEndpointProfile").nullable(); + t.binary("encryptedBundleEndpointCaCert").nullable(); + t.binary("encryptedCachedBundleJwks").nullable(); + t.datetime("cachedBundleLastRefreshedAt").nullable(); + t.integer("bundleRefreshHintSeconds").defaultTo(300).notNullable(); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentitySpiffeAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentitySpiffeAuth); + await dropOnUpdateTrigger(knex, TableName.IdentitySpiffeAuth); +} diff --git a/backend/src/db/schemas/identity-spiffe-auths.ts b/backend/src/db/schemas/identity-spiffe-auths.ts new file mode 100644 index 00000000000..5073ea7f2e2 --- /dev/null +++ b/backend/src/db/schemas/identity-spiffe-auths.ts @@ -0,0 +1,36 @@ +// 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 IdentitySpiffeAuthsSchema = z.object({ + id: z.string().uuid(), + identityId: z.string().uuid(), + trustDomain: z.string(), + allowedSpiffeIds: z.string(), + allowedAudiences: z.string(), + configurationType: z.string(), + encryptedCaBundleJwks: zodBuffer.nullable().optional(), + bundleEndpointUrl: z.string().nullable().optional(), + bundleEndpointProfile: z.string().nullable().optional(), + encryptedBundleEndpointCaCert: zodBuffer.nullable().optional(), + encryptedCachedBundleJwks: zodBuffer.nullable().optional(), + cachedBundleLastRefreshedAt: z.date().nullable().optional(), + bundleRefreshHintSeconds: z.coerce.number().default(300), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentitySpiffeAuths = z.infer; +export type TIdentitySpiffeAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentitySpiffeAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 73e0727ce94..39b3b203a9e 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -72,6 +72,7 @@ export * from "./identity-org-memberships"; export * from "./identity-project-additional-privilege"; export * from "./identity-project-membership-role"; export * from "./identity-project-memberships"; +export * from "./identity-spiffe-auths"; export * from "./identity-tls-cert-auths"; export * from "./identity-token-auths"; export * from "./identity-ua-client-secrets"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index a927eb79e6e..2cacd9447c5 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -103,6 +103,7 @@ export enum TableName { IdentityJwtAuth = "identity_jwt_auths", IdentityLdapAuth = "identity_ldap_auths", IdentityTlsCertAuth = "identity_tls_cert_auths", + IdentitySpiffeAuth = "identity_spiffe_auths", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", @@ -343,7 +344,8 @@ export enum IdentityAuthMethod { OCI_AUTH = "oci-auth", OIDC_AUTH = "oidc-auth", JWT_AUTH = "jwt-auth", - LDAP_AUTH = "ldap-auth" + LDAP_AUTH = "ldap-auth", + SPIFFE_AUTH = "spiffe-auth" } export enum ProjectType { 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 0cf50028cd8..347efc02588 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -216,6 +216,13 @@ export enum EventType { GET_IDENTITY_JWT_AUTH = "get-identity-jwt-auth", REVOKE_IDENTITY_JWT_AUTH = "revoke-identity-jwt-auth", + LOGIN_IDENTITY_SPIFFE_AUTH = "login-identity-spiffe-auth", + ADD_IDENTITY_SPIFFE_AUTH = "add-identity-spiffe-auth", + UPDATE_IDENTITY_SPIFFE_AUTH = "update-identity-spiffe-auth", + GET_IDENTITY_SPIFFE_AUTH = "get-identity-spiffe-auth", + REVOKE_IDENTITY_SPIFFE_AUTH = "revoke-identity-spiffe-auth", + REFRESH_IDENTITY_SPIFFE_AUTH_BUNDLE = "refresh-identity-spiffe-auth-bundle", + CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts", @@ -1840,6 +1847,66 @@ interface GetIdentityJwtAuthEvent { }; } +interface LoginIdentitySpiffeAuthEvent { + type: EventType.LOGIN_IDENTITY_SPIFFE_AUTH; + metadata: { + identityId: string; + identitySpiffeAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentitySpiffeAuthEvent { + type: EventType.ADD_IDENTITY_SPIFFE_AUTH; + metadata: { + identityId: string; + trustDomain: string; + allowedSpiffeIds: string; + allowedAudiences: string; + configurationType: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentitySpiffeAuthEvent { + type: EventType.UPDATE_IDENTITY_SPIFFE_AUTH; + metadata: { + identityId: string; + trustDomain?: string; + allowedSpiffeIds?: string; + allowedAudiences?: string; + configurationType?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface DeleteIdentitySpiffeAuthEvent { + type: EventType.REVOKE_IDENTITY_SPIFFE_AUTH; + metadata: { + identityId: string; + }; +} + +interface GetIdentitySpiffeAuthEvent { + type: EventType.GET_IDENTITY_SPIFFE_AUTH; + metadata: { + identityId: string; + }; +} + +interface RefreshIdentitySpiffeAuthBundleEvent { + type: EventType.REFRESH_IDENTITY_SPIFFE_AUTH_BUNDLE; + metadata: { + identityId: string; + }; +} + interface CreateEnvironmentEvent { type: EventType.CREATE_ENVIRONMENT; metadata: { @@ -5171,6 +5238,12 @@ export type Event = | UpdateIdentityJwtAuthEvent | GetIdentityJwtAuthEvent | DeleteIdentityJwtAuthEvent + | LoginIdentitySpiffeAuthEvent + | AddIdentitySpiffeAuthEvent + | UpdateIdentitySpiffeAuthEvent + | GetIdentitySpiffeAuthEvent + | RefreshIdentitySpiffeAuthBundleEvent + | DeleteIdentitySpiffeAuthEvent | LoginIdentityLdapAuthEvent | AddIdentityLdapAuthEvent | UpdateIdentityLdapAuthEvent diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index a022ee23f09..902073d5314 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -32,6 +32,7 @@ export enum ApiDocsTags { AzureAuth = "Azure Auth", KubernetesAuth = "Kubernetes Auth", JwtAuth = "JWT Auth", + SpiffeAuth = "SPIFFE Auth", OidcAuth = "OIDC Auth", LdapAuth = "LDAP Auth", Groups = "Groups", @@ -745,6 +746,61 @@ export const JWT_AUTH = { } } as const; +export const SPIFFE_AUTH = { + LOGIN: { + identityId: "The ID of the machine identity to login.", + jwt: "The JWT-SVID token to authenticate with.", + organizationSlug: IDENTITY_AUTH_SUB_ORGANIZATION_NAME + }, + ATTACH: { + identityId: "The ID of the machine identity to attach the configuration onto.", + trustDomain: "The SPIFFE trust domain (e.g. prod.example.com).", + allowedSpiffeIds: + "Comma-separated list of allowed SPIFFE ID patterns. Supports picomatch glob patterns (e.g. spiffe://prod.example.com/**).", + allowedAudiences: "Comma-separated list of allowed audiences for JWT-SVID validation.", + configurationType: + "The configuration type for trust bundle management. Must be one of: 'static' (admin uploads JWKS), 'remote' (auto-refresh from SPIRE bundle endpoint).", + caBundleJwks: + "The JWKS JSON containing public keys for JWT-SVID verification. Required if configurationType is 'static'.", + bundleEndpointUrl: + "The SPIRE bundle endpoint URL for automatic trust bundle retrieval. Required if configurationType is 'remote'.", + bundleEndpointProfile: + "The bundle endpoint authentication profile. Must be one of: 'https_web' (standard HTTPS), 'https_spiffe' (mTLS with SPIFFE auth).", + bundleEndpointCaCert: + "The PEM-encoded CA certificate for verifying the bundle endpoint TLS connection. Required when bundleEndpointProfile is 'https_spiffe'.", + bundleRefreshHintSeconds: "The interval in seconds between bundle refresh attempts. Defaults to 300.", + accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from.", + accessTokenTTL: "The lifetime for an access token in seconds.", + accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", + accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." + }, + UPDATE: { + identityId: "The ID of the machine identity to update the auth method for.", + trustDomain: "The new SPIFFE trust domain.", + allowedSpiffeIds: "The new comma-separated list of allowed SPIFFE ID patterns.", + allowedAudiences: "The new comma-separated list of allowed audiences.", + configurationType: "The new configuration type for trust bundle management.", + caBundleJwks: "The new JWKS JSON containing public keys.", + bundleEndpointUrl: "The new SPIRE bundle endpoint URL.", + bundleEndpointProfile: "The new bundle endpoint authentication profile.", + bundleEndpointCaCert: "The new PEM-encoded CA certificate for the bundle endpoint.", + bundleRefreshHintSeconds: "The new interval in seconds between bundle refresh attempts.", + accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.", + accessTokenTTL: "The new lifetime for an access token in seconds.", + accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", + accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." + }, + RETRIEVE: { + identityId: "The ID of the machine identity to retrieve the auth method for." + }, + REVOKE: { + identityId: "The ID of the machine identity to revoke the auth method for." + }, + REFRESH: { + identityId: "The ID of the machine identity to force-refresh the cached SPIFFE trust bundle for." + } +} as const; + export const ORGANIZATIONS = { LIST_USER_MEMBERSHIPS: { organizationId: "The ID of the organization to get memberships from." diff --git a/backend/src/lib/telemetry/metrics.ts b/backend/src/lib/telemetry/metrics.ts index 0a4efff9aba..2f8516fa8f0 100644 --- a/backend/src/lib/telemetry/metrics.ts +++ b/backend/src/lib/telemetry/metrics.ts @@ -23,7 +23,8 @@ export enum AuthAttemptAuthMethod { OCI_AUTH = "oci-auth", OIDC_AUTH = "oidc-auth", JWT_AUTH = "jwt-auth", - LDAP_AUTH = "ldap-auth" + LDAP_AUTH = "ldap-auth", + SPIFFE_AUTH = "spiffe-auth" } export enum AuthAttemptAuthResult { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index ede9548abad..be81ed4db8f 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -287,6 +287,8 @@ import { identityOidcAuthDALFactory } from "@app/services/identity-oidc-auth/ide import { identityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-service"; import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; +import { identitySpiffeAuthDALFactory } from "@app/services/identity-spiffe-auth/identity-spiffe-auth-dal"; +import { identitySpiffeAuthServiceFactory } from "@app/services/identity-spiffe-auth/identity-spiffe-auth-service"; import { identityTlsCertAuthDALFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-dal"; import { identityTlsCertAuthServiceFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-service"; import { identityTokenAuthDALFactory } from "@app/services/identity-token-auth/identity-token-auth-dal"; @@ -527,6 +529,7 @@ export const registerRoutes = async ( const identityOciAuthDAL = identityOciAuthDALFactory(db); const identityOidcAuthDAL = identityOidcAuthDALFactory(db); const identityJwtAuthDAL = identityJwtAuthDALFactory(db); + const identitySpiffeAuthDAL = identitySpiffeAuthDALFactory(db); const identityAzureAuthDAL = identityAzureAuthDALFactory(db); const identityLdapAuthDAL = identityLdapAuthDALFactory(db); @@ -1950,6 +1953,17 @@ export const registerRoutes = async ( membershipIdentityDAL }); + const identitySpiffeAuthService = identitySpiffeAuthServiceFactory({ + identityDAL, + identitySpiffeAuthDAL, + orgDAL, + permissionService, + identityAccessTokenDAL, + licenseService, + kmsService, + membershipIdentityDAL + }); + const identityLdapAuthService = identityLdapAuthServiceFactory({ identityLdapAuthDAL, orgDAL, @@ -2832,6 +2846,7 @@ export const registerRoutes = async ( identityTlsCertAuth: identityTlsCertAuthService, identityOidcAuth: identityOidcAuthService, identityJwtAuth: identityJwtAuthService, + identitySpiffeAuth: identitySpiffeAuthService, identityLdapAuth: identityLdapAuthService, accessApprovalPolicy: accessApprovalPolicyService, accessApprovalRequest: accessApprovalRequestService, diff --git a/backend/src/server/routes/v1/identity-spiffe-auth-router.ts b/backend/src/server/routes/v1/identity-spiffe-auth-router.ts new file mode 100644 index 00000000000..2c105f10410 --- /dev/null +++ b/backend/src/server/routes/v1/identity-spiffe-auth-router.ts @@ -0,0 +1,430 @@ +import { z } from "zod"; + +import { IdentitySpiffeAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, SPIFFE_AUTH } 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"; +import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { + SpiffeBundleEndpointProfile, + SpiffeConfigurationType +} from "@app/services/identity-spiffe-auth/identity-spiffe-auth-types"; +import { + validateSpiffeAllowedAudiencesField, + validateSpiffeAllowedIdsField, + validateTrustDomain +} from "@app/services/identity-spiffe-auth/identity-spiffe-auth-validators"; +import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; + +const IdentitySpiffeAuthResponseSchema = IdentitySpiffeAuthsSchema.omit({ + encryptedCaBundleJwks: true, + encryptedBundleEndpointCaCert: true, + encryptedCachedBundleJwks: true +}).extend({ + caBundleJwks: z.string(), + bundleEndpointCaCert: z.string() +}); + +const CommonCreateFields = z.object({ + trustDomain: validateTrustDomain.describe(SPIFFE_AUTH.ATTACH.trustDomain), + allowedSpiffeIds: validateSpiffeAllowedIdsField.describe(SPIFFE_AUTH.ATTACH.allowedSpiffeIds), + allowedAudiences: validateSpiffeAllowedAudiencesField.describe(SPIFFE_AUTH.ATTACH.allowedAudiences), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(SPIFFE_AUTH.ATTACH.accessTokenTrustedIps), + accessTokenTTL: z.number().int().min(0).max(315360000).default(2592000).describe(SPIFFE_AUTH.ATTACH.accessTokenTTL), + accessTokenMaxTTL: z + .number() + .int() + .min(0) + .max(315360000) + .default(2592000) + .describe(SPIFFE_AUTH.ATTACH.accessTokenMaxTTL), + accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(SPIFFE_AUTH.ATTACH.accessTokenNumUsesLimit) +}); + +const CommonUpdateFields = z + .object({ + trustDomain: validateTrustDomain.describe(SPIFFE_AUTH.UPDATE.trustDomain), + allowedSpiffeIds: validateSpiffeAllowedIdsField.describe(SPIFFE_AUTH.UPDATE.allowedSpiffeIds), + allowedAudiences: validateSpiffeAllowedAudiencesField.describe(SPIFFE_AUTH.UPDATE.allowedAudiences), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .describe(SPIFFE_AUTH.UPDATE.accessTokenTrustedIps), + accessTokenTTL: z.number().int().min(0).max(315360000).describe(SPIFFE_AUTH.UPDATE.accessTokenTTL), + accessTokenMaxTTL: z.number().int().min(0).max(315360000).describe(SPIFFE_AUTH.UPDATE.accessTokenMaxTTL), + accessTokenNumUsesLimit: z.number().int().min(0).describe(SPIFFE_AUTH.UPDATE.accessTokenNumUsesLimit) + }) + .partial(); + +const StaticConfigurationSchema = z.object({ + configurationType: z.literal(SpiffeConfigurationType.STATIC).describe(SPIFFE_AUTH.ATTACH.configurationType), + caBundleJwks: z.string().min(1).describe(SPIFFE_AUTH.ATTACH.caBundleJwks), + bundleEndpointUrl: z.string().optional().default(""), + bundleEndpointProfile: z.nativeEnum(SpiffeBundleEndpointProfile).optional(), + bundleEndpointCaCert: z.string().optional().default(""), + bundleRefreshHintSeconds: z.number().int().min(0).optional().default(3600) +}); + +const RemoteConfigurationSchema = z.object({ + configurationType: z.literal(SpiffeConfigurationType.REMOTE).describe(SPIFFE_AUTH.ATTACH.configurationType), + caBundleJwks: z.string().optional().default(""), + bundleEndpointUrl: z + .string() + .trim() + .url() + .refine((url) => url.startsWith("https://"), "Bundle endpoint URL must use HTTPS") + .describe(SPIFFE_AUTH.ATTACH.bundleEndpointUrl), + bundleEndpointProfile: z + .nativeEnum(SpiffeBundleEndpointProfile) + .default(SpiffeBundleEndpointProfile.HTTPS_WEB) + .describe(SPIFFE_AUTH.ATTACH.bundleEndpointProfile), + bundleEndpointCaCert: z.string().optional().default("").describe(SPIFFE_AUTH.ATTACH.bundleEndpointCaCert), + bundleRefreshHintSeconds: z.number().int().min(0).default(3600).describe(SPIFFE_AUTH.ATTACH.bundleRefreshHintSeconds) +}); + +export const registerIdentitySpiffeAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/spiffe-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + operationId: "loginWithSpiffeAuth", + tags: [ApiDocsTags.SpiffeAuth], + description: "Login with SPIFFE Auth (JWT-SVID) for machine identity", + body: z.object({ + identityId: z.string().trim().describe(SPIFFE_AUTH.LOGIN.identityId), + jwt: z.string().trim().describe(SPIFFE_AUTH.LOGIN.jwt), + organizationSlug: slugSchema().optional().describe(SPIFFE_AUTH.LOGIN.organizationSlug) + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identitySpiffeAuth, accessToken, identityAccessToken, identity } = + await server.services.identitySpiffeAuth.login(req.body); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identity.orgId, + event: { + type: EventType.LOGIN_IDENTITY_SPIFFE_AUTH, + metadata: { + identityId: identitySpiffeAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identitySpiffeAuthId: identitySpiffeAuth.id + } + } + }); + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identitySpiffeAuth.accessTokenTTL, + accessTokenMaxTTL: identitySpiffeAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/spiffe-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + operationId: "attachSpiffeAuth", + tags: [ApiDocsTags.SpiffeAuth], + description: "Attach SPIFFE Auth configuration onto machine identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(SPIFFE_AUTH.ATTACH.identityId) + }), + body: z.discriminatedUnion("configurationType", [ + StaticConfigurationSchema.merge(CommonCreateFields), + RemoteConfigurationSchema.merge(CommonCreateFields) + ]), + response: { + 200: z.object({ + identitySpiffeAuth: IdentitySpiffeAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identitySpiffeAuth = await server.services.identitySpiffeAuth.attachSpiffeAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identitySpiffeAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_SPIFFE_AUTH, + metadata: { + identityId: identitySpiffeAuth.identityId, + trustDomain: identitySpiffeAuth.trustDomain, + allowedSpiffeIds: identitySpiffeAuth.allowedSpiffeIds, + allowedAudiences: identitySpiffeAuth.allowedAudiences, + configurationType: identitySpiffeAuth.configurationType, + accessTokenTTL: identitySpiffeAuth.accessTokenTTL, + accessTokenMaxTTL: identitySpiffeAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identitySpiffeAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identitySpiffeAuth.accessTokenNumUsesLimit + } + } + }); + + return { + identitySpiffeAuth + }; + } + }); + + server.route({ + method: "PATCH", + url: "/spiffe-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + operationId: "updateSpiffeAuth", + tags: [ApiDocsTags.SpiffeAuth], + description: "Update SPIFFE Auth configuration on machine identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(SPIFFE_AUTH.UPDATE.identityId) + }), + body: z.discriminatedUnion("configurationType", [ + StaticConfigurationSchema.merge(CommonUpdateFields), + RemoteConfigurationSchema.merge(CommonUpdateFields) + ]), + response: { + 200: z.object({ + identitySpiffeAuth: IdentitySpiffeAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identitySpiffeAuth = await server.services.identitySpiffeAuth.updateSpiffeAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identitySpiffeAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_SPIFFE_AUTH, + metadata: { + identityId: identitySpiffeAuth.identityId, + trustDomain: identitySpiffeAuth.trustDomain, + allowedSpiffeIds: identitySpiffeAuth.allowedSpiffeIds, + allowedAudiences: identitySpiffeAuth.allowedAudiences, + configurationType: identitySpiffeAuth.configurationType, + accessTokenTTL: identitySpiffeAuth.accessTokenTTL, + accessTokenMaxTTL: identitySpiffeAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identitySpiffeAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identitySpiffeAuth.accessTokenNumUsesLimit + } + } + }); + + return { identitySpiffeAuth }; + } + }); + + server.route({ + method: "GET", + url: "/spiffe-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + operationId: "getSpiffeAuth", + tags: [ApiDocsTags.SpiffeAuth], + description: "Retrieve SPIFFE Auth configuration on machine identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(SPIFFE_AUTH.RETRIEVE.identityId) + }), + response: { + 200: z.object({ + identitySpiffeAuth: IdentitySpiffeAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identitySpiffeAuth = await server.services.identitySpiffeAuth.getSpiffeAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identitySpiffeAuth.orgId, + event: { + type: EventType.GET_IDENTITY_SPIFFE_AUTH, + metadata: { + identityId: identitySpiffeAuth.identityId + } + } + }); + + return { identitySpiffeAuth }; + } + }); + + server.route({ + method: "POST", + url: "/spiffe-auth/identities/:identityId/refresh-bundle", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + operationId: "refreshSpiffeBundle", + tags: [ApiDocsTags.SpiffeAuth], + description: "Force-refresh the cached SPIFFE trust bundle for a remote-configured machine identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(SPIFFE_AUTH.REFRESH.identityId) + }), + response: { + 200: z.object({ + identitySpiffeAuth: IdentitySpiffeAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identitySpiffeAuth = await server.services.identitySpiffeAuth.refreshSpiffeBundle({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identitySpiffeAuth.orgId, + event: { + type: EventType.REFRESH_IDENTITY_SPIFFE_AUTH_BUNDLE, + metadata: { + identityId: identitySpiffeAuth.identityId + } + } + }); + + return { identitySpiffeAuth }; + } + }); + + server.route({ + method: "DELETE", + url: "/spiffe-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + operationId: "deleteSpiffeAuth", + tags: [ApiDocsTags.SpiffeAuth], + description: "Delete SPIFFE Auth configuration on machine identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(SPIFFE_AUTH.REVOKE.identityId) + }), + response: { + 200: z.object({ + identitySpiffeAuth: IdentitySpiffeAuthResponseSchema.omit({ + caBundleJwks: true, + bundleEndpointCaCert: true + }) + }) + } + }, + handler: async (req) => { + const identitySpiffeAuth = await server.services.identitySpiffeAuth.revokeSpiffeAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identitySpiffeAuth.orgId, + event: { + type: EventType.REVOKE_IDENTITY_SPIFFE_AUTH, + metadata: { + identityId: identitySpiffeAuth.identityId + } + } + }); + + return { identitySpiffeAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 3d57df4518a..6b69c2c7ea5 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -45,6 +45,7 @@ import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router"; import { registerIdentityOrgMembershipRouter } from "./identity-org-membership-router"; import { registerIdentityProjectMembershipRouter } from "./identity-project-membership-router"; import { registerIdentityRouter } from "./identity-router"; +import { registerIdentitySpiffeAuthRouter } from "./identity-spiffe-auth-router"; import { registerIdentityTlsCertAuthRouter } from "./identity-tls-cert-auth-router"; import { registerIdentityTokenAuthRouter } from "./identity-token-auth-router"; import { registerIdentityUaRouter } from "./identity-universal-auth-router"; @@ -98,6 +99,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await authRouter.register(registerIdentityOciAuthRouter); await authRouter.register(registerIdentityOidcAuthRouter); await authRouter.register(registerIdentityJwtAuthRouter); + await authRouter.register(registerIdentitySpiffeAuthRouter); await authRouter.register(registerIdentityLdapAuthRouter); }, { prefix: "/auth" } diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index c264717d0f8..936f7c8c8e5 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -9,12 +9,16 @@ import { TIdentityAwsAuths, TIdentityAzureAuths, TIdentityGcpAuths, + TIdentityJwtAuths, TIdentityKubernetesAuths, TIdentityOciAuths, TIdentityOidcAuths, + TIdentitySpiffeAuths, + TIdentityTlsCertAuths, TIdentityTokenAuths, TIdentityUniversalAuths } from "@app/db/schemas"; +import { TIdentityLdapAuths } from "@app/db/schemas/identity-ldap-auths"; import { DatabaseError } from "@app/lib/errors"; import { selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; import { OrderByDirection } from "@app/lib/types"; @@ -81,6 +85,26 @@ export const identityProjectDALFactory = (db: TDbClient) => { `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityTokenAuth}.identityId` ) + .leftJoin( + TableName.IdentityJwtAuth, + `${TableName.Membership}.actorIdentityId`, + `${TableName.IdentityJwtAuth}.identityId` + ) + .leftJoin( + TableName.IdentityLdapAuth, + `${TableName.Membership}.actorIdentityId`, + `${TableName.IdentityLdapAuth}.identityId` + ) + .leftJoin( + TableName.IdentityTlsCertAuth, + `${TableName.Membership}.actorIdentityId`, + `${TableName.IdentityTlsCertAuth}.identityId` + ) + .leftJoin( + TableName.IdentitySpiffeAuth, + `${TableName.Membership}.actorIdentityId`, + `${TableName.IdentitySpiffeAuth}.identityId` + ) .select( db.ref("id").withSchema(TableName.Membership), @@ -112,7 +136,11 @@ export const identityProjectDALFactory = (db: TDbClient) => { db.ref("id").as("ociId").withSchema(TableName.IdentityOciAuth), db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), - db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth) + db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), + db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), + db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth), + db.ref("id").as("spiffeId").withSchema(TableName.IdentitySpiffeAuth) ); const members = sqlNestRelationships({ @@ -127,6 +155,10 @@ export const identityProjectDALFactory = (db: TDbClient) => { oidcId, azureId, tokenId, + jwtId, + ldapId, + tlsCertId, + spiffeId, id, createdAt, updatedAt, @@ -149,7 +181,11 @@ export const identityProjectDALFactory = (db: TDbClient) => { kubernetesId, oidcId, azureId, - tokenId + tokenId, + jwtId, + ldapId, + tlsCertId, + spiffeId }) }, project: { @@ -285,6 +321,26 @@ export const identityProjectDALFactory = (db: TDbClient) => { `${TableName.Identity}.id`, `${TableName.IdentityTokenAuth}.identityId` ) + .leftJoin( + TableName.IdentityJwtAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityJwtAuth}.identityId` + ) + .leftJoin( + TableName.IdentityLdapAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityLdapAuth}.identityId` + ) + .leftJoin( + TableName.IdentityTlsCertAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityTlsCertAuth}.identityId` + ) + .leftJoin( + TableName.IdentitySpiffeAuth, + `${TableName.Identity}.id`, + `${TableName.IdentitySpiffeAuth}.identityId` + ) .select( db.ref("id").withSchema(TableName.Membership), @@ -317,7 +373,11 @@ export const identityProjectDALFactory = (db: TDbClient) => { db.ref("id").as("ociId").withSchema(TableName.IdentityOciAuth), db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), - db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth) + db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), + db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), + db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth), + db.ref("id").as("spiffeId").withSchema(TableName.IdentitySpiffeAuth) ); // TODO: scott - joins seem to reorder identities so need to order again, for the sake of urgency will optimize at a later point @@ -349,6 +409,10 @@ export const identityProjectDALFactory = (db: TDbClient) => { oidcId, azureId, tokenId, + jwtId, + ldapId, + tlsCertId, + spiffeId, id, createdAt, updatedAt, @@ -374,7 +438,11 @@ export const identityProjectDALFactory = (db: TDbClient) => { ociId, oidcId, azureId, - tokenId + tokenId, + jwtId, + ldapId, + tlsCertId, + spiffeId }) }, // TODO: scott - not sure why these aren't properly typed? diff --git a/backend/src/services/identity-spiffe-auth/identity-spiffe-auth-dal.ts b/backend/src/services/identity-spiffe-auth/identity-spiffe-auth-dal.ts new file mode 100644 index 00000000000..4d7523469a6 --- /dev/null +++ b/backend/src/services/identity-spiffe-auth/identity-spiffe-auth-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentitySpiffeAuthDALFactory = ReturnType; + +export const identitySpiffeAuthDALFactory = (db: TDbClient) => { + const spiffeAuthOrm = ormify(db, TableName.IdentitySpiffeAuth); + + return { ...spiffeAuthOrm }; +}; diff --git a/backend/src/services/identity-spiffe-auth/identity-spiffe-auth-fns.ts b/backend/src/services/identity-spiffe-auth/identity-spiffe-auth-fns.ts new file mode 100644 index 00000000000..022df6d117e --- /dev/null +++ b/backend/src/services/identity-spiffe-auth/identity-spiffe-auth-fns.ts @@ -0,0 +1,96 @@ +import https from "https"; +import picomatch from "picomatch"; +import RE2 from "re2"; + +const SPIFFE_ID_REGEX = new RE2("^spiffe:\\/\\/([^/]+)(\\/.*)?"); + +export const isValidSpiffeId = (value: string): boolean => { + return SPIFFE_ID_REGEX.test(value); +}; + +export const extractTrustDomainFromSpiffeId = (spiffeId: string): string => { + const match = SPIFFE_ID_REGEX.exec(spiffeId); + if (!match) { + throw new Error(`Invalid SPIFFE ID: ${spiffeId}`); + } + return match[1]; +}; + +export const doesSpiffeIdMatchPattern = (spiffeId: string, patterns: string): boolean => { + const patternList = patterns + .split(",") + .map((p) => p.trim()) + .filter(Boolean); + + return patternList.some((pattern) => picomatch.isMatch(spiffeId, pattern)); +}; + +export const findSigningKeyInJwks = (jwksJson: string, kid: string) => { + const jwks = JSON.parse(jwksJson) as { + keys: Array<{ kid?: string; use?: string; kty: string; [key: string]: unknown }>; + }; + + if (!jwks.keys || !Array.isArray(jwks.keys)) { + throw new Error("Invalid JWKS: missing keys array"); + } + + const matchingKey = jwks.keys.find((key) => key.kid === kid); + + if (!matchingKey) { + throw new Error(`No key found in JWKS matching kid: ${kid}`); + } + + return matchingKey; +}; + +const BUNDLE_FETCH_TIMEOUT_MS = 10_000; +const MAX_BUNDLE_SIZE_BYTES = 1_048_576; // 1 MB + +export const fetchRemoteBundleJwks = (url: string, caCert?: string): Promise => { + const parsedUrl = new URL(url); + + return new Promise((resolve, reject) => { + let totalBytes = 0; + const chunks: Buffer[] = []; + + const req = https.request( + { + hostname: parsedUrl.hostname, + port: parsedUrl.port || 443, + path: parsedUrl.pathname + parsedUrl.search, + method: "GET", + agent: caCert ? new https.Agent({ ca: caCert, rejectUnauthorized: true }) : undefined, + timeout: BUNDLE_FETCH_TIMEOUT_MS + }, + (res) => { + if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { + req.destroy(new Error(`Failed to fetch SPIFFE trust bundle: HTTP ${res.statusCode}`)); + return; + } + + const contentLengthHeader = res.headers["content-length"]; + if (contentLengthHeader && parseInt(contentLengthHeader, 10) > MAX_BUNDLE_SIZE_BYTES) { + req.destroy(new Error("SPIFFE trust bundle response exceeds maximum allowed size")); + return; + } + + res.on("data", (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > MAX_BUNDLE_SIZE_BYTES) { + req.destroy(new Error("SPIFFE trust bundle response exceeds maximum allowed size")); + return; + } + chunks.push(chunk); + }); + + res.on("end", () => { + resolve(Buffer.concat(chunks).toString()); + }); + } + ); + + req.on("timeout", () => req.destroy(new Error("SPIFFE trust bundle fetch timed out"))); + req.on("error", reject); + req.end(); + }); +}; diff --git a/backend/src/services/identity-spiffe-auth/identity-spiffe-auth-service.ts b/backend/src/services/identity-spiffe-auth/identity-spiffe-auth-service.ts new file mode 100644 index 00000000000..9ab43767fa8 --- /dev/null +++ b/backend/src/services/identity-spiffe-auth/identity-spiffe-auth-service.ts @@ -0,0 +1,905 @@ +import { ForbiddenError, subject } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; +import { decodeProtectedHeader, errors as joseErrors, importJWK, jwtVerify } from "jose"; + +import { + AccessScope, + ActionProjectType, + IdentityAuthMethod, + OrganizationActionScope, + TIdentitySpiffeAuthsUpdate +} from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { getConfig } from "@app/lib/config/env"; +import { crypto } from "@app/lib/crypto"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { logger } from "@app/lib/logger"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; + +import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { KmsDataKey } from "../kms/kms-types"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; +import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; +import { TIdentitySpiffeAuthDALFactory } from "./identity-spiffe-auth-dal"; +import { + doesSpiffeIdMatchPattern, + extractTrustDomainFromSpiffeId, + fetchRemoteBundleJwks, + findSigningKeyInJwks, + isValidSpiffeId +} from "./identity-spiffe-auth-fns"; +import { + SpiffeConfigurationType, + TAttachSpiffeAuthDTO, + TGetSpiffeAuthDTO, + TLoginSpiffeAuthDTO, + TRevokeSpiffeAuthDTO, + TUpdateSpiffeAuthDTO +} from "./identity-spiffe-auth-types"; + +type TIdentitySpiffeAuthServiceFactoryDep = { + identityDAL: Pick; + identitySpiffeAuthDAL: TIdentitySpiffeAuthDALFactory; + membershipIdentityDAL: Pick; + identityAccessTokenDAL: Pick; + permissionService: Pick; + licenseService: Pick; + kmsService: Pick; + orgDAL: Pick; +}; + +export type TIdentitySpiffeAuthServiceFactory = ReturnType; + +const verifyJwtSvid = async (jwtValue: string, jwksJson: string) => { + const header = decodeProtectedHeader(jwtValue); + if (!header.kid) { + throw new UnauthorizedError({ message: "JWT missing kid header" }); + } + + let jwk; + try { + jwk = findSigningKeyInJwks(jwksJson, header.kid); + } catch { + throw new UnauthorizedError({ message: `No key found in JWKS matching kid: ${header.kid}` }); + } + const key = await importJWK(jwk, header.alg); + + try { + const { payload } = await jwtVerify(jwtValue, key); + return { tokenData: payload as Record, kid: header.kid }; + } catch (error) { + if (error instanceof joseErrors.JWTExpired) { + throw new UnauthorizedError({ message: "JWT-SVID has expired" }); + } + if (error instanceof joseErrors.JWSSignatureVerificationFailed) { + throw new UnauthorizedError({ message: "JWT-SVID signature verification failed" }); + } + throw new UnauthorizedError({ message: "JWT-SVID verification failed" }); + } +}; + +const validateSpiffeClaims = ( + tokenData: Record, + config: { allowedAudiences: string; trustDomain: string; allowedSpiffeIds: string } +): boolean => { + if (!tokenData.aud) return false; + + const tokenAudiences: string[] = Array.isArray(tokenData.aud) + ? (tokenData.aud as string[]) + : [tokenData.aud as string]; + const allowedAudiences = config.allowedAudiences + .split(", ") + .map((a) => a.trim()) + .filter(Boolean); + + if (!tokenAudiences.some((aud) => allowedAudiences.includes(aud))) return false; + + const tokenSub = tokenData.sub as string; + if (!tokenSub || !isValidSpiffeId(tokenSub)) return false; + + const tokenTrustDomain = extractTrustDomainFromSpiffeId(tokenSub); + if (tokenTrustDomain !== config.trustDomain) return false; + + if (!doesSpiffeIdMatchPattern(tokenSub, config.allowedSpiffeIds)) return false; + + return true; +}; + +export const identitySpiffeAuthServiceFactory = ({ + identityDAL, + identitySpiffeAuthDAL, + membershipIdentityDAL, + permissionService, + licenseService, + identityAccessTokenDAL, + kmsService, + orgDAL +}: TIdentitySpiffeAuthServiceFactoryDep) => { + const resolveJwks = async ({ + config, + orgId, + forceRefresh + }: { + config: { + id: string; + configurationType: string; + encryptedCaBundleJwks?: Buffer | null; + encryptedCachedBundleJwks?: Buffer | null; + bundleEndpointUrl?: string | null; + bundleEndpointProfile?: string | null; + encryptedBundleEndpointCaCert?: Buffer | null; + bundleRefreshHintSeconds?: number | null; + cachedBundleLastRefreshedAt?: Date | string | null; + }; + orgId: string; + forceRefresh?: boolean; + }): Promise<{ jwksJson: string; fromCache: boolean }> => { + const { decryptor: orgDataKeyDecryptor, encryptor: orgDataKeyEncryptor } = + await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + if (config.configurationType === SpiffeConfigurationType.STATIC) { + if (!config.encryptedCaBundleJwks) { + throw new BadRequestError({ message: "Static SPIFFE auth has no CA bundle JWKS configured" }); + } + + return { + jwksJson: orgDataKeyDecryptor({ cipherTextBlob: config.encryptedCaBundleJwks }).toString(), + fromCache: false + }; + } + + // Remote configuration: check cache freshness + if (!forceRefresh && config.encryptedCachedBundleJwks) { + const refreshIntervalMs = (config.bundleRefreshHintSeconds || 3600) * 1000; + const lastRefreshed = config.cachedBundleLastRefreshedAt + ? new Date(config.cachedBundleLastRefreshedAt).getTime() + : 0; + + if (Date.now() - lastRefreshed < refreshIntervalMs) { + return { + jwksJson: orgDataKeyDecryptor({ cipherTextBlob: config.encryptedCachedBundleJwks }).toString(), + fromCache: true + }; + } + } + + // Fetch fresh bundle + if (!config.bundleEndpointUrl) { + throw new BadRequestError({ message: "Remote SPIFFE auth has no bundle endpoint URL configured" }); + } + + await blockLocalAndPrivateIpAddresses(config.bundleEndpointUrl); + + let caCert: string | undefined; + if (config.bundleEndpointProfile === "https_spiffe" && config.encryptedBundleEndpointCaCert) { + caCert = orgDataKeyDecryptor({ cipherTextBlob: config.encryptedBundleEndpointCaCert }).toString(); + } + + let bundleJson: string; + try { + bundleJson = await fetchRemoteBundleJwks(config.bundleEndpointUrl, caCert); + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + throw new BadRequestError({ message: `Failed to fetch SPIFFE trust bundle from remote endpoint: ${msg}` }); + } + + const { cipherTextBlob: encryptedCachedBundleJwks } = orgDataKeyEncryptor({ + plainText: Buffer.from(bundleJson) + }); + + await identitySpiffeAuthDAL.updateById(config.id, { + encryptedCachedBundleJwks, + cachedBundleLastRefreshedAt: new Date() + }); + + logger.info(`SPIFFE auth: refreshed JWKS bundle for config ${config.id}`); + + return { jwksJson: bundleJson, fromCache: false }; + }; + + const login = async ({ identityId, jwt: jwtValue, organizationSlug }: TLoginSpiffeAuthDTO) => { + const appCfg = getConfig(); + const identitySpiffeAuth = await identitySpiffeAuthDAL.findOne({ identityId }); + if (!identitySpiffeAuth) { + throw new NotFoundError({ + message: "SPIFFE auth method not found for identity, did you configure SPIFFE auth?" + }); + } + + const identity = await identityDAL.findById(identitySpiffeAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); + + const org = await orgDAL.findById(identity.orgId); + const isSubOrgIdentity = Boolean(org.rootOrgId); + let subOrganizationId = isSubOrgIdentity ? org.id : null; + + try { + // Resolve JWKS (lazy fetch for remote, decrypt for static) + let { jwksJson, fromCache } = await resolveJwks({ config: identitySpiffeAuth, orgId: identity.orgId }); + + let tokenData: Record; + try { + ({ tokenData } = await verifyJwtSvid(jwtValue, jwksJson)); + } catch (verifyError) { + // Kid-miss retry: if we used a cached JWKS and the kid wasn't found, force-refresh once + if (fromCache && verifyError instanceof Error && verifyError.message.includes("No key found in JWKS")) { + ({ jwksJson, fromCache } = await resolveJwks({ + config: identitySpiffeAuth, + orgId: identity.orgId, + forceRefresh: true + })); + ({ tokenData } = await verifyJwtSvid(jwtValue, jwksJson)); + } else { + throw verifyError; + } + } + + if (!validateSpiffeClaims(tokenData, identitySpiffeAuth)) { + throw new UnauthorizedError({ message: "Access denied" }); + } + + // Sub-org resolution + if (organizationSlug) { + if (!isSubOrgIdentity) { + const subOrg = await orgDAL.findOne({ rootOrgId: org.id, slug: organizationSlug }); + if (!subOrg) { + throw new NotFoundError({ message: `Sub organization with slug ${organizationSlug} not found` }); + } + + const subOrgMembership = await orgDAL.findEffectiveOrgMembership({ + actorType: ActorType.IDENTITY, + actorId: identity.id, + orgId: subOrg.id + }); + + if (!subOrgMembership) { + throw new UnauthorizedError({ + message: `Identity not authorized to access sub organization ${organizationSlug}` + }); + } + + subOrganizationId = subOrg.id; + } + } + + const identityAccessToken = await identitySpiffeAuthDAL.transaction(async (tx) => { + await membershipIdentityDAL.update( + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, + { + lastLoginAuthMethod: IdentityAuthMethod.SPIFFE_AUTH, + lastLoginTime: new Date() + }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identitySpiffeAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identitySpiffeAuth.accessTokenTTL, + accessTokenMaxTTL: identitySpiffeAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identitySpiffeAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.SPIFFE_AUTH, + subOrganizationId + }, + tx + ); + + return newToken; + }); + + let expireyOptions: { expiresIn: number } | undefined; + const accessTokenTTL = Number(identityAccessToken.accessTokenTTL); + if (accessTokenTTL > 0) { + expireyOptions = { expiresIn: accessTokenTTL }; + } + + const accessToken = crypto.jwt().sign( + { + identityId: identitySpiffeAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + expireyOptions + ); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identitySpiffeAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.SPIFFE_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + + return { accessToken, identitySpiffeAuth, identityAccessToken, identity }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identitySpiffeAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.SPIFFE_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } + }; + + const attachSpiffeAuth = async ({ + identityId, + trustDomain, + allowedSpiffeIds, + allowedAudiences, + configurationType, + caBundleJwks, + bundleEndpointUrl, + bundleEndpointProfile, + bundleEndpointCaCert, + bundleRefreshHintSeconds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId, + isActorSuperAdmin + }: TAttachSpiffeAuthDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.orgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } + if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.SPIFFE_AUTH)) { + throw new BadRequestError({ + message: "Failed to add SPIFFE Auth to already configured identity" + }); + } + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } + + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + if (bundleEndpointUrl) { + await blockLocalAndPrivateIpAddresses(bundleEndpointUrl); + } + + const { encryptor: orgDataKeyEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + + const encryptedCaBundleJwks = caBundleJwks + ? orgDataKeyEncryptor({ plainText: Buffer.from(caBundleJwks) }).cipherTextBlob + : null; + + const encryptedBundleEndpointCaCert = bundleEndpointCaCert + ? orgDataKeyEncryptor({ plainText: Buffer.from(bundleEndpointCaCert) }).cipherTextBlob + : null; + + const identitySpiffeAuth = await identitySpiffeAuthDAL.transaction(async (tx) => { + const doc = await identitySpiffeAuthDAL.create( + { + identityId: identityMembershipOrg.identity.id, + trustDomain, + allowedSpiffeIds, + allowedAudiences, + configurationType, + encryptedCaBundleJwks, + bundleEndpointUrl: bundleEndpointUrl || null, + bundleEndpointProfile: bundleEndpointProfile || null, + encryptedBundleEndpointCaCert, + bundleRefreshHintSeconds: bundleRefreshHintSeconds || 300, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + + return doc; + }); + + return { + ...identitySpiffeAuth, + orgId: identityMembershipOrg.scopeOrgId, + caBundleJwks: caBundleJwks || "", + bundleEndpointCaCert: bundleEndpointCaCert || "" + }; + }; + + const updateSpiffeAuth = async ({ + identityId, + trustDomain, + allowedSpiffeIds, + allowedAudiences, + configurationType, + caBundleJwks, + bundleEndpointUrl, + bundleEndpointProfile, + bundleEndpointCaCert, + bundleRefreshHintSeconds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateSpiffeAuthDTO) => { + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.orgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.SPIFFE_AUTH)) { + throw new BadRequestError({ message: "Failed to update SPIFFE Auth" }); + } + + const identitySpiffeAuth = await identitySpiffeAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identitySpiffeAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identitySpiffeAuth.accessTokenTTL) > + (accessTokenMaxTTL || identitySpiffeAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } + + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + if (bundleEndpointUrl) { + await blockLocalAndPrivateIpAddresses(bundleEndpointUrl); + } + + const updateQuery: TIdentitySpiffeAuthsUpdate = { + trustDomain, + allowedSpiffeIds, + allowedAudiences, + configurationType, + bundleEndpointUrl, + bundleEndpointProfile, + bundleRefreshHintSeconds, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }; + + const { encryptor: orgDataKeyEncryptor, decryptor: orgDataKeyDecryptor } = + await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + + if (caBundleJwks !== undefined) { + updateQuery.encryptedCaBundleJwks = caBundleJwks + ? orgDataKeyEncryptor({ plainText: Buffer.from(caBundleJwks) }).cipherTextBlob + : null; + } + + if (bundleEndpointCaCert !== undefined) { + updateQuery.encryptedBundleEndpointCaCert = bundleEndpointCaCert + ? orgDataKeyEncryptor({ plainText: Buffer.from(bundleEndpointCaCert) }).cipherTextBlob + : null; + } + + const updatedSpiffeAuth = await identitySpiffeAuthDAL.updateById(identitySpiffeAuth.id, updateQuery); + + const decryptedCaBundleJwks = updatedSpiffeAuth.encryptedCaBundleJwks + ? orgDataKeyDecryptor({ cipherTextBlob: updatedSpiffeAuth.encryptedCaBundleJwks }).toString() + : ""; + const decryptedBundleEndpointCaCert = updatedSpiffeAuth.encryptedBundleEndpointCaCert + ? orgDataKeyDecryptor({ cipherTextBlob: updatedSpiffeAuth.encryptedBundleEndpointCaCert }).toString() + : ""; + + return { + ...updatedSpiffeAuth, + orgId: identityMembershipOrg.scopeOrgId, + caBundleJwks: decryptedCaBundleJwks, + bundleEndpointCaCert: decryptedBundleEndpointCaCert + }; + }; + + const getSpiffeAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetSpiffeAuthDTO) => { + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.orgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.SPIFFE_AUTH)) { + throw new BadRequestError({ message: "The identity does not have SPIFFE Auth attached" }); + } + + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } + + const identitySpiffeAuth = await identitySpiffeAuthDAL.findOne({ identityId }); + + const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + + const decryptedCaBundleJwks = identitySpiffeAuth.encryptedCaBundleJwks + ? orgDataKeyDecryptor({ cipherTextBlob: identitySpiffeAuth.encryptedCaBundleJwks }).toString() + : ""; + const decryptedBundleEndpointCaCert = identitySpiffeAuth.encryptedBundleEndpointCaCert + ? orgDataKeyDecryptor({ cipherTextBlob: identitySpiffeAuth.encryptedBundleEndpointCaCert }).toString() + : ""; + + return { + ...identitySpiffeAuth, + orgId: identityMembershipOrg.scopeOrgId, + caBundleJwks: decryptedCaBundleJwks, + bundleEndpointCaCert: decryptedBundleEndpointCaCert + }; + }; + + const revokeSpiffeAuth = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TRevokeSpiffeAuthDTO) => { + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); + if (!identityMembershipOrg) { + throw new NotFoundError({ message: "Failed to find identity" }); + } + if (identityMembershipOrg.identity.orgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.SPIFFE_AUTH)) { + throw new BadRequestError({ message: "The identity does not have SPIFFE auth" }); + } + + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke SPIFFE auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + + const revokedIdentitySpiffeAuth = await identitySpiffeAuthDAL.transaction(async (tx) => { + const deletedSpiffeAuth = await identitySpiffeAuthDAL.delete({ identityId }, tx); + await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.SPIFFE_AUTH }, tx); + + return { ...deletedSpiffeAuth?.[0], orgId: identityMembershipOrg.scopeOrgId }; + }); + + return revokedIdentitySpiffeAuth; + }; + + const refreshSpiffeBundle = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TGetSpiffeAuthDTO) => { + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.orgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.SPIFFE_AUTH)) { + throw new BadRequestError({ message: "The identity does not have SPIFFE Auth attached" }); + } + + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } + + const identitySpiffeAuth = await identitySpiffeAuthDAL.findOne({ identityId }); + + if (identitySpiffeAuth.configurationType !== SpiffeConfigurationType.REMOTE) { + throw new BadRequestError({ + message: "Bundle refresh is only applicable to identities with remote SPIFFE Auth configuration" + }); + } + + await resolveJwks({ config: identitySpiffeAuth, orgId: identityMembershipOrg.scopeOrgId, forceRefresh: true }); + + const refreshed = await identitySpiffeAuthDAL.findOne({ identityId }); + + const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + + const decryptedCaBundleJwks = refreshed.encryptedCaBundleJwks + ? orgDataKeyDecryptor({ cipherTextBlob: refreshed.encryptedCaBundleJwks }).toString() + : ""; + const decryptedBundleEndpointCaCert = refreshed.encryptedBundleEndpointCaCert + ? orgDataKeyDecryptor({ cipherTextBlob: refreshed.encryptedBundleEndpointCaCert }).toString() + : ""; + + return { + ...refreshed, + orgId: identityMembershipOrg.scopeOrgId, + caBundleJwks: decryptedCaBundleJwks, + bundleEndpointCaCert: decryptedBundleEndpointCaCert + }; + }; + + return { + login, + attachSpiffeAuth, + updateSpiffeAuth, + getSpiffeAuth, + revokeSpiffeAuth, + refreshSpiffeBundle + }; +}; diff --git a/backend/src/services/identity-spiffe-auth/identity-spiffe-auth-types.ts b/backend/src/services/identity-spiffe-auth/identity-spiffe-auth-types.ts new file mode 100644 index 00000000000..e75c5f7faeb --- /dev/null +++ b/backend/src/services/identity-spiffe-auth/identity-spiffe-auth-types.ts @@ -0,0 +1,60 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum SpiffeConfigurationType { + STATIC = "static", + REMOTE = "remote" +} + +export enum SpiffeBundleEndpointProfile { + HTTPS_WEB = "https_web", + HTTPS_SPIFFE = "https_spiffe" +} + +export type TLoginSpiffeAuthDTO = { + identityId: string; + jwt: string; + organizationSlug?: string; +}; + +export type TAttachSpiffeAuthDTO = { + identityId: string; + trustDomain: string; + allowedSpiffeIds: string; + allowedAudiences: string; + configurationType: SpiffeConfigurationType; + caBundleJwks?: string; + bundleEndpointUrl?: string; + bundleEndpointProfile?: SpiffeBundleEndpointProfile; + bundleEndpointCaCert?: string; + bundleRefreshHintSeconds?: number; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; + isActorSuperAdmin?: boolean; +} & Omit; + +export type TUpdateSpiffeAuthDTO = { + identityId: string; + trustDomain?: string; + allowedSpiffeIds?: string; + allowedAudiences?: string; + configurationType?: SpiffeConfigurationType; + caBundleJwks?: string; + bundleEndpointUrl?: string; + bundleEndpointProfile?: SpiffeBundleEndpointProfile; + bundleEndpointCaCert?: string; + bundleRefreshHintSeconds?: number; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetSpiffeAuthDTO = { + identityId: string; +} & Omit; + +export type TRevokeSpiffeAuthDTO = { + identityId: string; +} & Omit; diff --git a/backend/src/services/identity-spiffe-auth/identity-spiffe-auth-validators.ts b/backend/src/services/identity-spiffe-auth/identity-spiffe-auth-validators.ts new file mode 100644 index 00000000000..cffcc6c8a7b --- /dev/null +++ b/backend/src/services/identity-spiffe-auth/identity-spiffe-auth-validators.ts @@ -0,0 +1,34 @@ +import RE2 from "re2"; +import { z } from "zod"; + +export const validateSpiffeAllowedAudiencesField = z + .string() + .trim() + .min(1, "At least one audience is required") + .transform((data) => { + return data + .split(",") + .map((id) => id.trim()) + .filter(Boolean) + .join(", "); + }); + +export const validateSpiffeAllowedIdsField = z + .string() + .trim() + .min(1, "At least one SPIFFE ID pattern is required") + .transform((data) => { + return data + .split(",") + .map((id) => id.trim()) + .filter(Boolean) + .join(", "); + }); + +const TRUST_DOMAIN_REGEX = new RE2("^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$"); + +export const validateTrustDomain = z + .string() + .trim() + .min(1, "Trust domain is required") + .refine((val) => TRUST_DOMAIN_REGEX.test(val), "Invalid trust domain format"); diff --git a/backend/src/services/identity-v2/identity-dal.ts b/backend/src/services/identity-v2/identity-dal.ts index 72c9a16c85e..7e594227f9c 100644 --- a/backend/src/services/identity-v2/identity-dal.ts +++ b/backend/src/services/identity-v2/identity-dal.ts @@ -43,6 +43,7 @@ export const identityV2DALFactory = (db: TDbClient) => { ) .leftJoin(TableName.IdentityLdapAuth, `${TableName.Identity}.id`, `${TableName.IdentityLdapAuth}.identityId`) .leftJoin(TableName.IdentityJwtAuth, `${TableName.Identity}.id`, `${TableName.IdentityJwtAuth}.identityId`) + .leftJoin(TableName.IdentitySpiffeAuth, `${TableName.Identity}.id`, `${TableName.IdentitySpiffeAuth}.identityId`) .where(`${TableName.Identity}.id`, identityId) .where(`${TableName.Identity}.orgId`, scopeData.orgId) .where((qb) => { @@ -68,7 +69,8 @@ export const identityV2DALFactory = (db: TDbClient) => { db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), - db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth) + db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth), + db.ref("id").as("spiffeId").withSchema(TableName.IdentitySpiffeAuth) ); if (!doc) return doc; @@ -89,7 +91,8 @@ export const identityV2DALFactory = (db: TDbClient) => { jwtId, ociId, ldapId, - tlsCertId + tlsCertId, + spiffeId } = el; return { ...IdentitiesSchema.parse(el), @@ -105,7 +108,8 @@ export const identityV2DALFactory = (db: TDbClient) => { jwtId, ldapId, ociId, - tlsCertId + tlsCertId, + spiffeId }) }; }, diff --git a/backend/src/services/identity/identity-dal.ts b/backend/src/services/identity/identity-dal.ts index a2e4995ca4d..5a07f383b53 100644 --- a/backend/src/services/identity/identity-dal.ts +++ b/backend/src/services/identity/identity-dal.ts @@ -21,7 +21,8 @@ export const identityDALFactory = (db: TDbClient) => { [IdentityAuthMethod.OCI_AUTH]: TableName.IdentityOciAuth, [IdentityAuthMethod.OIDC_AUTH]: TableName.IdentityOidcAuth, [IdentityAuthMethod.JWT_AUTH]: TableName.IdentityJwtAuth, - [IdentityAuthMethod.LDAP_AUTH]: TableName.IdentityLdapAuth + [IdentityAuthMethod.LDAP_AUTH]: TableName.IdentityLdapAuth, + [IdentityAuthMethod.SPIFFE_AUTH]: TableName.IdentitySpiffeAuth } as const; const tableName = authMethodToTableName[authMethod]; if (!tableName) return; diff --git a/backend/src/services/identity/identity-fns.ts b/backend/src/services/identity/identity-fns.ts index dee87fc4965..189948d01ca 100644 --- a/backend/src/services/identity/identity-fns.ts +++ b/backend/src/services/identity/identity-fns.ts @@ -12,7 +12,8 @@ export const buildAuthMethods = ({ tokenId, jwtId, ldapId, - tlsCertId + tlsCertId, + spiffeId }: { uaId?: string; gcpId?: string; @@ -26,6 +27,7 @@ export const buildAuthMethods = ({ jwtId?: string; ldapId?: string; tlsCertId?: string; + spiffeId?: string; }) => { return [ ...[uaId ? IdentityAuthMethod.UNIVERSAL_AUTH : null], @@ -39,6 +41,7 @@ export const buildAuthMethods = ({ ...[tokenId ? IdentityAuthMethod.TOKEN_AUTH : null], ...[jwtId ? IdentityAuthMethod.JWT_AUTH : null], ...[ldapId ? IdentityAuthMethod.LDAP_AUTH : null], - ...[tlsCertId ? IdentityAuthMethod.TLS_CERT_AUTH : null] + ...[tlsCertId ? IdentityAuthMethod.TLS_CERT_AUTH : null], + ...[spiffeId ? IdentityAuthMethod.SPIFFE_AUTH : null] ].filter((authMethod) => authMethod) as IdentityAuthMethod[]; }; diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index 117195ca718..893fcaa69ad 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -12,6 +12,7 @@ import { TIdentityKubernetesAuths, TIdentityOciAuths, TIdentityOidcAuths, + TIdentitySpiffeAuths, TIdentityTlsCertAuths, TIdentityTokenAuths, TIdentityUniversalAuths, @@ -106,6 +107,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityTlsCertAuth}.identityId` ) + .leftJoin( + TableName.IdentitySpiffeAuth, + `${TableName.Membership}.actorIdentityId`, + `${TableName.IdentitySpiffeAuth}.identityId` + ) .select( selectAllTableCols(TableName.Membership), @@ -121,6 +127,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth), + db.ref("id").as("spiffeId").withSchema(TableName.IdentitySpiffeAuth), db.ref("name").withSchema(TableName.Identity), db.ref("hasDeleteProtection").withSchema(TableName.Identity) ); @@ -251,6 +258,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { "paginatedIdentity.actorIdentityId", `${TableName.IdentityTlsCertAuth}.identityId` ) + .leftJoin( + TableName.IdentitySpiffeAuth, + "paginatedIdentity.actorIdentityId", + `${TableName.IdentitySpiffeAuth}.identityId` + ) .select( db.ref("id").withSchema("paginatedIdentity"), db.ref("role").withSchema(TableName.MembershipRole), @@ -276,7 +288,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), - db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth) + db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth), + db.ref("id").as("spiffeId").withSchema(TableName.IdentitySpiffeAuth) ) // cr stands for custom role .select(db.ref("id").as("crId").withSchema(TableName.Role)) @@ -323,6 +336,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { tokenId, ldapId, tlsCertId, + spiffeId, createdAt, updatedAt, lastLoginAuthMethod, @@ -363,7 +377,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { tokenId, jwtId, ldapId, - tlsCertId + tlsCertId, + spiffeId }) } }), @@ -504,6 +519,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityLdapAuth}.identityId` ) + .leftJoin( + TableName.IdentitySpiffeAuth, + `${TableName.Membership}.actorIdentityId`, + `${TableName.IdentitySpiffeAuth}.identityId` + ) .select( db.ref("id").withSchema(TableName.Membership), db.ref("total_count").withSchema("searchedIdentities"), @@ -529,7 +549,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), - db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth) + db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), + db.ref("id").as("spiffeId").withSchema(TableName.IdentitySpiffeAuth) ) // cr stands for custom role .select(db.ref("id").as("crId").withSchema(TableName.Role)) @@ -587,6 +608,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { azureId, tokenId, ldapId, + spiffeId, createdAt, updatedAt, lastLoginTime, @@ -627,7 +649,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { azureId, tokenId, jwtId, - ldapId + ldapId, + spiffeId }) } }), diff --git a/backend/src/services/membership-identity/membership-identity-dal.ts b/backend/src/services/membership-identity/membership-identity-dal.ts index 5b8490eb9d4..042fe1566e8 100644 --- a/backend/src/services/membership-identity/membership-identity-dal.ts +++ b/backend/src/services/membership-identity/membership-identity-dal.ts @@ -81,6 +81,11 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { ) .leftJoin(TableName.IdentityLdapAuth, `${TableName.Identity}.id`, `${TableName.IdentityLdapAuth}.identityId`) .leftJoin(TableName.IdentityJwtAuth, `${TableName.Identity}.id`, `${TableName.IdentityJwtAuth}.identityId`) + .leftJoin( + TableName.IdentitySpiffeAuth, + `${TableName.Identity}.id`, + `${TableName.IdentitySpiffeAuth}.identityId` + ) .select(selectAllTableCols(TableName.Membership)) .select( db.ref("name").withSchema(TableName.Identity).as("identityName"), @@ -120,7 +125,8 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), - db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth) + db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth), + db.ref("id").as("spiffeId").withSchema(TableName.IdentitySpiffeAuth) ); const data = sqlNestRelationships({ @@ -144,7 +150,8 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { jwtId, ociId, ldapId, - tlsCertId + tlsCertId, + spiffeId } = el; return { ...MembershipsSchema.parse(el), @@ -166,7 +173,8 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { jwtId, ldapId, ociId, - tlsCertId + tlsCertId, + spiffeId }) } }; diff --git a/docs/docs.json b/docs/docs.json index b27e58ae26d..2cba814a231 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -280,6 +280,7 @@ "documentation/platform/identities/kubernetes-auth", "documentation/platform/identities/oci-auth", "documentation/platform/identities/token-auth", + "documentation/platform/identities/spiffe-auth", "documentation/platform/identities/tls-cert-auth", "documentation/platform/identities/universal-auth", { diff --git a/docs/documentation/platform/identities/machine-identities.mdx b/docs/documentation/platform/identities/machine-identities.mdx index 964a3ad7c7f..fdd88fbe637 100644 --- a/docs/documentation/platform/identities/machine-identities.mdx +++ b/docs/documentation/platform/identities/machine-identities.mdx @@ -59,6 +59,7 @@ To interact with various resources in Infisical, Machine Identities can authenti - [Azure Auth](/documentation/platform/identities/azure-auth): An Azure-native authentication method for Azure resources (e.g. Azure VMs, Azure App Services, Azure Functions, Azure Kubernetes Service, etc.). - [GCP Auth](/documentation/platform/identities/gcp-auth): A GCP-native authentication method for GCP resources (e.g. Compute Engine, App Engine, Cloud Run, Google Kubernetes Engine, IAM service accounts, etc.). - [OIDC Auth](/documentation/platform/identities/oidc-auth): A platform-agnostic, JWT-based authentication method for workloads using an OpenID Connect identity provider. +- [SPIFFE Auth](/documentation/platform/identities/spiffe-auth): A SPIFFE-native authentication method for workloads using JWT-SVIDs issued by SPIRE. ## Identity Lockout diff --git a/docs/documentation/platform/identities/spiffe-auth.mdx b/docs/documentation/platform/identities/spiffe-auth.mdx new file mode 100644 index 00000000000..602ba1566f5 --- /dev/null +++ b/docs/documentation/platform/identities/spiffe-auth.mdx @@ -0,0 +1,230 @@ +--- +title: SPIFFE Auth +description: "Learn how to authenticate with Infisical using SPIFFE JWT-SVIDs." +--- + +**SPIFFE Auth** is a SPIFFE-native authentication method that validates JWT-SVIDs issued by [SPIRE](https://spiffe.io/docs/latest/spire-about/) or other SPIFFE-compliant workload identity providers, allowing workloads with SPIFFE identities to securely authenticate with Infisical. + +## Diagram + +The following sequence diagram illustrates the SPIFFE Auth workflow for authenticating with Infisical. + +```mermaid +sequenceDiagram + participant Client as Client Workload + participant SPIRE as SPIRE Agent + participant Infisical as Infisical + + Client->>SPIRE: Step 1: Request JWT-SVID + SPIRE-->>Client: Return signed JWT-SVID + + Note over Client,Infisical: Step 2: Login Operation + Client->>Infisical: Send JWT-SVID to /api/v1/auth/spiffe-auth/login + + Note over Infisical: Step 3: JWT-SVID Validation + Infisical->>Infisical: Verify signature using configured JWKS (static or remote) + Infisical->>Infisical: Validate trust domain, SPIFFE ID, and audience + + Note over Infisical: Step 4: Token Generation + Infisical->>Client: Return short-lived access token + + Note over Client,Infisical: Step 5: Access Infisical API with Token + Client->>Infisical: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high level, Infisical authenticates a workload by verifying its JWT-SVID and checking that it meets specific requirements (e.g. its SPIFFE ID matches an allowed pattern, it belongs to the expected trust domain) at the `/api/v1/auth/spiffe-auth/login` endpoint. If successful, Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The workload requests a JWT-SVID from its local SPIRE Agent (via the SPIFFE Workload API). +2. The JWT-SVID is sent to Infisical at the `/api/v1/auth/spiffe-auth/login` endpoint. +3. Infisical verifies the JWT-SVID signature using either: + - Pre-configured JWKS (Static configuration) + - JWKS fetched from a SPIRE bundle endpoint (Remote configuration) +4. Infisical validates that the token's trust domain matches the configured trust domain, the SPIFFE ID (`sub` claim) matches at least one of the allowed SPIFFE ID patterns, and the audience (`aud` claim) is in the allowed audiences list. +5. If all checks pass, Infisical returns a short-lived access token that the workload can use to make authenticated requests to the Infisical API. + + + For Remote configuration, Infisical needs network-level access to the configured SPIRE bundle endpoint. The trust bundle is fetched on-demand at login time and cached. If the cache is older than the configured refresh interval (default: 1 hour), Infisical fetches a fresh bundle before verifying the JWT-SVID. You can also force an immediate refresh via the API without waiting for the cache to expire. + + +## Guide + +In the following steps, we explore how to create and use identities to access the Infisical API using the SPIFFE authentication method. + + + + To create an identity, head to your Organization Settings > Access Control > [Identities](https://app.infisical.com/organization/access-management?selectedTab=identities) and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization-level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles). + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Input some details for your new identity: + + - **Name (required):** A friendly name for the identity. + - **Role (required):** A role from the [**Organization Roles**](https://app.infisical.com/organization/access-management?selectedTab=roles) tab for the identity to assume. The organization role assigned will determine what organization-level resources this identity can have access to. + + Once you've created an identity, you'll be redirected to a page where you can manage the identity. + + ![identities page](/images/platform/identities/identities-page.png) + + Since the identity has been configured with [Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth) by default, you should reconfigure it to use SPIFFE Auth instead. To do this, click the cog next to **Universal Auth** and then select **Delete** in the options dropdown. + + ![identities press cog](/images/platform/identities/identities-press-cog.png) + + ![identities page remove default auth](/images/platform/identities/identities-page-remove-default-auth.png) + + Now create a new SPIFFE Auth Method. + + Restrict access by properly configuring the trust domain, allowed SPIFFE IDs, and allowed audiences. + + Here's some information about each field: + + **Configuration Type:** + - **Static:** You provide the SPIRE JWKS JSON directly. Best for environments where the trust bundle is managed externally or does not change frequently. + - **Remote:** Infisical automatically fetches and caches the trust bundle from a SPIRE bundle endpoint. Best for dynamic environments where keys rotate regularly. + + **Static configuration fields:** + - **CA Bundle JWKS (required):** The JWKS JSON containing the public keys used to verify JWT-SVID signatures. You can obtain this from your SPIRE server's bundle endpoint or via `spire-server bundle show -format jwks`. + + **Remote configuration fields:** + - **Bundle Endpoint URL (required):** The URL of the SPIRE bundle endpoint (e.g. `https://spire-server:8443`). Must use HTTPS. Infisical fetches the trust bundle on-demand at login time and caches it for the configured refresh interval. + - **Bundle Endpoint Profile:** The authentication profile for the bundle endpoint. + - `HTTPS Web` — Standard HTTPS with publicly trusted certificates. + - `HTTPS SPIFFE` — HTTPS with a custom CA certificate for mTLS-secured endpoints. + - **Bundle Endpoint CA Certificate:** The PEM-encoded CA certificate for verifying the bundle endpoint TLS connection. Required when using the `HTTPS SPIFFE` profile. + - **Bundle Refresh Hint (seconds):** How long the cached trust bundle is considered fresh before Infisical re-fetches it on the next login. Defaults to `3600` (1 hour). You can force an immediate refresh at any time using the [refresh bundle API endpoint](/api-reference/endpoints/machine-identities/spiffe-auth/refresh-bundle). + + **Common fields for both configurations:** + - **Trust Domain (required):** The SPIFFE trust domain that authenticating workloads must belong to (e.g. `example.org` or `prod.example.com`). + - **Allowed SPIFFE IDs (required):** A comma-separated list of SPIFFE ID patterns that are permitted to authenticate. Supports [picomatch](https://github.com/micromatch/picomatch) glob patterns (e.g. `spiffe://example.org/ns/production/**`, `spiffe://example.org/ns/*/sa/my-service`). + - **Allowed Audiences (required):** A comma-separated list of allowed audience values. The JWT-SVID's `aud` claim must contain at least one of these values. + - **Access Token TTL (default is `2592000` equivalent to 30 days):** The lifetime for an access token in seconds. This value will be referenced at renewal time. + - **Access Token Max TTL (default is `2592000` equivalent to 30 days):** The maximum lifetime for an access token in seconds. This value will be referenced at renewal time. + - **Access Token Max Number of Uses (default is `0`):** The maximum number of times that an access token can be used; a value of `0` implies an infinite number of uses. + - **Access Token Trusted IPs:** The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project-level role you want to allow it to assume. The project role assigned will determine what project-level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + To access the Infisical API as the identity, you need to obtain a JWT-SVID from your SPIRE Agent that meets the validation requirements configured in the previous step. + + The JWT-SVID must: + - Be signed by a key present in the configured JWKS (static or remote). + - Have a `sub` claim containing a valid SPIFFE ID that matches one of the allowed SPIFFE ID patterns. + - Have an `aud` claim containing at least one of the allowed audiences. + - Belong to the configured trust domain. + + Once you have a valid JWT-SVID, use it to authenticate with Infisical at the `/api/v1/auth/spiffe-auth/login` endpoint. + + + The following example uses Node.js, but you can use any language that supports the SPIFFE Workload API or can obtain a JWT-SVID from your SPIRE Agent. + + ```javascript + const axios = require("axios"); + + // Obtain JWT-SVID from SPIRE Agent via the Workload API, + // or from your SPIFFE-compatible identity provider. + const jwtSvid = ""; + + const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL + const identityId = ""; + + const { data } = await axios.post( + `${infisicalUrl}/api/v1/auth/spiffe-auth/login`, + { + identityId, + jwt: jwtSvid, + } + ); + + console.log("Access token:", data.accessToken); + // Use data.accessToken for subsequent Infisical API requests + ``` + + + + If your workload uses the [go-spiffe](https://github.com/spiffe/go-spiffe) library, you can fetch a JWT-SVID directly from the Workload API. + + ```go + package main + + import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/spiffe/go-spiffe/v2/workloadapi" + ) + + func main() { + ctx := context.Background() + + // Connect to the SPIRE Agent Workload API + client, err := workloadapi.New(ctx, workloadapi.WithAddr("unix:///tmp/spire-agent/public/api.sock")) + if err != nil { + panic(err) + } + defer client.Close() + + // Fetch a JWT-SVID with the required audience + svid, err := client.FetchJWTSVID(ctx, workloadapi.JWTSVIDParams{ + Audience: "infisical", + }) + if err != nil { + panic(err) + } + + // Authenticate with Infisical + payload, _ := json.Marshal(map[string]string{ + "identityId": "", + "jwt": svid.Marshal(), + }) + + resp, err := http.Post( + "https://app.infisical.com/api/v1/auth/spiffe-auth/login", + "application/json", + bytes.NewReader(payload), + ) + if err != nil { + panic(err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + fmt.Println("Response:", string(body)) + } + ``` + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using SPIFFE Auth as they handle the authentication process for you. + + + + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; + the default TTL is `2592000` seconds (30 days) which can be adjusted in the configuration. + + If an identity access token exceeds its max TTL or maximum number of uses, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation with a valid JWT-SVID. + + + diff --git a/docs/snippets/MachineAuthenticationBrowser.jsx b/docs/snippets/MachineAuthenticationBrowser.jsx index 52149384664..6cb673dc526 100644 --- a/docs/snippets/MachineAuthenticationBrowser.jsx +++ b/docs/snippets/MachineAuthenticationBrowser.jsx @@ -25,7 +25,8 @@ export const MachineAuthenticationBrowser = () => { {"name": "OIDC Auth for SPIRE", "slug": "oidc-auth-spire", "path": "/documentation/platform/identities/oidc-auth/spire", "description": "Learn how to authenticate workloads using SPIFFE/SPIRE OIDC.", "category": "Token-based"}, {"name": "TLS Certificate Auth", "slug": "tls-cert-auth", "path": "/documentation/platform/identities/tls-cert-auth", "description": "Learn how to authenticate machines using TLS client certificates.", "category": "Certificate-based"}, {"name": "LDAP Auth", "slug": "ldap-auth-general", "path": "/documentation/platform/identities/ldap-auth/general", "description": "Learn how to authenticate machines using LDAP credentials.", "category": "Directory-based"}, - {"name": "LDAP Auth for JumpCloud", "slug": "ldap-auth-jumpcloud", "path": "/documentation/platform/identities/ldap-auth/jumpcloud", "description": "Learn how to authenticate machines using JumpCloud LDAP.", "category": "Directory-based"} + {"name": "LDAP Auth for JumpCloud", "slug": "ldap-auth-jumpcloud", "path": "/documentation/platform/identities/ldap-auth/jumpcloud", "description": "Learn how to authenticate machines using JumpCloud LDAP.", "category": "Directory-based"}, + {"name": "SPIFFE Auth", "slug": "spiffe-auth", "path": "/documentation/platform/identities/spiffe-auth", "description": "Learn how to authenticate workloads using SPIFFE JWT-SVIDs issued by SPIRE.", "category": "Certificate-based"} ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index b441f5015d6..66338f538a1 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -12,5 +12,6 @@ export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { [IdentityAuthMethod.OIDC_AUTH]: "OIDC Auth", [IdentityAuthMethod.LDAP_AUTH]: "LDAP Auth", [IdentityAuthMethod.JWT_AUTH]: "JWT Auth", - [IdentityAuthMethod.TLS_CERT_AUTH]: "TLS Certificate Auth" + [IdentityAuthMethod.TLS_CERT_AUTH]: "TLS Certificate Auth", + [IdentityAuthMethod.SPIFFE_AUTH]: "SPIFFE Auth" }; diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index c850005cf8f..22c9ca1dc97 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -10,10 +10,21 @@ export enum IdentityAuthMethod { OIDC_AUTH = "oidc-auth", LDAP_AUTH = "ldap-auth", JWT_AUTH = "jwt-auth", - TLS_CERT_AUTH = "tls-cert-auth" + TLS_CERT_AUTH = "tls-cert-auth", + SPIFFE_AUTH = "spiffe-auth" } export enum IdentityJwtConfigurationType { JWKS = "jwks", STATIC = "static" } + +export enum IdentitySpiffeConfigurationType { + STATIC = "static", + REMOTE = "remote" +} + +export enum SpiffeBundleEndpointProfile { + HTTPS_WEB = "https_web", + HTTPS_SPIFFE = "https_spiffe" +} diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index 14903eaef22..7559bae3063 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -15,6 +15,7 @@ import { AddIdentityLdapAuthDTO, AddIdentityOciAuthDTO, AddIdentityOidcAuthDTO, + AddIdentitySpiffeAuthDTO, AddIdentityTlsCertAuthDTO, AddIdentityTokenAuthDTO, AddIdentityUniversalAuthDTO, @@ -34,6 +35,7 @@ import { DeleteIdentityLdapAuthDTO, DeleteIdentityOciAuthDTO, DeleteIdentityOidcAuthDTO, + DeleteIdentitySpiffeAuthDTO, DeleteIdentityTlsCertAuthDTO, DeleteIdentityTokenAuthDTO, DeleteIdentityUniversalAuthClientSecretDTO, @@ -48,6 +50,7 @@ import { IdentityLdapAuth, IdentityOciAuth, IdentityOidcAuth, + IdentitySpiffeAuth, IdentityTlsCertAuth, IdentityTokenAuth, IdentityUniversalAuth, @@ -62,6 +65,7 @@ import { UpdateIdentityLdapAuthDTO, UpdateIdentityOciAuthDTO, UpdateIdentityOidcAuthDTO, + UpdateIdentitySpiffeAuthDTO, UpdateIdentityTlsCertAuthDTO, UpdateIdentityTokenAuthDTO, UpdateIdentityUniversalAuthDTO, @@ -1209,6 +1213,159 @@ export const useDeleteIdentityJwtAuth = () => { }); }; +export const useAddIdentitySpiffeAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + trustDomain, + allowedSpiffeIds, + allowedAudiences, + configurationType, + caBundleJwks, + bundleEndpointUrl, + bundleEndpointProfile, + bundleEndpointCaCert, + bundleRefreshHintSeconds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identitySpiffeAuth } + } = await apiRequest.post<{ identitySpiffeAuth: IdentitySpiffeAuth }>( + `/api/v1/auth/spiffe-auth/identities/${identityId}`, + { + trustDomain, + allowedSpiffeIds, + allowedAudiences, + configurationType, + caBundleJwks, + bundleEndpointUrl, + bundleEndpointProfile, + bundleEndpointCaCert, + bundleRefreshHintSeconds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identitySpiffeAuth; + }, + onSuccess: (_, { identityId, organizationId, projectId }) => { + if (organizationId) { + queryClient.invalidateQueries({ + queryKey: organizationKeys.getOrgIdentityMemberships(organizationId) + }); + } + if (projectId) { + queryClient.invalidateQueries({ + queryKey: projectKeys.getProjectIdentityMemberships(projectId) + }); + queryClient.invalidateQueries({ + queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId }) + }); + } + queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) }); + queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentitySpiffeAuth(identityId) }); + } + }); +}; + +export const useUpdateIdentitySpiffeAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + trustDomain, + allowedSpiffeIds, + allowedAudiences, + configurationType, + caBundleJwks, + bundleEndpointUrl, + bundleEndpointProfile, + bundleEndpointCaCert, + bundleRefreshHintSeconds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identitySpiffeAuth } + } = await apiRequest.patch<{ identitySpiffeAuth: IdentitySpiffeAuth }>( + `/api/v1/auth/spiffe-auth/identities/${identityId}`, + { + trustDomain, + allowedSpiffeIds, + allowedAudiences, + configurationType, + caBundleJwks, + bundleEndpointUrl, + bundleEndpointProfile, + bundleEndpointCaCert, + bundleRefreshHintSeconds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identitySpiffeAuth; + }, + onSuccess: (_, { identityId, organizationId, projectId }) => { + if (organizationId) { + queryClient.invalidateQueries({ + queryKey: organizationKeys.getOrgIdentityMemberships(organizationId) + }); + } + if (projectId) { + queryClient.invalidateQueries({ + queryKey: projectKeys.getProjectIdentityMemberships(projectId) + }); + queryClient.invalidateQueries({ + queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId }) + }); + } + queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) }); + queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentitySpiffeAuth(identityId) }); + } + }); +}; + +export const useDeleteIdentitySpiffeAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId }) => { + const { + data: { identitySpiffeAuth } + } = await apiRequest.delete(`/api/v1/auth/spiffe-auth/identities/${identityId}`); + return identitySpiffeAuth; + }, + onSuccess: (_, { organizationId, identityId, projectId }) => { + if (organizationId) { + queryClient.invalidateQueries({ + queryKey: organizationKeys.getOrgIdentityMemberships(organizationId) + }); + } + if (projectId) { + queryClient.invalidateQueries({ + queryKey: projectKeys.getProjectIdentityMemberships(projectId) + }); + queryClient.invalidateQueries({ + queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId }) + }); + } + queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) }); + queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentitySpiffeAuth(identityId) }); + } + }); +}; + export const useAddIdentityAzureAuth = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index 866bcac8029..5614802ceb6 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -17,6 +17,7 @@ import { IdentityOciAuth, IdentityOidcAuth, IdentityProjectMembershipV1, + IdentitySpiffeAuth, IdentityTlsCertAuth, IdentityTokenAuth, IdentityUniversalAuth, @@ -45,6 +46,7 @@ export const identitiesKeys = { getIdentityAzureAuth: (identityId: string) => [{ identityId }, "identity-azure-auth"] as const, getIdentityTokenAuth: (identityId: string) => [{ identityId }, "identity-token-auth"] as const, getIdentityJwtAuth: (identityId: string) => [{ identityId }, "identity-jwt-auth"] as const, + getIdentitySpiffeAuth: (identityId: string) => [{ identityId }, "identity-spiffe-auth"] as const, getIdentityLdapAuth: (identityId: string) => [{ identityId }, "identity-ldap-auth"] as const, getIdentityTokensTokenAuth: (identityId: string) => [{ identityId }, "identity-tokens-token-auth"] as const, @@ -385,3 +387,25 @@ export const useGetIdentityJwtAuth = ( enabled: Boolean(identityId) && (options?.enabled ?? true) }); }; + +export const useGetIdentitySpiffeAuth = ( + identityId: string, + options?: TReactQueryOptions["options"] +) => { + return useQuery({ + queryKey: identitiesKeys.getIdentitySpiffeAuth(identityId), + queryFn: async () => { + const { + data: { identitySpiffeAuth } + } = await apiRequest.get<{ identitySpiffeAuth: IdentitySpiffeAuth }>( + `/api/v1/auth/spiffe-auth/identities/${identityId}` + ); + + return identitySpiffeAuth; + }, + staleTime: 0, + gcTime: 0, + ...options, + enabled: Boolean(identityId) && (options?.enabled ?? true) + }); +}; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index b28e9385bda..66b6e032008 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -833,6 +833,72 @@ export type DeleteIdentityJwtAuthDTO = { identityId: string; } & ({ organizationId: string } | { projectId: string }); +export type IdentitySpiffeAuth = { + identityId: string; + trustDomain: string; + allowedSpiffeIds: string; + allowedAudiences: string; + configurationType: string; + caBundleJwks: string; + bundleEndpointUrl: string | null; + bundleEndpointProfile: string | null; + bundleEndpointCaCert: string; + cachedBundleLastRefreshedAt: string | null; + bundleRefreshHintSeconds: number; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentitySpiffeAuthDTO = { + organizationId?: string; + projectId?: string; + identityId: string; + trustDomain: string; + allowedSpiffeIds: string; + allowedAudiences: string; + configurationType: string; + caBundleJwks?: string; + bundleEndpointUrl?: string; + bundleEndpointProfile?: string; + bundleEndpointCaCert?: string; + bundleRefreshHintSeconds?: number; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +} & ({ organizationId: string } | { projectId: string }); + +export type UpdateIdentitySpiffeAuthDTO = { + organizationId?: string; + projectId?: string; + identityId: string; + trustDomain?: string; + allowedSpiffeIds?: string; + allowedAudiences?: string; + configurationType?: string; + caBundleJwks?: string; + bundleEndpointUrl?: string; + bundleEndpointProfile?: string; + bundleEndpointCaCert?: string; + bundleRefreshHintSeconds?: number; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +} & ({ organizationId: string } | { projectId: string }); + +export type DeleteIdentitySpiffeAuthDTO = { + organizationId?: string; + projectId?: string; + identityId: string; +} & ({ organizationId: string } | { projectId: string }); + export type CreateTokenIdentityTokenAuthDTO = { identityId: string; name: string; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx index 1bfb904a57a..ab667c7582a 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx @@ -18,6 +18,7 @@ import { IdentityKubernetesAuthForm } from "./IdentityKubernetesAuthForm"; import { IdentityLdapAuthForm } from "./IdentityLdapAuthForm"; import { IdentityOciAuthForm } from "./IdentityOciAuthForm"; import { IdentityOidcAuthForm } from "./IdentityOidcAuthForm"; +import { IdentitySpiffeAuthForm } from "./IdentitySpiffeAuthForm"; import { IdentityTlsCertAuthForm } from "./IdentityTlsCertAuthForm"; import { IdentityTokenAuthForm } from "./IdentityTokenAuthForm"; import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm"; @@ -58,7 +59,8 @@ const identityAuthMethods = [ { label: "JWT Auth", value: IdentityAuthMethod.JWT_AUTH - } + }, + { label: "SPIFFE Auth", value: IdentityAuthMethod.SPIFFE_AUTH } ]; const schema = z @@ -234,6 +236,16 @@ export const IdentityAuthMethodModalContent = ({ handlePopUpToggle={handlePopUpToggle} /> ) + }, + + [IdentityAuthMethod.SPIFFE_AUTH]: { + render: () => ( + + ) } }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentitySpiffeAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentitySpiffeAuthForm.tsx new file mode 100644 index 00000000000..1f8e3961095 --- /dev/null +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentitySpiffeAuthForm.tsx @@ -0,0 +1,569 @@ +import { useEffect, useState } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useParams } from "@tanstack/react-router"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + IconButton, + Input, + Select, + SelectItem, + Tab, + TabList, + TabPanel, + Tabs, + TextArea +} from "@app/components/v2"; +import { useOrganization, useSubscription } from "@app/context"; +import { useAddIdentitySpiffeAuth, useUpdateIdentitySpiffeAuth } from "@app/hooks/api"; +import { + IdentitySpiffeConfigurationType, + SpiffeBundleEndpointProfile +} from "@app/hooks/api/identities/enums"; +import { useGetIdentitySpiffeAuth } from "@app/hooks/api/identities/queries"; +import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { IdentityFormTab } from "./types"; + +const commonSchema = z.object({ + trustDomain: z.string().trim().min(1, "Trust domain is required"), + allowedSpiffeIds: z.string().trim().min(1, "Allowed SPIFFE IDs are required"), + allowedAudiences: z.string().trim().min(1, "Allowed audiences are required"), + accessTokenTrustedIps: z + .array( + z.object({ + ipAddress: z.string().max(50) + }) + ) + .min(1), + accessTokenTTL: z.string().refine((val) => Number(val) <= 315360000, { + message: "Access Token TTL cannot be greater than 315360000" + }), + accessTokenMaxTTL: z.string().refine((val) => Number(val) <= 315360000, { + message: "Access Token Max TTL cannot be greater than 315360000" + }), + accessTokenNumUsesLimit: z.string() +}); + +const schema = z.discriminatedUnion("configurationType", [ + z + .object({ + configurationType: z.literal(IdentitySpiffeConfigurationType.STATIC), + caBundleJwks: z.string().trim().min(1, "CA Bundle JWKS is required for static configuration"), + bundleEndpointUrl: z.string().trim().optional().default(""), + bundleEndpointProfile: z + .nativeEnum(SpiffeBundleEndpointProfile) + .optional() + .default(SpiffeBundleEndpointProfile.HTTPS_WEB), + bundleEndpointCaCert: z.string().trim().optional().default(""), + bundleRefreshHintSeconds: z.string().optional().default("300") + }) + .merge(commonSchema), + z + .object({ + configurationType: z.literal(IdentitySpiffeConfigurationType.REMOTE), + caBundleJwks: z.string().trim().optional().default(""), + bundleEndpointUrl: z.string().trim().url("Must be a valid URL"), + bundleEndpointProfile: z + .nativeEnum(SpiffeBundleEndpointProfile) + .default(SpiffeBundleEndpointProfile.HTTPS_WEB), + bundleEndpointCaCert: z.string().trim().optional().default(""), + bundleRefreshHintSeconds: z.string().default("300") + }) + .merge(commonSchema) +]); + +export type FormData = z.infer; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["upgradePlan"]>, + data?: { featureName?: string } + ) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["identityAuthMethod"]>, + state?: boolean + ) => void; + identityId?: string; + isUpdate?: boolean; +}; + +export const IdentitySpiffeAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityId, + isUpdate +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + const { projectId } = useParams({ + strict: false + }); + const { mutateAsync: addMutateAsync } = useAddIdentitySpiffeAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentitySpiffeAuth(); + const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); + + const { data } = useGetIdentitySpiffeAuth(identityId ?? "", { + enabled: isUpdate + }); + + const { + watch, + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + configurationType: IdentitySpiffeConfigurationType.STATIC, + trustDomain: "", + allowedSpiffeIds: "", + allowedAudiences: "", + caBundleJwks: "", + bundleEndpointUrl: "", + bundleEndpointProfile: SpiffeBundleEndpointProfile.HTTPS_WEB, + bundleEndpointCaCert: "", + bundleRefreshHintSeconds: "300", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + } + }); + + const selectedConfigurationType = watch("configurationType") as IdentitySpiffeConfigurationType; + const selectedBundleEndpointProfile = watch("bundleEndpointProfile"); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + useEffect(() => { + if (data) { + reset({ + configurationType: + (data.configurationType as IdentitySpiffeConfigurationType) || + IdentitySpiffeConfigurationType.STATIC, + trustDomain: data.trustDomain, + allowedSpiffeIds: data.allowedSpiffeIds, + allowedAudiences: data.allowedAudiences, + caBundleJwks: data.caBundleJwks || "", + bundleEndpointUrl: data.bundleEndpointUrl || "", + bundleEndpointProfile: + (data.bundleEndpointProfile as SpiffeBundleEndpointProfile) || + SpiffeBundleEndpointProfile.HTTPS_WEB, + bundleEndpointCaCert: data.bundleEndpointCaCert || "", + bundleRefreshHintSeconds: String(data.bundleRefreshHintSeconds ?? 300), + accessTokenTTL: String(data.accessTokenTTL), + accessTokenMaxTTL: String(data.accessTokenMaxTTL), + accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit), + accessTokenTrustedIps: data.accessTokenTrustedIps.map( + ({ ipAddress, prefix }: IdentityTrustedIp) => { + return { + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }; + } + ) + }); + } + }, [data]); + + const onFormSubmit = async ({ + accessTokenTrustedIps, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + configurationType, + trustDomain, + allowedSpiffeIds, + allowedAudiences, + caBundleJwks, + bundleEndpointUrl, + bundleEndpointProfile, + bundleEndpointCaCert, + bundleRefreshHintSeconds + }: FormData) => { + if (!identityId) { + return; + } + + if (data) { + await updateMutateAsync({ + identityId, + ...(projectId ? { projectId } : { organizationId: orgId }), + configurationType, + trustDomain, + allowedSpiffeIds, + allowedAudiences, + caBundleJwks, + bundleEndpointUrl, + bundleEndpointProfile, + bundleEndpointCaCert, + bundleRefreshHintSeconds: Number(bundleRefreshHintSeconds), + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + identityId, + configurationType, + trustDomain, + allowedSpiffeIds, + allowedAudiences, + caBundleJwks, + bundleEndpointUrl, + bundleEndpointProfile, + bundleEndpointCaCert, + bundleRefreshHintSeconds: Number(bundleRefreshHintSeconds), + ...(projectId ? { projectId } : { organizationId: orgId }), + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } + + handlePopUpToggle("identityAuthMethod", false); + + createNotification({ + text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`, + type: "success" + }); + reset(); + }; + + return ( +
{ + setTabValue( + ["accessTokenTrustedIps"].includes(Object.keys(fields)[0]) + ? IdentityFormTab.Advanced + : IdentityFormTab.Configuration + ); + })} + > + setTabValue(value as IdentityFormTab)}> + + Configuration + Advanced + + + ( + + + + )} + /> + ( + + + + )} + /> + ( + +