diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index c7e58fb099..50a60606e0 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -41,6 +41,7 @@ import { TIdentityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/ import { TIdentityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service"; import { TIdentityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; import { TIdentityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-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 { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; @@ -132,6 +133,7 @@ declare module "fastify" { identityGcpAuth: TIdentityGcpAuthServiceFactory; identityAwsAuth: TIdentityAwsAuthServiceFactory; identityAzureAuth: TIdentityAzureAuthServiceFactory; + identityOidcAuth: TIdentityOidcAuthServiceFactory; accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; accessApprovalRequest: TAccessApprovalRequestServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index eff1b25944..d80b44ec75 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -166,5 +166,6 @@ export enum IdentityAuthMethod { KUBERNETES_AUTH = "kubernetes-auth", GCP_AUTH = "gcp-auth", AWS_AUTH = "aws-auth", - AZURE_AUTH = "azure-auth" + AZURE_AUTH = "azure-auth", + OIDC_AUTH = "oidc-auth" } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 54c5e51d66..178d9b443d 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -102,6 +102,8 @@ import { identityGcpAuthDALFactory } from "@app/services/identity-gcp-auth/ident import { identityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; import { identityKubernetesAuthDALFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-dal"; import { identityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; +import { identityOidcAuthDALFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-dal"; +import { identityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-service"; import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { identityProjectMembershipRoleDALFactory } from "@app/services/identity-project/identity-project-membership-role-dal"; import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; @@ -238,6 +240,7 @@ export const registerRoutes = async ( const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); const identityAwsAuthDAL = identityAwsAuthDALFactory(db); const identityGcpAuthDAL = identityGcpAuthDALFactory(db); + const identityOidcAuthDAL = identityOidcAuthDALFactory(db); const identityAzureAuthDAL = identityAzureAuthDALFactory(db); const auditLogDAL = auditLogDALFactory(db); @@ -874,6 +877,16 @@ export const registerRoutes = async ( licenseService }); + const identityOidcAuthService = identityOidcAuthServiceFactory({ + identityOidcAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + permissionService, + licenseService, + orgBotDAL + }); + const dynamicSecretProviders = buildDynamicSecretProviders(); const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({ queueService, @@ -972,6 +985,7 @@ export const registerRoutes = async ( identityGcpAuth: identityGcpAuthService, identityAwsAuth: identityAwsAuthService, identityAzureAuth: identityAzureAuthService, + identityOidcAuth: identityOidcAuthService, accessApprovalPolicy: accessApprovalPolicyService, accessApprovalRequest: accessApprovalRequestService, secretApprovalPolicy: secretApprovalPolicyService, diff --git a/backend/src/server/routes/v1/identity-oidc-auth-router.ts b/backend/src/server/routes/v1/identity-oidc-auth-router.ts index a1ad47a47a..c6bf1db6a6 100644 --- a/backend/src/server/routes/v1/identity-oidc-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oidc-auth-router.ts @@ -1,11 +1,19 @@ import { z } from "zod"; import { IdentityOidcAuthsSchema } from "@app/db/schemas"; -import { writeLimit } from "@app/server/config/rateLimiter"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { validateOidcAuthAudiencesField } from "@app/services/identity-oidc-auth/identity-oidc-auth-validators"; +const IdentityOidcAuthResponseSchema = IdentityOidcAuthsSchema.omit({ + encryptedCaCert: true, + caCertIV: true, + caCertTag: true +}).extend({ + caCert: z.string() +}); + export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -53,14 +61,133 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) boundIssuer: z.string().min(1), boundAudiences: validateOidcAuthAudiencesField, boundClaims: z.record(z.string()), - boundSubject: z.string().optional() + boundSubject: z.string().optional().default("") }), response: { 200: z.object({ - identityOidcAuth: IdentityOidcAuthsSchema + identityOidcAuth: IdentityOidcAuthResponseSchema }) } }, - handler: async () => {} + handler: async (req) => { + const identityOidcAuth = await server.services.identityOidcAuth.attachOidcAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + return { + identityOidcAuth + }; + } + }); + + server.route({ + method: "PATCH", + url: "/oidc-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update OIDC Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z + .object({ + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]), + accessTokenTTL: z + .number() + .int() + .min(1) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000), + accessTokenNumUsesLimit: z.number().int().min(0).default(0), + oidcDiscoveryUrl: z.string().url().min(1), + caCert: z.string().trim().default(""), + boundIssuer: z.string().min(1), + boundAudiences: validateOidcAuthAudiencesField, + boundClaims: z.record(z.string()), + boundSubject: z.string().optional().default("") + }) + .partial(), + response: { + 200: z.object({ + identityOidcAuth: IdentityOidcAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identityOidcAuth = await server.services.identityOidcAuth.updateOidcAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + identityId: req.params.identityId + }); + + return { identityOidcAuth }; + } + }); + + server.route({ + method: "GET", + url: "/oidc-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve OIDC Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityOidcAuth: IdentityOidcAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identityOidcAuth = await server.services.identityOidcAuth.getOidcAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + return { identityOidcAuth }; + } }); }; diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts index e6493ac0f7..ed338ef1a7 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -1,11 +1,350 @@ +import { ForbiddenError } from "@casl/ability"; + +import { IdentityAuthMethod, SecretKeyEncoding, TIdentityOidcAuthsUpdate } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { generateAsymmetricKeyPair } from "@app/lib/crypto"; +import { + decryptSymmetric, + encryptSymmetric, + generateSymmetricKey, + infisicalSymmetricDecrypt, + infisicalSymmetricEncypt +} from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TOrgBotDALFactory } from "../org/org-bot-dal"; import { TIdentityOidcAuthDALFactory } from "./identity-oidc-auth-dal"; +import { TAttachOidcAuthDTO, TGetOidcAuthDTO, TUpdateOidcAuthDTO } from "./identity-oidc-auth-types"; type TIdentityOidcAuthServiceFactoryDep = { identityOidcAuthDAL: TIdentityOidcAuthDALFactory; + identityOrgMembershipDAL: Pick; + identityAccessTokenDAL: Pick; + identityDAL: Pick; + permissionService: Pick; + licenseService: Pick; + orgBotDAL: Pick; }; export type TIdentityOidcAuthServiceFactory = ReturnType; -export const identityOidcAuthServiceFactory = ({ identityOidcAuthDAL }: TIdentityOidcAuthServiceFactoryDep) => { - return {}; +export const identityOidcAuthServiceFactory = ({ + identityOidcAuthDAL, + identityOrgMembershipDAL, + identityDAL, + permissionService, + licenseService, + orgBotDAL +}: TIdentityOidcAuthServiceFactoryDep) => { + const attachOidcAuth = async ({ + identityId, + oidcDiscoveryUrl, + caCert, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachOidcAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) { + throw new BadRequestError({ message: "Failed to find identity" }); + } + if (identityMembershipOrg.identity.authMethod) + throw new BadRequestError({ + message: "Failed to add OIDC Auth to already configured identity" + }); + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + 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); + }); + + const orgBot = await orgBotDAL.transaction(async (tx) => { + const doc = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId }, tx); + if (doc) return doc; + + const { privateKey, publicKey } = generateAsymmetricKeyPair(); + const key = generateSymmetricKey(); + const { + ciphertext: encryptedPrivateKey, + iv: privateKeyIV, + tag: privateKeyTag, + encoding: privateKeyKeyEncoding, + algorithm: privateKeyAlgorithm + } = infisicalSymmetricEncypt(privateKey); + const { + ciphertext: encryptedSymmetricKey, + iv: symmetricKeyIV, + tag: symmetricKeyTag, + encoding: symmetricKeyKeyEncoding, + algorithm: symmetricKeyAlgorithm + } = infisicalSymmetricEncypt(key); + + return orgBotDAL.create( + { + name: "Infisical org bot", + publicKey, + privateKeyIV, + encryptedPrivateKey, + symmetricKeyIV, + symmetricKeyTag, + encryptedSymmetricKey, + symmetricKeyAlgorithm, + orgId: identityMembershipOrg.orgId, + privateKeyTag, + privateKeyAlgorithm, + privateKeyKeyEncoding, + symmetricKeyKeyEncoding + }, + tx + ); + }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { ciphertext: encryptedCaCert, iv: caCertIV, tag: caCertTag } = encryptSymmetric(caCert, key); + + const identityOidcAuth = await identityOidcAuthDAL.transaction(async (tx) => { + const doc = await identityOidcAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + oidcDiscoveryUrl, + encryptedCaCert, + caCertIV, + caCertTag, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + await identityDAL.updateById( + identityMembershipOrg.identityId, + { + authMethod: IdentityAuthMethod.OIDC_AUTH + }, + tx + ); + return doc; + }); + return { ...identityOidcAuth, orgId: identityMembershipOrg.orgId, caCert }; + }; + + const updateOidcAuth = async ({ + identityId, + oidcDiscoveryUrl, + caCert, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateOidcAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) { + throw new BadRequestError({ message: "Failed to find identity" }); + } + + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.OIDC_AUTH) { + throw new BadRequestError({ + message: "Failed to update OIDC Auth" + }); + } + + const identityOidcAuth = await identityOidcAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityOidcAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityOidcAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityOidcAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + 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); + }); + + const updateQuery: TIdentityOidcAuthsUpdate = { + oidcDiscoveryUrl, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }; + + const orgBot = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId }); + if (!orgBot) { + throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + } + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + if (caCert !== undefined) { + const { ciphertext: encryptedCACert, iv: caCertIV, tag: caCertTag } = encryptSymmetric(caCert, key); + updateQuery.encryptedCaCert = encryptedCACert; + updateQuery.caCertIV = caCertIV; + updateQuery.caCertTag = caCertTag; + } + + const updatedOidcAuth = await identityOidcAuthDAL.updateById(identityOidcAuth.id, updateQuery); + const updatedCACert = + updatedOidcAuth.encryptedCaCert && updatedOidcAuth.caCertIV && updatedOidcAuth.caCertTag + ? decryptSymmetric({ + ciphertext: updatedOidcAuth.encryptedCaCert, + iv: updatedOidcAuth.caCertIV, + tag: updatedOidcAuth.caCertTag, + key + }) + : ""; + + return { + ...updatedOidcAuth, + orgId: identityMembershipOrg.orgId, + caCert: updatedCACert + }; + }; + + const getOidcAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetOidcAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) { + throw new BadRequestError({ message: "Failed to find identity" }); + } + + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.OIDC_AUTH) { + throw new BadRequestError({ + message: "The identity does not have OIDC Auth attached" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + + const identityOidcAuth = await identityOidcAuthDAL.findOne({ identityId }); + + const orgBot = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId }); + if (!orgBot) { + throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + } + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const caCert = decryptSymmetric({ + ciphertext: identityOidcAuth.encryptedCaCert, + iv: identityOidcAuth.caCertIV, + tag: identityOidcAuth.caCertTag, + key + }); + + return { ...identityOidcAuth, orgId: identityMembershipOrg.orgId, caCert }; + }; + + return { + attachOidcAuth, + updateOidcAuth, + getOidcAuth + }; }; diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts index e69de29bb2..aaa09a4733 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts @@ -0,0 +1,33 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TAttachOidcAuthDTO = { + identityId: string; + oidcDiscoveryUrl: string; + caCert: string; + boundIssuer: string; + boundAudiences: string; + boundClaims: Record; + boundSubject: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateOidcAuthDTO = { + identityId: string; + oidcDiscoveryUrl?: string; + caCert?: string; + boundIssuer?: string; + boundAudiences?: string; + boundClaims?: Record; + boundSubject?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetOidcAuthDTO = { + identityId: string; +} & Omit; diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index 51495d4f24..5cdcf629b9 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -5,5 +5,6 @@ export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { [IdentityAuthMethod.KUBERNETES_AUTH]: "Kubernetes Auth", [IdentityAuthMethod.GCP_AUTH]: "GCP Auth", [IdentityAuthMethod.AWS_AUTH]: "AWS Auth", - [IdentityAuthMethod.AZURE_AUTH]: "Azure Auth" + [IdentityAuthMethod.AZURE_AUTH]: "Azure Auth", + [IdentityAuthMethod.OIDC_AUTH]: "OIDC Auth" }; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index cb1fe4c170..cc2e88e02d 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -9,6 +9,7 @@ import { AddIdentityAzureAuthDTO, AddIdentityGcpAuthDTO, AddIdentityKubernetesAuthDTO, + AddIdentityOidcAuthDTO, AddIdentityUniversalAuthDTO, ClientSecretData, CreateIdentityDTO, @@ -21,12 +22,14 @@ import { IdentityAzureAuth, IdentityGcpAuth, IdentityKubernetesAuth, + IdentityOidcAuth, IdentityUniversalAuth, UpdateIdentityAwsAuthDTO, UpdateIdentityAzureAuthDTO, UpdateIdentityDTO, UpdateIdentityGcpAuthDTO, UpdateIdentityKubernetesAuthDTO, + UpdateIdentityOidcAuthDTO, UpdateIdentityUniversalAuthDTO } from "./types"; @@ -330,6 +333,90 @@ export const useUpdateIdentityAwsAuth = () => { }); }; +export const useUpdateIdentityOidcAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + oidcDiscoveryUrl, + caCert, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject + }) => { + const { + data: { identityOidcAuth } + } = await apiRequest.patch<{ identityOidcAuth: IdentityOidcAuth }>( + `/api/v1/auth/oidc-auth/identities/${identityId}`, + { + oidcDiscoveryUrl, + caCert, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityOidcAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useAddIdentityOidcAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + oidcDiscoveryUrl, + caCert, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityOidcAuth } + } = await apiRequest.post<{ identityOidcAuth: IdentityOidcAuth }>( + `/api/v1/auth/oidc-auth/identities/${identityId}`, + { + oidcDiscoveryUrl, + caCert, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityOidcAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + 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 eb04227eb6..8128ed5bf3 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -8,7 +8,9 @@ import { IdentityAzureAuth, IdentityGcpAuth, IdentityKubernetesAuth, - IdentityUniversalAuth} from "./types"; + IdentityOidcAuth, + IdentityUniversalAuth +} from "./types"; export const identitiesKeys = { getIdentityUniversalAuth: (identityId: string) => @@ -18,6 +20,7 @@ export const identitiesKeys = { getIdentityKubernetesAuth: (identityId: string) => [{ identityId }, "identity-kubernetes-auth"] as const, getIdentityGcpAuth: (identityId: string) => [{ identityId }, "identity-gcp-auth"] as const, + getIdentityOidcAuth: (identityId: string) => [{ identityId }, "identity-oidc-auth"] as const, getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const, getIdentityAzureAuth: (identityId: string) => [{ identityId }, "identity-azure-auth"] as const }; @@ -111,3 +114,18 @@ export const useGetIdentityKubernetesAuth = (identityId: string) => { } }); }; + +export const useGetIdentityOidcAuth = (identityId: string) => { + return useQuery({ + enabled: Boolean(identityId), + queryKey: identitiesKeys.getIdentityOidcAuth(identityId), + queryFn: async () => { + const { + data: { identityOidcAuth } + } = await apiRequest.get<{ identityOidcAuth: IdentityOidcAuth }>( + `/api/v1/auth/oidc-auth/identities/${identityId}` + ); + return identityOidcAuth; + } + }); +}; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 80d066c720..5ae6cf99df 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -155,6 +155,54 @@ export type UpdateIdentityGcpAuthDTO = { }[]; }; +export type IdentityOidcAuth = { + identityId: string; + oidcDiscoveryUrl: string; + caCert: string; + boundIssuer: string; + boundAudiences: string; + boundClaims: Record; + boundSubject: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityOidcAuthDTO = { + organizationId: string; + identityId: string; + oidcDiscoveryUrl: string; + caCert: string; + boundIssuer: string; + boundAudiences: string; + boundClaims: Record; + boundSubject: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityOidcAuthDTO = { + organizationId: string; + identityId: string; + oidcDiscoveryUrl?: string; + caCert?: string; + boundIssuer?: string; + boundAudiences?: string; + boundClaims?: Record; + boundSubject?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + export type IdentityAwsAuth = { identityId: string; type: "iam"; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx index a6a4fb0c9d..294b730b70 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx @@ -6,9 +6,14 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, IconButton, Input } from "@app/components/v2"; +import { Button, FormControl, IconButton, Input, TextArea } from "@app/components/v2"; import { useOrganization, useSubscription } from "@app/context"; import { IdentityAuthMethod } from "@app/hooks/api/identities"; +import { + useAddIdentityOidcAuth, + useUpdateIdentityOidcAuth +} from "@app/hooks/api/identities/mutations"; +import { useGetIdentityOidcAuth } from "@app/hooks/api/identities/queries"; import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -60,10 +65,10 @@ export const IdentityOidcAuthForm = ({ const orgId = currentOrg?.id || ""; const { subscription } = useSubscription(); - // const { mutateAsync: addMutateAsync } = useAddIdentityGcpAuth(); - // const { mutateAsync: updateMutateAsync } = useUpdateIdentityGcpAuth(); + const { mutateAsync: addMutateAsync } = useAddIdentityOidcAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityOidcAuth(); - // const { data } = useGetIdentityGcpAuth(identityAuthMethodData?.identityId ?? ""); + const { data } = useGetIdentityOidcAuth(identityAuthMethodData?.identityId ?? ""); const { control, @@ -95,78 +100,93 @@ export const IdentityOidcAuthForm = ({ remove: removeAccessTokenTrustedIp } = useFieldArray({ control, name: "accessTokenTrustedIps" }); - // useEffect(() => { - // if (data) { - // reset({ - // type: data.type, - // allowedServiceAccounts: data.allowedServiceAccounts, - // allowedProjects: data.allowedProjects, - // allowedZones: data.allowedZones, - // 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}` : ""}` - // }; - // } - // ) - // }); - // } else { - // reset({ - // type: "iam", - // allowedServiceAccounts: "", - // allowedProjects: "", - // allowedZones: "", - // accessTokenTTL: "2592000", - // accessTokenMaxTTL: "2592000", - // accessTokenNumUsesLimit: "0", - // accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] - // }); - // } - // }, [data]); + useEffect(() => { + if (data) { + reset({ + oidcDiscoveryUrl: data.oidcDiscoveryUrl, + caCert: data.caCert, + boundIssuer: data.boundIssuer, + boundAudiences: data.boundAudiences, + boundClaims: Object.entries(data.boundClaims).map(([key, value]) => ({ + key, + value + })), + boundSubject: data.boundSubject, + 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}` : ""}` + }; + } + ) + }); + } else { + reset({ + oidcDiscoveryUrl: "", + caCert: "", + boundIssuer: "", + boundAudiences: "", + boundClaims: [], + boundSubject: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + } + }, [data]); const onFormSubmit = async ({ - type, - allowedServiceAccounts, - allowedProjects, - allowedZones, + accessTokenTrustedIps, accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + oidcDiscoveryUrl, + caCert, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject }: FormData) => { try { - if (!identityAuthMethodData) return; + if (!identityAuthMethodData) { + return; + } - // if (data) { - // await updateMutateAsync({ - // identityId: identityAuthMethodData.identityId, - // organizationId: orgId, - // type, - // allowedServiceAccounts, - // allowedProjects, - // allowedZones, - // accessTokenTTL: Number(accessTokenTTL), - // accessTokenMaxTTL: Number(accessTokenMaxTTL), - // accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), - // accessTokenTrustedIps - // }); - // } else { - // await addMutateAsync({ - // identityId: identityAuthMethodData.identityId, - // organizationId: orgId, - // type, - // allowedServiceAccounts: allowedServiceAccounts || "", - // allowedProjects: allowedProjects || "", - // allowedZones: allowedZones || "", - // accessTokenTTL: Number(accessTokenTTL), - // accessTokenMaxTTL: Number(accessTokenMaxTTL), - // accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), - // accessTokenTrustedIps - // }); - // } + if (data) { + await updateMutateAsync({ + identityId: identityAuthMethodData.identityId, + organizationId: orgId, + oidcDiscoveryUrl, + caCert, + boundIssuer, + boundAudiences, + boundClaims: Object.fromEntries(boundClaims.map((entry) => [entry.key, entry.value])), + boundSubject, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + identityId: identityAuthMethodData.identityId, + oidcDiscoveryUrl, + caCert, + boundIssuer, + boundAudiences, + boundClaims: Object.fromEntries(boundClaims.map((entry) => [entry.key, entry.value])), + boundSubject, + organizationId: orgId, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } handlePopUpToggle("identityAuthMethod", false); @@ -198,11 +218,7 @@ export const IdentityOidcAuthForm = ({ isError={Boolean(error)} errorText={error?.message} > - + )} /> @@ -216,7 +232,16 @@ export const IdentityOidcAuthForm = ({ isError={Boolean(error)} errorText={error?.message} > - + + + )} + /> + ( + +