From c90e423e4aacf1a5742626509d4e24a5b4d23e95 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 3 Jul 2024 02:06:02 +0800 Subject: [PATCH 01/12] feat: initial setup --- backend/src/@types/knex.d.ts | 8 + .../20240702153745_identity-oidc-auth.ts | 34 ++ backend/src/db/schemas/identity-oidc-auths.ts | 31 ++ backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 1 + .../routes/v1/identity-oidc-auth-router.ts | 66 +++ backend/src/server/routes/v1/index.ts | 2 + .../identity-oidc-auth-dal.ts | 10 + .../identity-oidc-auth-service.ts | 11 + .../identity-oidc-auth-types.ts | 0 .../identity-oidc-auth-validators.ts | 14 + frontend/src/hooks/api/identities/enums.tsx | 3 +- .../IdentityAuthMethodModal.tsx | 13 +- .../IdentitySection/IdentityOidcAuthForm.tsx | 440 ++++++++++++++++++ 14 files changed, 632 insertions(+), 2 deletions(-) create mode 100644 backend/src/db/migrations/20240702153745_identity-oidc-auth.ts create mode 100644 backend/src/db/schemas/identity-oidc-auths.ts create mode 100644 backend/src/server/routes/v1/identity-oidc-auth-router.ts create mode 100644 backend/src/services/identity-oidc-auth/identity-oidc-auth-dal.ts create mode 100644 backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts create mode 100644 backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts create mode 100644 backend/src/services/identity-oidc-auth/identity-oidc-auth-validators.ts create mode 100644 frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 82ca897421..bbc53fa527 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -92,6 +92,9 @@ import { TIdentityKubernetesAuths, TIdentityKubernetesAuthsInsert, TIdentityKubernetesAuthsUpdate, + TIdentityOidcAuths, + TIdentityOidcAuthsInsert, + TIdentityOidcAuthsUpdate, TIdentityOrgMemberships, TIdentityOrgMembershipsInsert, TIdentityOrgMembershipsUpdate, @@ -475,6 +478,11 @@ declare module "knex/types/tables" { TIdentityAzureAuthsInsert, TIdentityAzureAuthsUpdate >; + [TableName.IdentityOidcAuth]: KnexOriginal.CompositeTableType< + TIdentityOidcAuths, + TIdentityOidcAuthsInsert, + TIdentityOidcAuthsUpdate + >; [TableName.IdentityUaClientSecret]: KnexOriginal.CompositeTableType< TIdentityUaClientSecrets, TIdentityUaClientSecretsInsert, diff --git a/backend/src/db/migrations/20240702153745_identity-oidc-auth.ts b/backend/src/db/migrations/20240702153745_identity-oidc-auth.ts new file mode 100644 index 0000000000..fbf5db2a09 --- /dev/null +++ b/backend/src/db/migrations/20240702153745_identity-oidc-auth.ts @@ -0,0 +1,34 @@ +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.IdentityOidcAuth))) { + await knex.schema.createTable(TableName.IdentityOidcAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("oidcDiscoveryUrl").notNullable(); + t.text("encryptedCaCert").notNullable(); + t.string("caCertIV").notNullable(); + t.string("caCertTag").notNullable(); + t.string("boundIssuer").notNullable(); + t.string("boundAudiences").notNullable(); + t.jsonb("boundClaims").notNullable(); + t.string("boundSubject"); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.IdentityOidcAuth); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityOidcAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityOidcAuth); +} diff --git a/backend/src/db/schemas/identity-oidc-auths.ts b/backend/src/db/schemas/identity-oidc-auths.ts new file mode 100644 index 0000000000..3d7d38c41a --- /dev/null +++ b/backend/src/db/schemas/identity-oidc-auths.ts @@ -0,0 +1,31 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityOidcAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + identityId: z.string().uuid(), + oidcDiscoveryUrl: z.string(), + encryptedCaCert: z.string(), + caCertIV: z.string(), + caCertTag: z.string(), + boundIssuer: z.string(), + boundAudiences: z.string(), + boundClaims: z.unknown(), + boundSubject: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityOidcAuths = z.infer; +export type TIdentityOidcAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityOidcAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index af8c2070a1..4a56ce8716 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -28,6 +28,7 @@ export * from "./identity-aws-auths"; export * from "./identity-azure-auths"; export * from "./identity-gcp-auths"; export * from "./identity-kubernetes-auths"; +export * from "./identity-oidc-auths"; export * from "./identity-org-memberships"; export * from "./identity-project-additional-privilege"; export * from "./identity-project-membership-role"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index bdc574bcb8..eff1b25944 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -59,6 +59,7 @@ export enum TableName { IdentityAzureAuth = "identity_azure_auths", IdentityUaClientSecret = "identity_ua_client_secrets", IdentityAwsAuth = "identity_aws_auths", + IdentityOidcAuth = "identity_oidc_auths", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", diff --git a/backend/src/server/routes/v1/identity-oidc-auth-router.ts b/backend/src/server/routes/v1/identity-oidc-auth-router.ts new file mode 100644 index 0000000000..a1ad47a47a --- /dev/null +++ b/backend/src/server/routes/v1/identity-oidc-auth-router.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; + +import { IdentityOidcAuthsSchema } from "@app/db/schemas"; +import { 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"; + +export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/oidc-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach OIDC Auth configuration onto 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() + }), + response: { + 200: z.object({ + identityOidcAuth: IdentityOidcAuthsSchema + }) + } + }, + handler: async () => {} + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index c2969b382e..5e3c842adb 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -8,6 +8,7 @@ import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router"; import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-router"; +import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router"; import { registerIdentityRouter } from "./identity-router"; import { registerIdentityUaRouter } from "./identity-universal-auth-router"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; @@ -39,6 +40,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await authRouter.register(registerIdentityAccessTokenRouter); await authRouter.register(registerIdentityAwsAuthRouter); await authRouter.register(registerIdentityAzureAuthRouter); + await authRouter.register(registerIdentityOidcAuthRouter); }, { prefix: "/auth" } ); diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-dal.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-dal.ts new file mode 100644 index 0000000000..1d8ab3c137 --- /dev/null +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityOidcAuthDALFactory = ReturnType; + +export const identityOidcAuthDALFactory = (db: TDbClient) => { + const oidcAuthOrm = ormify(db, TableName.IdentityOidcAuth); + return oidcAuthOrm; +}; 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 new file mode 100644 index 0000000000..e6493ac0f7 --- /dev/null +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -0,0 +1,11 @@ +import { TIdentityOidcAuthDALFactory } from "./identity-oidc-auth-dal"; + +type TIdentityOidcAuthServiceFactoryDep = { + identityOidcAuthDAL: TIdentityOidcAuthDALFactory; +}; + +export type TIdentityOidcAuthServiceFactory = ReturnType; + +export const identityOidcAuthServiceFactory = ({ identityOidcAuthDAL }: TIdentityOidcAuthServiceFactoryDep) => { + return {}; +}; 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 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-validators.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-validators.ts new file mode 100644 index 0000000000..a9518e3eaf --- /dev/null +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-validators.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const validateOidcAuthAudiencesField = z + .string() + .trim() + .default("") + .transform((data) => { + if (data === "") return ""; + // Trim each ID and join with ', ' to ensure formatting + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }); diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index 66af910939..2702c5b863 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -3,5 +3,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/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx index a1dc5d678a..d78953f210 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx @@ -18,6 +18,7 @@ import { IdentityAwsAuthForm } from "./IdentityAwsAuthForm"; import { IdentityAzureAuthForm } from "./IdentityAzureAuthForm"; import { IdentityGcpAuthForm } from "./IdentityGcpAuthForm"; import { IdentityKubernetesAuthForm } from "./IdentityKubernetesAuthForm"; +import { IdentityOidcAuthForm } from "./IdentityOidcAuthForm"; import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm"; type Props = { @@ -34,7 +35,8 @@ const identityAuthMethods = [ { label: "Kubernetes Auth", value: IdentityAuthMethod.KUBERNETES_AUTH }, { label: "GCP Auth", value: IdentityAuthMethod.GCP_AUTH }, { label: "AWS Auth", value: IdentityAuthMethod.AWS_AUTH }, - { label: "Azure Auth", value: IdentityAuthMethod.AZURE_AUTH } + { label: "Azure Auth", value: IdentityAuthMethod.AZURE_AUTH }, + { label: "OIDC Auth", value: IdentityAuthMethod.OIDC_AUTH } ]; const schema = yup @@ -117,6 +119,15 @@ export const IdentityAuthMethodModal = ({ popUp, handlePopUpOpen, handlePopUpTog /> ); } + case IdentityAuthMethod.OIDC_AUTH: { + return ( + + ); + } default: { return
; } 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 new file mode 100644 index 0000000000..a6a4fb0c9d --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx @@ -0,0 +1,440 @@ +import { useEffect } 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 { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, IconButton, Input } from "@app/components/v2"; +import { useOrganization, useSubscription } from "@app/context"; +import { IdentityAuthMethod } from "@app/hooks/api/identities"; +import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + accessTokenTrustedIps: z + .array( + z.object({ + ipAddress: z.string().max(50) + }) + ) + .min(1), + accessTokenTTL: z.string(), + accessTokenMaxTTL: z.string(), + accessTokenNumUsesLimit: z.string(), + oidcDiscoveryUrl: z.string().url().min(1), + caCert: z.string().trim().default(""), + boundIssuer: z.string().min(1), + boundAudiences: z.string().optional().default(""), + boundClaims: z.array( + z.object({ + key: z.string(), + value: z.string() + }) + ), + boundSubject: z.string().optional().default("") +}); + +export type FormData = z.infer; + +type Props = { + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["identityAuthMethod"]>, + state?: boolean + ) => void; + identityAuthMethodData: { + identityId: string; + name: string; + authMethod?: IdentityAuthMethod; + }; +}; + +export const IdentityOidcAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityAuthMethodData +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + // const { mutateAsync: addMutateAsync } = useAddIdentityGcpAuth(); + // const { mutateAsync: updateMutateAsync } = useUpdateIdentityGcpAuth(); + + // const { data } = useGetIdentityGcpAuth(identityAuthMethodData?.identityId ?? ""); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + } + }); + + const { + fields: boundClaimsFields, + append: appendBoundClaimField, + remove: removeBoundClaimField + } = useFieldArray({ + control, + name: "boundClaims" + }); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + 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]); + + const onFormSubmit = async ({ + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }: FormData) => { + try { + 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 + // }); + // } + + handlePopUpToggle("identityAuthMethod", false); + + createNotification({ + text: `Successfully ${ + identityAuthMethodData?.authMethod ? "updated" : "configured" + } auth method`, + type: "success" + }); + + reset(); + } catch (err) { + createNotification({ + text: `Failed to ${identityAuthMethodData?.authMethod ? "update" : "configure"} identity`, + type: "error" + }); + } + }; + + return ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {boundClaimsFields.map(({ id }, index) => ( +
+ { + return ( + + field.onChange(e)} + placeholder="property" + /> + + ); + }} + /> + { + return ( + + field.onChange(e)} + placeholder="value1, value2" + /> + + ); + }} + /> + + removeBoundClaimField(index)} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {accessTokenTrustedIpsFields.map(({ id }, index) => ( +
+ { + return ( + + { + if (subscription?.ipAllowlisting) { + field.onChange(e); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + placeholder="123.456.789.0" + /> + + ); + }} + /> + { + if (subscription?.ipAllowlisting) { + removeAccessTokenTrustedIp(index); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+
+ + +
+ + ); +}; From fc9326272a281a9bf6ecb3832f5998eaa4d16cd8 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 3 Jul 2024 21:18:50 +0800 Subject: [PATCH 02/12] feat: finished up oidc auth management --- backend/src/@types/fastify.d.ts | 2 + backend/src/db/schemas/models.ts | 3 +- backend/src/server/routes/index.ts | 14 + .../routes/v1/identity-oidc-auth-router.ts | 135 ++++++- .../identity-oidc-auth-service.ts | 343 +++++++++++++++++- .../identity-oidc-auth-types.ts | 33 ++ .../src/hooks/api/identities/constants.tsx | 3 +- .../src/hooks/api/identities/mutations.tsx | 87 +++++ frontend/src/hooks/api/identities/queries.tsx | 20 +- frontend/src/hooks/api/identities/types.ts | 48 +++ .../IdentitySection/IdentityOidcAuthForm.tsx | 173 +++++---- 11 files changed, 778 insertions(+), 83 deletions(-) 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} > - + + + )} + /> + ( + +