diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 65da730724..21c44a3b50 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -39,6 +39,7 @@ import { TCertificateServiceFactory } from "@app/services/certificate/certificat import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; import { TCmekServiceFactory } from "@app/services/cmek/cmek-service"; +import { TExternalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; import { TExternalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service"; import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; @@ -185,6 +186,7 @@ declare module "fastify" { workflowIntegration: TWorkflowIntegrationServiceFactory; cmek: TCmekServiceFactory; migration: TExternalMigrationServiceFactory; + externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 6249152762..fb78bce4d2 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -336,6 +336,11 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; +import { + TExternalGroupOrgRoleMappings, + TExternalGroupOrgRoleMappingsInsert, + TExternalGroupOrgRoleMappingsUpdate +} from "@app/db/schemas/external-group-org-role-mappings"; import { TSecretV2TagJunction, TSecretV2TagJunctionInsert, @@ -808,5 +813,10 @@ declare module "knex/types/tables" { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate >; + [TableName.ExternalGroupOrgRoleMapping]: KnexOriginal.CompositeTableType< + TExternalGroupOrgRoleMappings, + TExternalGroupOrgRoleMappingsInsert, + TExternalGroupOrgRoleMappingsUpdate + >; } } diff --git a/backend/src/db/migrations/20241015145450_external-group-org-role-mapping.ts b/backend/src/db/migrations/20241015145450_external-group-org-role-mapping.ts new file mode 100644 index 0000000000..728d49c258 --- /dev/null +++ b/backend/src/db/migrations/20241015145450_external-group-org-role-mapping.ts @@ -0,0 +1,32 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils"; + +export async function up(knex: Knex): Promise { + // add external group to org role mapping table + if (!(await knex.schema.hasTable(TableName.ExternalGroupOrgRoleMapping))) { + await knex.schema.createTable(TableName.ExternalGroupOrgRoleMapping, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("groupName").notNullable(); + t.index("groupName"); + t.string("role").notNullable(); + t.uuid("roleId"); + t.foreign("roleId").references("id").inTable(TableName.OrgRoles); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + t.unique(["orgId", "groupName"]); + }); + + await createOnUpdateTrigger(knex, TableName.ExternalGroupOrgRoleMapping); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.ExternalGroupOrgRoleMapping)) { + await dropOnUpdateTrigger(knex, TableName.ExternalGroupOrgRoleMapping); + + await knex.schema.dropTable(TableName.ExternalGroupOrgRoleMapping); + } +} diff --git a/backend/src/db/schemas/external-group-org-role-mappings.ts b/backend/src/db/schemas/external-group-org-role-mappings.ts new file mode 100644 index 0000000000..f7e6eab25d --- /dev/null +++ b/backend/src/db/schemas/external-group-org-role-mappings.ts @@ -0,0 +1,27 @@ +// 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 ExternalGroupOrgRoleMappingsSchema = z.object({ + id: z.string().uuid(), + groupName: z.string(), + role: z.string(), + roleId: z.string().uuid().nullable().optional(), + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TExternalGroupOrgRoleMappings = z.infer; +export type TExternalGroupOrgRoleMappingsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TExternalGroupOrgRoleMappingsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 08f3e79cee..7b48bb6fcd 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -17,6 +17,7 @@ export enum TableName { Groups = "groups", GroupProjectMembership = "group_project_memberships", GroupProjectMembershipRole = "group_project_membership_roles", + ExternalGroupOrgRoleMapping = "external_group_org_role_mappings", UserGroupMembership = "user_group_membership", UserAliases = "user_aliases", UserEncryptionKey = "user_encryption_keys", 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 83bc98009f..2744b34c22 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -190,7 +190,9 @@ export enum EventType { DELETE_CMEK = "delete-cmek", GET_CMEKS = "get-cmeks", CMEK_ENCRYPT = "cmek-encrypt", - CMEK_DECRYPT = "cmek-decrypt" + CMEK_DECRYPT = "cmek-decrypt", + UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "update-external-group-org-role-mapping", + GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "get-external-group-org-role-mapping" } interface UserActorMetadata { @@ -1604,6 +1606,18 @@ interface CmekDecryptEvent { }; } +interface GetExternalGroupOrgRoleMappingsEvent { + type: EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS; + metadata?: Record; // not needed, based off orgId +} + +interface UpdateExternalGroupOrgRoleMappingsEvent { + type: EventType.UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS; + metadata: { + mappings: { groupName: string; roleSlug: string }[]; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -1750,4 +1764,6 @@ export type Event = | DeleteCmekEvent | GetCmeksEvent | CmekEncryptEvent - | CmekDecryptEvent; + | CmekDecryptEvent + | GetExternalGroupOrgRoleMappingsEvent + | UpdateExternalGroupOrgRoleMappingsEvent; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 8b94d5b93b..9165408fa2 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -3,7 +3,7 @@ import slugify from "@sindresorhus/slugify"; import jwt from "jsonwebtoken"; import { scimPatch } from "scim-patch"; -import { OrgMembershipRole, OrgMembershipStatus, TableName, TOrgMemberships, TUsers } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipStatus, TableName, TGroups, TOrgMemberships, TUsers } from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; @@ -13,6 +13,7 @@ import { BadRequestError, NotFoundError, ScimRequestError, UnauthorizedError } f import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TOrgPermission } from "@app/lib/types"; import { AuthTokenType } from "@app/services/auth/auth-type"; +import { TExternalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { deleteOrgMembershipFn } from "@app/services/org/org-fns"; @@ -71,7 +72,10 @@ type TScimServiceFactoryDep = { | "transaction" | "updateMembershipById" >; - orgMembershipDAL: Pick; + orgMembershipDAL: Pick< + TOrgMembershipDALFactory, + "find" | "findOne" | "create" | "updateById" | "findById" | "update" + >; projectDAL: Pick; projectMembershipDAL: Pick; groupDAL: Pick< @@ -102,6 +106,7 @@ type TScimServiceFactoryDep = { permissionService: Pick; smtpService: Pick; projectUserAdditionalPrivilegeDAL: Pick; + externalGroupOrgRoleMappingDAL: TExternalGroupOrgRoleMappingDALFactory; }; export type TScimServiceFactory = ReturnType; @@ -122,7 +127,8 @@ export const scimServiceFactory = ({ projectBotDAL, permissionService, projectUserAdditionalPrivilegeDAL, - smtpService + smtpService, + externalGroupOrgRoleMappingDAL }: TScimServiceFactoryDep) => { const createScimToken = async ({ actor, @@ -692,6 +698,43 @@ export const scimServiceFactory = ({ }); }; + const $syncNewMembersRoles = async (group: TGroups, members: TScimGroup["members"]) => { + // this function handles configuring newly provisioned users org membership if an external group mapping exists + + if (!members.length) return; + + const externalGroupMapping = await externalGroupOrgRoleMappingDAL.findOne({ + orgId: group.orgId, + groupName: group.name + }); + + // no mapping, user will have default org membership + if (!externalGroupMapping) return; + + // only get org memberships that are new (invites) + const newOrgMemberships = await orgMembershipDAL.find({ + status: "invited", + $in: { + id: members.map((member) => member.value) + } + }); + + if (!newOrgMemberships.length) return; + + // set new membership roles to group mapping value + await orgMembershipDAL.update( + { + $in: { + id: newOrgMemberships.map((membership) => membership.id) + } + }, + { + role: externalGroupMapping.role, + roleId: externalGroupMapping.roleId + } + ); + }; + const createScimGroup = async ({ displayName, orgId, members }: TCreateScimGroupDTO) => { const plan = await licenseService.getPlan(orgId); if (!plan.groups) @@ -745,6 +788,8 @@ export const scimServiceFactory = ({ tx }); + await $syncNewMembersRoles(group, members); + return { group, newMembers }; } @@ -820,22 +865,41 @@ export const scimServiceFactory = ({ orgId: string, { displayName, members = [] }: { displayName: string; members: { value: string }[] } ) => { + let group = await groupDAL.findOne({ + id: groupId, + orgId + }); + + if (!group) { + throw new ScimRequestError({ + detail: "Group Not Found", + status: 404 + }); + } + const updatedGroup = await groupDAL.transaction(async (tx) => { - const [group] = await groupDAL.update( - { - id: groupId, - orgId - }, - { - name: displayName - } - ); + if (group.name !== displayName) { + await externalGroupOrgRoleMappingDAL.update( + { + groupName: group.name, + orgId + }, + { + groupName: displayName + } + ); - if (!group) { - throw new ScimRequestError({ - detail: "Group Not Found", - status: 404 - }); + const [modifiedGroup] = await groupDAL.update( + { + id: groupId, + orgId + }, + { + name: displayName + } + ); + + group = modifiedGroup; } const orgMemberships = members.length @@ -892,6 +956,8 @@ export const scimServiceFactory = ({ return group; }); + await $syncNewMembersRoles(group, members); + return updatedGroup; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 2b84e2881f..326b283f50 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -97,6 +97,8 @@ import { certificateTemplateDALFactory } from "@app/services/certificate-templat import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal"; import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; import { cmekServiceFactory } from "@app/services/cmek/cmek-service"; +import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal"; +import { externalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; import { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue"; import { externalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service"; import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; @@ -336,6 +338,8 @@ export const registerRoutes = async ( const projectSlackConfigDAL = projectSlackConfigDALFactory(db); const workflowIntegrationDAL = workflowIntegrationDALFactory(db); + const externalGroupOrgRoleMappingDAL = externalGroupOrgRoleMappingDALFactory(db); + const permissionService = permissionServiceFactory({ permissionDAL, orgRoleDAL, @@ -442,7 +446,8 @@ export const registerRoutes = async ( projectKeyDAL, projectBotDAL, permissionService, - smtpService + smtpService, + externalGroupOrgRoleMappingDAL }); const ldapService = ldapConfigServiceFactory({ @@ -537,7 +542,12 @@ export const registerRoutes = async ( orgService, licenseService }); - const orgRoleService = orgRoleServiceFactory({ permissionService, orgRoleDAL, orgDAL }); + const orgRoleService = orgRoleServiceFactory({ + permissionService, + orgRoleDAL, + orgDAL, + externalGroupOrgRoleMappingDAL + }); const superAdminService = superAdminServiceFactory({ userDAL, authService: loginService, @@ -1231,6 +1241,13 @@ export const registerRoutes = async ( permissionService }); + const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({ + permissionService, + licenseService, + orgRoleDAL, + externalGroupOrgRoleMappingDAL + }); + await superAdminService.initServerCfg(); // // setup the communication with license key server @@ -1316,7 +1333,8 @@ export const registerRoutes = async ( orgAdmin: orgAdminService, slack: slackService, workflowIntegration: workflowIntegrationService, - migration: migrationService + migration: migrationService, + externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts b/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts new file mode 100644 index 0000000000..032deda7d3 --- /dev/null +++ b/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts @@ -0,0 +1,83 @@ +import slugify from "@sindresorhus/slugify"; +import { z } from "zod"; + +import { ExternalGroupOrgRoleMappingsSchema } from "@app/db/schemas/external-group-org-role-mappings"; +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"; + +export const registerExternalGroupOrgRoleMappingRouter = async (server: FastifyZodProvider) => { + // get mappings for current org + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: ExternalGroupOrgRoleMappingsSchema.array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const mappings = server.services.externalGroupOrgRoleMapping.listExternalGroupOrgRoleMappings(req.permission); + + await server.services.auditLog.createAuditLog({ + orgId: req.permission.orgId, + ...req.auditLogInfo, + event: { + type: EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS + } + }); + + return mappings; + } + }); + + // update mappings for current org + server.route({ + method: "PUT", // using put since this endpoint creates, updates and deletes mappings + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + mappings: z + .object({ + groupName: z.string().trim().min(1), + roleSlug: z + .string() + .min(1) + .toLowerCase() + .refine((v) => slugify(v) === v, { + message: "Role must be a valid slug" + }) + }) + .array() + }), + response: { + 200: ExternalGroupOrgRoleMappingsSchema.array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { body, permission } = req; + + const mappings = server.services.externalGroupOrgRoleMapping.updateExternalGroupOrgRoleMappings(body, permission); + + await server.services.auditLog.createAuditLog({ + orgId: permission.orgId, + ...req.auditLogInfo, + event: { + type: EventType.UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS, + metadata: body + } + }); + + return mappings; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 55b3236566..f9edfc18c0 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -7,6 +7,7 @@ import { registerProjectBotRouter } from "./bot-router"; import { registerCaRouter } from "./certificate-authority-router"; import { registerCertRouter } from "./certificate-router"; import { registerCertificateTemplateRouter } from "./certificate-template-router"; +import { registerExternalGroupOrgRoleMappingRouter } from "./external-group-org-role-mapping-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; @@ -106,4 +107,5 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerUserEngagementRouter, { prefix: "/user-engagement" }); await server.register(registerDashboardRouter, { prefix: "/dashboard" }); await server.register(registerCmekRouter, { prefix: "/kms" }); + await server.register(registerExternalGroupOrgRoleMappingRouter, { prefix: "/external-group-mappings" }); }; diff --git a/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-dal.ts b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-dal.ts new file mode 100644 index 0000000000..6f8f5973c2 --- /dev/null +++ b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-dal.ts @@ -0,0 +1,46 @@ +import { Tables } from "knex/types/tables"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { TExternalGroupOrgRoleMappings } from "@app/db/schemas/external-group-org-role-mappings"; +import { ormify } from "@app/lib/knex"; + +export type TExternalGroupOrgRoleMappingDALFactory = ReturnType; + +export const externalGroupOrgRoleMappingDALFactory = (db: TDbClient) => { + const externalGroupOrgRoleMappingOrm = ormify(db, TableName.ExternalGroupOrgRoleMapping); + + const updateExternalGroupOrgRoleMappingForOrg = async ( + orgId: string, + newMappings: readonly Tables[TableName.ExternalGroupOrgRoleMapping]["insert"][] + ) => { + const currentMappings = await externalGroupOrgRoleMappingOrm.find({ orgId }); + + const newMap = new Map(newMappings.map((mapping) => [mapping.groupName, mapping])); + const currentMap = new Map(currentMappings.map((mapping) => [mapping.groupName, mapping])); + + const mappingsToDelete = currentMappings.filter((mapping) => !newMap.has(mapping.groupName)); + const mappingsToUpdate = currentMappings + .filter((mapping) => newMap.has(mapping.groupName)) + .map((mapping) => ({ id: mapping.id, ...newMap.get(mapping.groupName) })); + const mappingsToInsert = newMappings.filter((mapping) => !currentMap.has(mapping.groupName)); + + const mappings = await externalGroupOrgRoleMappingOrm.transaction(async (tx) => { + await externalGroupOrgRoleMappingOrm.delete({ $in: { id: mappingsToDelete.map((mapping) => mapping.id) } }, tx); + + const updatedMappings: TExternalGroupOrgRoleMappings[] = []; + for await (const { id, ...mappingData } of mappingsToUpdate) { + const updatedMapping = await externalGroupOrgRoleMappingOrm.update({ id }, mappingData, tx); + updatedMappings.push(updatedMapping[0]); + } + + const insertedMappings = await externalGroupOrgRoleMappingOrm.insertMany(mappingsToInsert, tx); + + return [...updatedMappings, ...insertedMappings]; + }); + + return mappings; + }; + + return { ...externalGroupOrgRoleMappingOrm, updateExternalGroupOrgRoleMappingForOrg }; +}; diff --git a/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-fns.ts b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-fns.ts new file mode 100644 index 0000000000..fe67242512 --- /dev/null +++ b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-fns.ts @@ -0,0 +1,67 @@ +import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal"; +import { isCustomOrgRole } from "@app/services/org/org-role-fns"; + +import { TExternalGroupOrgMembershipRoleMappingDTO } from "./external-group-org-role-mapping-types"; + +export const constructGroupOrgMembershipRoleMappings = async ({ + mappingsDTO, + orgId, + orgRoleDAL, + licenseService +}: { + mappingsDTO: TExternalGroupOrgMembershipRoleMappingDTO[]; + orgRoleDAL: TOrgRoleDALFactory; + licenseService: TLicenseServiceFactory; + orgId: string; +}) => { + const plan = await licenseService.getPlan(orgId); + + // prevent setting custom values if not in plan + if (mappingsDTO.some((map) => isCustomOrgRole(map.roleSlug)) && !plan?.rbac) + throw new BadRequestError({ + message: + "Failed to set group organization role mapping due to plan RBAC restriction. Upgrade plan to set custom role mapping." + }); + + const customRoleSlugs = mappingsDTO + .filter((mapping) => isCustomOrgRole(mapping.roleSlug)) + .map((mapping) => mapping.roleSlug); + + let customRolesMap: Map = new Map(); + if (customRoleSlugs.length > 0) { + const customRoles = await orgRoleDAL.find({ + $in: { + slug: customRoleSlugs + } + }); + + customRolesMap = new Map(customRoles.map((role) => [role.slug, role])); + } + + const mappings = mappingsDTO.map(({ roleSlug, groupName }) => { + if (isCustomOrgRole(roleSlug)) { + const customRole = customRolesMap.get(roleSlug); + + if (!customRole) throw new NotFoundError({ message: `Custom role ${roleSlug} not found.` }); + + return { + groupName, + role: OrgMembershipRole.Custom, + roleId: customRole.id, + orgId + }; + } + + return { + groupName, + role: roleSlug, + roleId: null, // need to set explicitly null for updates + orgId + }; + }); + + return mappings; +}; diff --git a/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts new file mode 100644 index 0000000000..2d116eb384 --- /dev/null +++ b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts @@ -0,0 +1,78 @@ +import { ForbiddenError } from "@casl/ability"; +import { FastifyRequest } from "fastify"; + +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 { constructGroupOrgMembershipRoleMappings } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-fns"; +import { TSyncExternalGroupOrgMembershipRoleMappingsDTO } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-types"; +import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal"; + +import { TExternalGroupOrgRoleMappingDALFactory } from "./external-group-org-role-mapping-dal"; + +type TExternalGroupOrgRoleMappingServiceFactoryDep = { + externalGroupOrgRoleMappingDAL: TExternalGroupOrgRoleMappingDALFactory; + permissionService: TPermissionServiceFactory; + licenseService: TLicenseServiceFactory; + orgRoleDAL: TOrgRoleDALFactory; +}; + +export type TExternalGroupOrgRoleMappingServiceFactory = ReturnType; + +export const externalGroupOrgRoleMappingServiceFactory = ({ + externalGroupOrgRoleMappingDAL, + licenseService, + permissionService, + orgRoleDAL +}: TExternalGroupOrgRoleMappingServiceFactoryDep) => { + const listExternalGroupOrgRoleMappings = async (actor: FastifyRequest["permission"]) => { + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + // TODO: will need to change if we add support for ldap, oidc, etc. + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Scim); + + const mappings = await externalGroupOrgRoleMappingDAL.find({ + orgId: actor.orgId + }); + + return mappings; + }; + + const updateExternalGroupOrgRoleMappings = async ( + dto: TSyncExternalGroupOrgMembershipRoleMappingsDTO, + actor: FastifyRequest["permission"] + ) => { + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + // TODO: will need to change if we add support for ldap, oidc, etc. + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim); + + const mappings = await constructGroupOrgMembershipRoleMappings({ + mappingsDTO: dto.mappings, + orgRoleDAL, + licenseService, + orgId: actor.orgId + }); + + const data = await externalGroupOrgRoleMappingDAL.updateExternalGroupOrgRoleMappingForOrg(actor.orgId, mappings); + + return data; + }; + + return { + updateExternalGroupOrgRoleMappings, + listExternalGroupOrgRoleMappings + }; +}; diff --git a/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-types.ts b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-types.ts new file mode 100644 index 0000000000..2b2ccc10c6 --- /dev/null +++ b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-types.ts @@ -0,0 +1,8 @@ +export type TExternalGroupOrgMembershipRoleMappingDTO = { + groupName: string; + roleSlug: string; +}; + +export type TSyncExternalGroupOrgMembershipRoleMappingsDTO = { + mappings: TExternalGroupOrgMembershipRoleMappingDTO[]; +}; diff --git a/backend/src/services/org/org-role-fns.ts b/backend/src/services/org/org-role-fns.ts index cccf255c62..f460e18a4d 100644 --- a/backend/src/services/org/org-role-fns.ts +++ b/backend/src/services/org/org-role-fns.ts @@ -5,6 +5,8 @@ import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal"; const RESERVED_ORG_ROLE_SLUGS = Object.values(OrgMembershipRole).filter((role) => role !== "custom"); +export const isCustomOrgRole = (roleSlug: string) => !RESERVED_ORG_ROLE_SLUGS.includes(roleSlug as OrgMembershipRole); + // this is only for updating an org export const getDefaultOrgMembershipRoleForUpdateOrg = async ({ membershipRoleSlug, @@ -17,9 +19,7 @@ export const getDefaultOrgMembershipRoleForUpdateOrg = async ({ orgRoleDAL: TOrgRoleDALFactory; plan: TFeatureSet; }) => { - const isCustomRole = !RESERVED_ORG_ROLE_SLUGS.includes(membershipRoleSlug as OrgMembershipRole); - - if (isCustomRole) { + if (isCustomOrgRole(membershipRoleSlug)) { if (!plan?.rbac) throw new BadRequestError({ message: @@ -41,9 +41,7 @@ export const getDefaultOrgMembershipRoleForUpdateOrg = async ({ export const getDefaultOrgMembershipRole = async ( defaultOrgMembershipRole: string // can either be ID or reserved slug ) => { - const isCustomRole = !RESERVED_ORG_ROLE_SLUGS.includes(defaultOrgMembershipRole as OrgMembershipRole); - - if (isCustomRole) + if (isCustomOrgRole(defaultOrgMembershipRole)) return { roleId: defaultOrgMembershipRole, role: OrgMembershipRole.Custom diff --git a/backend/src/services/org/org-role-service.ts b/backend/src/services/org/org-role-service.ts index 198a9ea859..f11d53aa0a 100644 --- a/backend/src/services/org/org-role-service.ts +++ b/backend/src/services/org/org-role-service.ts @@ -11,6 +11,7 @@ import { } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TExternalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { ActorAuthMethod } from "../auth/auth-type"; @@ -20,11 +21,17 @@ type TOrgRoleServiceFactoryDep = { orgRoleDAL: TOrgRoleDALFactory; permissionService: TPermissionServiceFactory; orgDAL: TOrgDALFactory; + externalGroupOrgRoleMappingDAL: TExternalGroupOrgRoleMappingDALFactory; }; export type TOrgRoleServiceFactory = ReturnType; -export const orgRoleServiceFactory = ({ orgRoleDAL, orgDAL, permissionService }: TOrgRoleServiceFactoryDep) => { +export const orgRoleServiceFactory = ({ + orgRoleDAL, + orgDAL, + permissionService, + externalGroupOrgRoleMappingDAL +}: TOrgRoleServiceFactoryDep) => { const createRole = async ( userId: string, orgId: string, @@ -144,6 +151,17 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, orgDAL, permissionService }: message: "Cannot delete default org membership role. Please re-assign and try again." }); + const externalGroupMapping = await externalGroupOrgRoleMappingDAL.findOne({ + orgId, + roleId + }); + + if (externalGroupMapping) + throw new BadRequestError({ + message: + "Cannot delete role assigned to external group organization role mapping. Please re-assign external mapping and try again." + }); + const [deletedRole] = await orgRoleDAL.delete({ id: roleId, orgId }); if (!deletedRole) throw new NotFoundError({ message: "Organization role not found", name: "Update role" }); diff --git a/frontend/src/helpers/roles.ts b/frontend/src/helpers/roles.ts new file mode 100644 index 0000000000..afb9e12743 --- /dev/null +++ b/frontend/src/helpers/roles.ts @@ -0,0 +1,8 @@ +enum OrgMembershipRole { + Admin = "admin", + Member = "member", + NoAccess = "no-access" +} + +export const isCustomOrgRole = (slug: string) => + !Object.values(OrgMembershipRole).includes(slug as OrgMembershipRole); diff --git a/frontend/src/hooks/api/externalGroupOrgRoleMappings/index.ts b/frontend/src/hooks/api/externalGroupOrgRoleMappings/index.ts new file mode 100644 index 0000000000..177955438b --- /dev/null +++ b/frontend/src/hooks/api/externalGroupOrgRoleMappings/index.ts @@ -0,0 +1,3 @@ +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/externalGroupOrgRoleMappings/mutations.tsx b/frontend/src/hooks/api/externalGroupOrgRoleMappings/mutations.tsx new file mode 100644 index 0000000000..aac46726a1 --- /dev/null +++ b/frontend/src/hooks/api/externalGroupOrgRoleMappings/mutations.tsx @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { externalGroupOrgRoleMappingKeys } from "@app/hooks/api/externalGroupOrgRoleMappings/queries"; +import { TSyncExternalGroupOrgRoleMappingsDTO } from "@app/hooks/api/externalGroupOrgRoleMappings/types"; + +export const useUpdateExternalGroupOrgRoleMappings = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (payload: TSyncExternalGroupOrgRoleMappingsDTO) => { + const { data } = await apiRequest.put("/api/v1/external-group-mappings", payload); + + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries(externalGroupOrgRoleMappingKeys.list()); + } + }); +}; diff --git a/frontend/src/hooks/api/externalGroupOrgRoleMappings/queries.tsx b/frontend/src/hooks/api/externalGroupOrgRoleMappings/queries.tsx new file mode 100644 index 0000000000..620ec0447b --- /dev/null +++ b/frontend/src/hooks/api/externalGroupOrgRoleMappings/queries.tsx @@ -0,0 +1,33 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { TExternalGroupOrgRoleMappingList } from "@app/hooks/api/externalGroupOrgRoleMappings/types"; + +export const externalGroupOrgRoleMappingKeys = { + all: ["external-group-org-role-mapping"] as const, + list: () => [...externalGroupOrgRoleMappingKeys.all, "list"] as const +}; + +export const useGetExternalGroupOrgRoleMappings = ( + options?: Omit< + UseQueryOptions< + TExternalGroupOrgRoleMappingList, + unknown, + TExternalGroupOrgRoleMappingList, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: externalGroupOrgRoleMappingKeys.list(), + queryFn: async () => { + const { data } = await apiRequest.get( + "/api/v1/external-group-mappings" + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/externalGroupOrgRoleMappings/types.ts b/frontend/src/hooks/api/externalGroupOrgRoleMappings/types.ts new file mode 100644 index 0000000000..3130457426 --- /dev/null +++ b/frontend/src/hooks/api/externalGroupOrgRoleMappings/types.ts @@ -0,0 +1,18 @@ +export type TSyncExternalGroupOrgRoleMappingsDTO = { + mappings: { + groupName: string; + roleSlug: string; + }[]; +}; + +export type TExternalGroupOrgRoleMapping = { + id: string; + groupName: string; + role: string; + roleId: string; + orgId: string; + createdAt: string; + updatedAt: string; +}; + +export type TExternalGroupOrgRoleMappingList = TExternalGroupOrgRoleMapping[]; diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx index ad7098b458..eaef19c6c6 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx @@ -25,6 +25,7 @@ import { Tooltip } from "@app/components/v2"; import { useOrganization } from "@app/context"; +import { isCustomOrgRole } from "@app/helpers/roles"; import { useAddUsersToOrg, useFetchServerStatus, @@ -34,7 +35,6 @@ import { import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; -import { isCustomOrgRole } from "@app/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable"; import { OrgInviteLink } from "./OrgInviteLink"; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx index 577f727557..1229f0dc00 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx @@ -30,20 +30,12 @@ import { useOrganization, useSubscription } from "@app/context"; +import { isCustomOrgRole } from "@app/helpers/roles"; import { usePopUp } from "@app/hooks"; import { useDeleteOrgRole, useGetOrgRoles, useUpdateOrg } from "@app/hooks/api"; import { TOrgRole } from "@app/hooks/api/roles/types"; import { RoleModal } from "@app/views/Org/RolePage/components"; -enum OrgMembershipRole { - Admin = "admin", - Member = "member", - NoAccess = "no-access" -} - -export const isCustomOrgRole = (slug: string) => - !Object.values(OrgMembershipRole).includes(slug as OrgMembershipRole); - export const OrgRoleTable = () => { const router = useRouter(); const { currentOrg } = useOrganization(); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/ExternalGroupOrgRoleMappings.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/ExternalGroupOrgRoleMappings.tsx new file mode 100644 index 0000000000..8fa0d42c4c --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/ExternalGroupOrgRoleMappings.tsx @@ -0,0 +1,213 @@ +import { useEffect } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faTrash } 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 { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + FormControl, + FormLabel, + IconButton, + Input, + Select, + SelectItem, + Spinner +} from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useOrgPermission +} from "@app/context"; +import { isCustomOrgRole } from "@app/helpers/roles"; +import { useGetOrgRoles } from "@app/hooks/api"; +import { + useGetExternalGroupOrgRoleMappings, + useUpdateExternalGroupOrgRoleMappings +} from "@app/hooks/api/externalGroupOrgRoleMappings"; + +const formSchema = z.object({ + mappings: z + .object({ + groupName: z.string().trim().min(1, { message: "Group name is required" }), + roleSlug: z.string() + }) + .array() +}); + +type TForm = z.infer; + +export const ExternalGroupOrgRoleMappings = () => { + const { currentOrg } = useOrganization(); + const { data: roles, isLoading: isRolesLoading } = useGetOrgRoles(currentOrg?.id!); + const { data: mappings } = useGetExternalGroupOrgRoleMappings(); + const updateMappings = useUpdateExternalGroupOrgRoleMappings(); + const { permission } = useOrgPermission(); + + const { + control, + formState: { isDirty }, + handleSubmit, + reset + } = useForm({ + defaultValues: { mappings: [] }, + resolver: zodResolver(formSchema) + }); + + useEffect(() => { + if (!mappings || !roles) return; + + reset({ + mappings: mappings.map((mapping) => ({ + groupName: mapping.groupName, + roleSlug: + mapping.role === "custom" + ? roles.find((role) => mapping.roleId === role.id)!.slug + : mapping.role + })) + }); + }, [mappings, roles]); + + const mappingField = useFieldArray({ control, name: "mappings" }); + + const handleUpdateMappings = async (form: TForm) => { + try { + await updateMappings.mutateAsync(form); + createNotification({ + text: "Group organization role mappings updated.", + type: "success" + }); + } catch (e) { + console.error(e); + createNotification({ + text: "Failed to update group organization role mappings.", + type: "error" + }); + } + }; + + const disableScimEdit = permission.cannot(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim); + + return ( +
+

SCIM Group to Organization Role Mappings

+

+ Assign newly provisioned users a default organization role based on their SCIM group. +

+
+ {isRolesLoading || isRolesLoading ? ( + + ) : ( +
+ {mappingField.fields.map(({ id: scopeFieldId }, i) => ( +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+
+ {i === 0 && ( + + Role to Assign Users in this Group + + )} + ( + + + + )} + /> +
+ { + mappingField.remove(i); + }} + > + + +
+ ))} +
+ +
+ {isDirty && ( +
+ + {(isAllowed) => ( + + )} + +
+ )} +
+ )} + +
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx index ba216ff3ad..30cb16293c 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx @@ -9,6 +9,7 @@ import { } from "@app/context"; import { useUpdateOrg } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; +import { ExternalGroupOrgRoleMappings } from "@app/views/Settings/OrgSettingsPage/components/OrgAuthTab/ExternalGroupOrgRoleMappings"; import { ScimTokenModal } from "./ScimTokenModal"; @@ -76,6 +77,7 @@ export const OrgScimSection = () => {

Manage SCIM configuration

+

Enable SCIM

diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx index 8b5d52d315..b24f2a2054 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx @@ -12,8 +12,8 @@ import { useOrganization, useOrgPermission } from "@app/context"; +import { isCustomOrgRole } from "@app/helpers/roles"; import { useGetOrgRoles, useUpdateOrg } from "@app/hooks/api"; -import { isCustomOrgRole } from "@app/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable"; const formSchema = yup.object({ name: yup