From c90e423e4aacf1a5742626509d4e24a5b4d23e95 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 3 Jul 2024 02:06:02 +0800 Subject: [PATCH] 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" + > + + +
+ ))} +
+ +
+
+ + +
+ + ); +};