From 265932df208b6913003a11cf258e8a6159a02ed5 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 15 May 2024 16:30:42 -0700 Subject: [PATCH] Finish preliminary azure auth method --- backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 8 + .../20240515211432_identity-azure-auth.ts | 29 ++ .../src/db/schemas/identity-azure-auths.ts | 26 ++ backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 4 +- .../ee/services/audit-log/audit-log-types.ts | 50 +++ backend/src/server/routes/index.ts | 14 +- .../routes/v1/identity-azure-auth-router.ts | 261 +++++++++++++ backend/src/server/routes/v1/index.ts | 2 + .../identity-azure-auth-dal.ts | 10 + .../identity-azure-auth-fns.ts | 34 ++ .../identity-azure-auth-service.ts | 286 ++++++++++++++ .../identity-azure-auth-types.ts | 120 ++++++ .../src/hooks/api/identities/constants.tsx | 3 +- frontend/src/hooks/api/identities/enums.tsx | 3 +- frontend/src/hooks/api/identities/index.tsx | 3 + .../src/hooks/api/identities/mutations.tsx | 78 +++- frontend/src/hooks/api/identities/queries.tsx | 25 +- frontend/src/hooks/api/identities/types.ts | 39 ++ .../IdentityAuthMethodModal.tsx | 13 +- .../IdentitySection/IdentityAzureAuthForm.tsx | 350 ++++++++++++++++++ 22 files changed, 1352 insertions(+), 9 deletions(-) create mode 100644 backend/src/db/migrations/20240515211432_identity-azure-auth.ts create mode 100644 backend/src/db/schemas/identity-azure-auths.ts create mode 100644 backend/src/server/routes/v1/identity-azure-auth-router.ts create mode 100644 backend/src/services/identity-azure-auth/identity-azure-auth-dal.ts create mode 100644 backend/src/services/identity-azure-auth/identity-azure-auth-fns.ts create mode 100644 backend/src/services/identity-azure-auth/identity-azure-auth-service.ts create mode 100644 backend/src/services/identity-azure-auth/identity-azure-auth-types.ts create mode 100644 frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAzureAuthForm.tsx diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 994b324f6..faeb89c4f 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -33,6 +33,7 @@ import { TGroupProjectServiceFactory } from "@app/services/group-project/group-p import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { TIdentityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; +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 { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; @@ -119,6 +120,7 @@ declare module "fastify" { identityUa: TIdentityUaServiceFactory; identityGcpAuth: TIdentityGcpAuthServiceFactory; identityAwsAuth: TIdentityAwsAuthServiceFactory; + identityAzureAuth: TIdentityAzureAuthServiceFactory; accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; accessApprovalRequest: TAccessApprovalRequestServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 9e8a2a752..5617539ce 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -62,6 +62,9 @@ import { TIdentityAwsAuths, TIdentityAwsAuthsInsert, TIdentityAwsAuthsUpdate, + TIdentityAzureAuths, + TIdentityAzureAuthsInsert, + TIdentityAzureAuthsUpdate, TIdentityGcpAuths, TIdentityGcpAuthsInsert, TIdentityGcpAuthsUpdate, @@ -348,6 +351,11 @@ declare module "knex/types/tables" { TIdentityAwsAuthsInsert, TIdentityAwsAuthsUpdate >; + [TableName.IdentityAzureAuth]: Knex.CompositeTableType< + TIdentityAzureAuths, + TIdentityAzureAuthsInsert, + TIdentityAzureAuthsUpdate + >; [TableName.IdentityUaClientSecret]: Knex.CompositeTableType< TIdentityUaClientSecrets, TIdentityUaClientSecretsInsert, diff --git a/backend/src/db/migrations/20240515211432_identity-azure-auth.ts b/backend/src/db/migrations/20240515211432_identity-azure-auth.ts new file mode 100644 index 000000000..3d91b2f9c --- /dev/null +++ b/backend/src/db/migrations/20240515211432_identity-azure-auth.ts @@ -0,0 +1,29 @@ +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.IdentityAzureAuth))) { + await knex.schema.createTable(TableName.IdentityAzureAuth, (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.timestamps(true, true, true); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("tenantId").notNullable(); + t.string("resource").notNullable(); + t.string("allowedServicePrincipalIds").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityAzureAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityAzureAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityAzureAuth); +} diff --git a/backend/src/db/schemas/identity-azure-auths.ts b/backend/src/db/schemas/identity-azure-auths.ts new file mode 100644 index 000000000..856f7b8f1 --- /dev/null +++ b/backend/src/db/schemas/identity-azure-auths.ts @@ -0,0 +1,26 @@ +// 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 IdentityAzureAuthsSchema = 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(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid(), + tenantId: z.string(), + resource: z.string(), + allowedServicePrincipalIds: z.string() +}); + +export type TIdentityAzureAuths = z.infer; +export type TIdentityAzureAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityAzureAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 4993003f9..917f5c1ea 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -18,6 +18,7 @@ export * from "./groups"; export * from "./identities"; export * from "./identity-access-tokens"; export * from "./identity-aws-auths"; +export * from "./identity-azure-auths"; export * from "./identity-gcp-auths"; export * from "./identity-org-memberships"; export * from "./identity-project-additional-privilege"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index aaaa0ff9a..e4c6f6a91 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -46,6 +46,7 @@ export enum TableName { IdentityAccessToken = "identity_access_tokens", IdentityUniversalAuth = "identity_universal_auths", IdentityGcpAuth = "identity_gcp_auths", + IdentityAzureAuth = "identity_azure_auths", IdentityUaClientSecret = "identity_ua_client_secrets", IdentityAwsAuth = "identity_aws_auths", IdentityOrgMembership = "identity_org_memberships", @@ -147,5 +148,6 @@ export enum ProjectUpgradeStatus { export enum IdentityAuthMethod { Univeral = "universal-auth", GCP_AUTH = "gcp-auth", - AWS_AUTH = "aws-auth" + AWS_AUTH = "aws-auth", + AZURE_AUTH = "azure-auth" } diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 0dc993358..36d236323 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -74,6 +74,10 @@ export enum EventType { ADD_IDENTITY_AWS_AUTH = "add-identity-aws-auth", UPDATE_IDENTITY_AWS_AUTH = "update-identity-aws-auth", GET_IDENTITY_AWS_AUTH = "get-identity-aws-auth", + LOGIN_IDENTITY_AZURE_AUTH = "login-identity-azure-auth", + ADD_IDENTITY_AZURE_AUTH = "add-identity-azure-auth", + UPDATE_IDENTITY_AZURE_AUTH = "update-identity-azure-auth", + GET_IDENTITY_AZURE_AUTH = "get-identity-azure-auth", CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", DELETE_ENVIRONMENT = "delete-environment", @@ -504,6 +508,48 @@ interface GetIdentityAwsAuthEvent { }; } +interface LoginIdentityAzureAuthEvent { + type: EventType.LOGIN_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + identityAzureAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityAzureAuthEvent { + type: EventType.ADD_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + tenantId: string; + resource: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityAzureAuthEvent { + type: EventType.UPDATE_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + tenantId?: string; + resource?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityAzureAuthEvent { + type: EventType.GET_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + }; +} + interface CreateEnvironmentEvent { type: EventType.CREATE_ENVIRONMENT; metadata: { @@ -766,6 +812,10 @@ export type Event = | AddIdentityAwsAuthEvent | UpdateIdentityAwsAuthEvent | GetIdentityAwsAuthEvent + | LoginIdentityAzureAuthEvent + | AddIdentityAzureAuthEvent + | UpdateIdentityAzureAuthEvent + | GetIdentityAzureAuthEvent | CreateEnvironmentEvent | UpdateEnvironmentEvent | DeleteEnvironmentEvent diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 3a050bcad..ebc572a57 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -80,6 +80,8 @@ import { identityAccessTokenDALFactory } from "@app/services/identity-access-tok import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { identityAwsAuthDALFactory } from "@app/services/identity-aws-auth/identity-aws-auth-dal"; import { identityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; +import { identityAzureAuthDALFactory } from "@app/services/identity-azure-auth/identity-azure-auth-dal"; +import { identityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service"; import { identityGcpAuthDALFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-dal"; import { identityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; @@ -209,8 +211,8 @@ export const registerRoutes = async ( const identityUaDAL = identityUaDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); const identityAwsAuthDAL = identityAwsAuthDALFactory(db); - const identityGcpAuthDAL = identityGcpAuthDALFactory(db); + const identityAzureAuthDAL = identityAzureAuthDALFactory(db); const auditLogDAL = auditLogDALFactory(db); const auditLogStreamDAL = auditLogStreamDALFactory(db); @@ -730,6 +732,15 @@ export const registerRoutes = async ( permissionService }); + const identityAzureAuthService = identityAzureAuthServiceFactory({ + identityAzureAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + permissionService, + licenseService + }); + const dynamicSecretProviders = buildDynamicSecretProviders(); const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({ queueService, @@ -800,6 +811,7 @@ export const registerRoutes = async ( identityUa: identityUaService, identityGcpAuth: identityGcpAuthService, identityAwsAuth: identityAwsAuthService, + identityAzureAuth: identityAzureAuthService, secretApprovalPolicy: sapService, accessApprovalPolicy: accessApprovalPolicyService, accessApprovalRequest: accessApprovalRequestService, diff --git a/backend/src/server/routes/v1/identity-azure-auth-router.ts b/backend/src/server/routes/v1/identity-azure-auth-router.ts new file mode 100644 index 000000000..9e9e49b56 --- /dev/null +++ b/backend/src/server/routes/v1/identity-azure-auth-router.ts @@ -0,0 +1,261 @@ +import { z } from "zod"; + +import { IdentityAzureAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; + +export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/azure-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with Azure Auth", + body: z.object({ + identityId: z.string(), + jwt: z.string() + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityAzureAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityAzureAuth.login(req.body); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg.orgId, + event: { + type: EventType.LOGIN_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityAzureAuthId: identityAzureAuth.id + } + } + }); + + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/azure-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach Azure Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + tenantId: z.string().trim(), + resource: z.string().trim(), + allowedServicePrincipalIds: z.string().trim(), + 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) + }), + response: { + 200: z.object({ + identityAzureAuth: IdentityAzureAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAzureAuth = await server.services.identityAzureAuth.attachAzureAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAzureAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId, + tenantId: identityAzureAuth.tenantId, + resource: identityAzureAuth.resource, + accessTokenTTL: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAzureAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityAzureAuth }; + } + }); + + server.route({ + method: "PATCH", + url: "/azure-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update Azure Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + tenantId: z.string().trim().optional(), + resource: z.string().trim().optional(), + allowedServicePrincipalIds: z.string().trim().optional(), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional(), + accessTokenTTL: z.number().int().min(0).optional(), + accessTokenNumUsesLimit: z.number().int().min(0).optional(), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .optional() + }), + response: { + 200: z.object({ + identityAzureAuth: IdentityAzureAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAzureAuth = await server.services.identityAzureAuth.updateAzureAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAzureAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId, + tenantId: identityAzureAuth.tenantId, + resource: identityAzureAuth.resource, + accessTokenTTL: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAzureAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityAzureAuth }; + } + }); + + server.route({ + method: "GET", + url: "/azure-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve Azure Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityAzureAuth: IdentityAzureAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAzureAuth = await server.services.identityAzureAuth.getAzureAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAzureAuth.orgId, + event: { + type: EventType.GET_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId + } + } + }); + + return { identityAzureAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 1a02a4cbf..1302a9469 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -3,6 +3,7 @@ import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; +import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router"; import { registerIdentityRouter } from "./identity-router"; import { registerIdentityUaRouter } from "./identity-ua"; @@ -32,6 +33,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await authRouter.register(registerIdentityGcpAuthRouter); await authRouter.register(registerIdentityAccessTokenRouter); await authRouter.register(registerIdentityAwsAuthRouter); + await authRouter.register(registerIdentityAzureAuthRouter); }, { prefix: "/auth" } ); diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-dal.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-dal.ts new file mode 100644 index 000000000..7038e2b9c --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-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 TIdentityAzureAuthDALFactory = ReturnType; + +export const identityAzureAuthDALFactory = (db: TDbClient) => { + const azureAuthOrm = ormify(db, TableName.IdentityAzureAuth); + return azureAuthOrm; +}; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-fns.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-fns.ts new file mode 100644 index 000000000..ad9e6f12d --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-fns.ts @@ -0,0 +1,34 @@ +import axios from "axios"; +import jwt from "jsonwebtoken"; + +import { UnauthorizedError } from "@app/lib/errors"; + +import { TAzureAuthJwtPayload, TAzureJwksUriResponse, TDecodedAzureAuthJwt } from "./identity-azure-auth-types"; + +export const validateAzureIdentity = async ({ + tenantId, + resource, + jwt: azureJwt +}: { + tenantId: string; + resource: string; + jwt: string; +}) => { + const jwksUri = `https://login.microsoftonline.com/${tenantId}/discovery/keys`; + + const decodedJwt = jwt.decode(azureJwt, { complete: true }) as TDecodedAzureAuthJwt; + const { kid } = decodedJwt.header; + + const { data }: { data: TAzureJwksUriResponse } = await axios.get(jwksUri); + const signingKeys = data.keys; + + const signingKey = signingKeys.find((key) => key.kid === kid); + if (!signingKey) throw new UnauthorizedError(); + + const publicKey = `-----BEGIN CERTIFICATE-----\n${signingKey.x5c[0]}\n-----END CERTIFICATE-----`; + + return jwt.verify(azureJwt, publicKey, { + audience: resource, + issuer: `https://sts.windows.net/${tenantId}/` + }) as TAzureAuthJwtPayload; +}; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts new file mode 100644 index 000000000..02e3996cf --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts @@ -0,0 +1,286 @@ +import { ForbiddenError } from "@casl/ability"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod } 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 { getConfig } from "@app/lib/config/env"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TIdentityAzureAuthDALFactory } from "./identity-azure-auth-dal"; +import { validateAzureIdentity } from "./identity-azure-auth-fns"; +import { + TAttachAzureAuthDTO, + TGetAzureAuthDTO, + TLoginAzureAuthDTO, + TUpdateAzureAuthDTO +} from "./identity-azure-auth-types"; + +type TIdentityAzureAuthServiceFactoryDep = { + identityAzureAuthDAL: TIdentityAzureAuthDALFactory; // TODO: Pick + identityOrgMembershipDAL: Pick; + identityAccessTokenDAL: Pick; + identityDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TIdentityAzureAuthServiceFactory = ReturnType; + +export const identityAzureAuthServiceFactory = ({ + identityAzureAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + permissionService, + licenseService +}: TIdentityAzureAuthServiceFactoryDep) => { + const login = async ({ identityId, jwt: azureJwt }: TLoginAzureAuthDTO) => { + const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); + if (!identityAzureAuth) throw new UnauthorizedError(); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityAzureAuth.identityId }); + if (!identityMembershipOrg) throw new UnauthorizedError(); + + const azureIdentity = await validateAzureIdentity({ + tenantId: identityAzureAuth.tenantId, + resource: identityAzureAuth.resource, + jwt: azureJwt + }); + + if (azureIdentity.tid !== identityAzureAuth.tenantId) throw new UnauthorizedError(); + + if (identityAzureAuth.allowedServicePrincipalIds) { + // validate if the service principal id is in the list of allowed service principal ids + + const isServicePrincipalAllowed = identityAzureAuth.allowedServicePrincipalIds + .split(",") + .map((servicePrincipalId) => servicePrincipalId.trim()) + .some((servicePrincipalId) => servicePrincipalId === azureIdentity.appid); + + if (!isServicePrincipalAllowed) throw new UnauthorizedError(); + } + + const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityAzureAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityAzureAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + { + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) + } + ); + + return { accessToken, identityAzureAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachAzureAuth = async ({ + identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachAzureAuthDTO) => { + 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 Azure 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 identityAzureAuth = await identityAzureAuthDAL.transaction(async (tx) => { + const doc = await identityAzureAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + await identityDAL.updateById( + identityMembershipOrg.identityId, + { + authMethod: IdentityAuthMethod.AZURE_AUTH + }, + tx + ); + return doc; + }); + return { ...identityAzureAuth, orgId: identityMembershipOrg.orgId }; + }; + + const updateAzureAuth = async ({ + identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateAzureAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AZURE_AUTH) + throw new BadRequestError({ + message: "Failed to update Azure Auth" + }); + + const identityGcpAuth = await identityAzureAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityGcpAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityGcpAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityGcpAuth.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 updatedAzureAuth = await identityAzureAuthDAL.updateById(identityGcpAuth.id, { + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + + return { + ...updatedAzureAuth, + orgId: identityMembershipOrg.orgId + }; + }; + + const getAzureAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetAzureAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AZURE_AUTH) + throw new BadRequestError({ + message: "The identity does not have Azure Auth attached" + }); + + const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + + return { ...identityAzureAuth, orgId: identityMembershipOrg.orgId }; + }; + + return { + login, + attachAzureAuth, + updateAzureAuth, + getAzureAuth + }; +}; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts new file mode 100644 index 000000000..b8c4b6855 --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts @@ -0,0 +1,120 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TLoginAzureAuthDTO = { + identityId: string; + jwt: string; +}; + +export type TAttachAzureAuthDTO = { + identityId: string; + tenantId: string; + resource: string; + allowedServicePrincipalIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateAzureAuthDTO = { + identityId: string; + tenantId?: string; + resource?: string; + allowedServicePrincipalIds?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetAzureAuthDTO = { + identityId: string; +} & Omit; + +export type TAzureJwksUriResponse = { + keys: { + kty: string; + use: string; + kid: string; + x5t: string; + n: string; + e: string; + x5c: string[]; + }[]; +}; + +type TUserPayload = { + aud: string; + iss: string; + iat: number; + nbf: number; + exp: number; + acr: string; + aio: string; + amr: string[]; + appid: string; + appidacr: string; + family_name: string; + given_name: string; + groups: string[]; + idtyp: string; + ipaddr: string; + name: string; + oid: string; + puid: string; + rh: string; + scp: string; + sub: string; + tid: string; + unique_name: string; + upn: string; + uti: string; + ver: string; + wids: string[]; + xms_cae: string; + xms_cc: string[]; + xms_filter_index: string[]; + xms_rd: string; + xms_ssm: string; + xms_tcdt: number; +}; + +type TAppRegistrationPayload = { + aud: string; + iss: string; + iat: number; + nbf: number; + exp: number; + aio: string; + appid: string; + appidacr: string; + idp: string; + idtyp: string; + oid: string; + rh: string; + sub: string; + tid: string; + uti: string; + ver: string; + xms_cae: string; + xms_cc: string[]; + xms_rd: string; + xms_ssm: string; + xms_tcdt: number; +}; + +export type TAzureAuthJwtPayload = TUserPayload | TAppRegistrationPayload; + +export type TDecodedAzureAuthJwt = { + header: { + type: string; + alg: string; + x5t: string; + kid: string; + }; + payload: TAzureAuthJwtPayload; + signature: string; + metadata: { + [key: string]: string; + }; +}; diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index d2669a308..57dc5beb6 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -3,5 +3,6 @@ import { IdentityAuthMethod } from "./enums"; export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { [IdentityAuthMethod.UNIVERSAL_AUTH]: "Universal Auth", [IdentityAuthMethod.GCP_AUTH]: "GCP Auth", - [IdentityAuthMethod.AWS_AUTH]: "AWS Auth" + [IdentityAuthMethod.AWS_AUTH]: "AWS Auth", + [IdentityAuthMethod.AZURE_AUTH]: "Azure Auth" }; diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index a67776c87..bd51053f1 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -1,5 +1,6 @@ export enum IdentityAuthMethod { UNIVERSAL_AUTH = "universal-auth", GCP_AUTH = "gcp-auth", - AWS_AUTH = "aws-auth" + AWS_AUTH = "aws-auth", + AZURE_AUTH = "azure-auth" } diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index d59e096c0..ce1f099be 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -2,6 +2,7 @@ export { identityAuthToNameMap } from "./constants"; export { IdentityAuthMethod } from "./enums"; export { useAddIdentityAwsAuth, + useAddIdentityAzureAuth, useAddIdentityGcpAuth, useAddIdentityUniversalAuth, useCreateIdentity, @@ -10,10 +11,12 @@ export { useRevokeIdentityUniversalAuthClientSecret, useUpdateIdentity, useUpdateIdentityAwsAuth, + useUpdateIdentityAzureAuth, useUpdateIdentityGcpAuth, useUpdateIdentityUniversalAuth} from "./mutations"; export { useGetIdentityAwsAuth, + useGetIdentityAzureAuth, useGetIdentityGcpAuth, useGetIdentityUniversalAuth, useGetIdentityUniversalAuthClientSecrets diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index 5c2e20ff0..615d53336 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -6,6 +6,7 @@ import { organizationKeys } from "../organization/queries"; import { identitiesKeys } from "./queries"; import { AddIdentityAwsAuthDTO, + AddIdentityAzureAuthDTO, AddIdentityGcpAuthDTO, AddIdentityUniversalAuthDTO, ClientSecretData, @@ -16,13 +17,14 @@ import { DeleteIdentityUniversalAuthClientSecretDTO, Identity, IdentityAwsAuth, + IdentityAzureAuth, IdentityGcpAuth, IdentityUniversalAuth, UpdateIdentityAwsAuthDTO, + UpdateIdentityAzureAuthDTO, UpdateIdentityDTO, UpdateIdentityGcpAuthDTO, - UpdateIdentityUniversalAuthDTO -} from "./types"; + UpdateIdentityUniversalAuthDTO} from "./types"; export const useCreateIdentity = () => { const queryClient = useQueryClient(); @@ -323,3 +325,75 @@ export const useUpdateIdentityAwsAuth = () => { } }); }; + +export const useAddIdentityAzureAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityAzureAuth } + } = await apiRequest.post<{ identityAzureAuth: IdentityAzureAuth }>( + `/api/v1/auth/azure-auth/identities/${identityId}`, + { + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityAzureAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useUpdateIdentityAzureAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityAzureAuth } + } = await apiRequest.patch<{ identityAzureAuth: IdentityAzureAuth }>( + `/api/v1/auth/azure-auth/identities/${identityId}`, + { + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityAzureAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index 2996f275f..c5236a86e 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -2,7 +2,12 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { ClientSecretData, IdentityAwsAuth, IdentityGcpAuth, IdentityUniversalAuth } from "./types"; +import { + ClientSecretData, + IdentityAwsAuth, + IdentityAzureAuth, + IdentityGcpAuth, + IdentityUniversalAuth} from "./types"; export const identitiesKeys = { getIdentityUniversalAuth: (identityId: string) => @@ -10,7 +15,8 @@ export const identitiesKeys = { getIdentityUniversalAuthClientSecrets: (identityId: string) => [{ identityId }, "identity-universal-auth-client-secrets"] as const, getIdentityGcpAuth: (identityId: string) => [{ identityId }, "identity-gcp-auth"] as const, - getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const + getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const, + getIdentityAzureAuth: (identityId: string) => [{ identityId }, "identity-azure-auth"] as const }; export const useGetIdentityUniversalAuth = (identityId: string) => { @@ -72,3 +78,18 @@ export const useGetIdentityAwsAuth = (identityId: string) => { } }); }; + +export const useGetIdentityAzureAuth = (identityId: string) => { + return useQuery({ + enabled: Boolean(identityId), + queryKey: identitiesKeys.getIdentityAzureAuth(identityId), + queryFn: async () => { + const { + data: { identityAzureAuth } + } = await apiRequest.get<{ identityAzureAuth: IdentityAzureAuth }>( + `/api/v1/auth/azure-auth/identities/${identityId}` + ); + return identityAzureAuth; + } + }); +}; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index c1d8e09fa..4f756fa60 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -195,6 +195,45 @@ export type UpdateIdentityAwsAuthDTO = { }[]; }; +export type IdentityAzureAuth = { + identityId: string; + tenantId: string; + resource: string; + allowedServicePrincipalIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityAzureAuthDTO = { + organizationId: string; + identityId: string; + tenantId: string; + resource: string; + allowedServicePrincipalIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityAzureAuthDTO = { + organizationId: string; + identityId: string; + tenantId?: string; + resource?: string; + allowedServicePrincipalIds?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + export type CreateIdentityUniversalAuthClientSecretDTO = { identityId: string; description?: string; 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 74c4c7f6d..b4cbca0a6 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 @@ -15,6 +15,7 @@ import { IdentityAuthMethod } from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { IdentityAwsAuthForm } from "./IdentityAwsAuthForm"; +import { IdentityAzureAuthForm } from "./IdentityAzureAuthForm"; import { IdentityGcpAuthForm } from "./IdentityGcpAuthForm"; import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm"; @@ -30,7 +31,8 @@ type Props = { const identityAuthMethods = [ { label: "Universal Auth", value: IdentityAuthMethod.UNIVERSAL_AUTH }, { label: "GCP Auth", value: IdentityAuthMethod.GCP_AUTH }, - { label: "AWS Auth", value: IdentityAuthMethod.AWS_AUTH } + { label: "AWS Auth", value: IdentityAuthMethod.AWS_AUTH }, + { label: "Azure Auth", value: IdentityAuthMethod.AZURE_AUTH } ]; const schema = yup @@ -86,6 +88,15 @@ export const IdentityAuthMethodModal = ({ popUp, handlePopUpOpen, handlePopUpTog /> ); } + case IdentityAuthMethod.AZURE_AUTH: { + return ( + + ); + } case IdentityAuthMethod.UNIVERSAL_AUTH: { return ( ; + +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 IdentityAzureAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityAuthMethodData +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + const { mutateAsync: addMutateAsync } = useAddIdentityAzureAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityAzureAuth(); + + const { data } = useGetIdentityAzureAuth(identityAuthMethodData?.identityId ?? ""); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + tenantId: "", + resource: "https://management.azure.com/", + allowedServicePrincipalIds: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + } + }); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + useEffect(() => { + if (data) { + reset({ + tenantId: data.tenantId, + resource: data.resource, + allowedServicePrincipalIds: data.allowedServicePrincipalIds, + 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({ + tenantId: "", + resource: "https://management.azure.com/", + allowedServicePrincipalIds: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + } + }, [data]); + + const onFormSubmit = async ({ + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }: FormData) => { + try { + if (!identityAuthMethodData) return; + + if (data) { + await updateMutateAsync({ + organizationId: orgId, + identityId: identityAuthMethodData.identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + organizationId: orgId, + identityId: identityAuthMethodData.identityId, + tenantId: tenantId || "", + resource: resource || "", + allowedServicePrincipalIds: allowedServicePrincipalIds || "", + 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 ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {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" + > + + +
+ ))} +
+ +
+
+ + +
+ + ); +};