feature: scim group org role mapping

This commit is contained in:
Scott Wilson
2024-10-15 07:57:26 -07:00
parent bda0681dee
commit 8c4a26b0e2
26 changed files with 802 additions and 41 deletions

View File

@@ -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

View File

@@ -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
>;
}
}

View File

@@ -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<void> {
// 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<void> {
if (await knex.schema.hasTable(TableName.ExternalGroupOrgRoleMapping)) {
await dropOnUpdateTrigger(knex, TableName.ExternalGroupOrgRoleMapping);
await knex.schema.dropTable(TableName.ExternalGroupOrgRoleMapping);
}
}

View File

@@ -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<typeof ExternalGroupOrgRoleMappingsSchema>;
export type TExternalGroupOrgRoleMappingsInsert = Omit<
z.input<typeof ExternalGroupOrgRoleMappingsSchema>,
TImmutableDBKeys
>;
export type TExternalGroupOrgRoleMappingsUpdate = Partial<
Omit<z.input<typeof ExternalGroupOrgRoleMappingsSchema>, TImmutableDBKeys>
>;

View File

@@ -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",

View File

@@ -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<string, never>; // 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;

View File

@@ -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<TOrgMembershipDALFactory, "find" | "findOne" | "create" | "updateById" | "findById">;
orgMembershipDAL: Pick<
TOrgMembershipDALFactory,
"find" | "findOne" | "create" | "updateById" | "findById" | "update"
>;
projectDAL: Pick<TProjectDALFactory, "find" | "findProjectGhostUser">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "delete" | "findProjectMembershipsByUserId">;
groupDAL: Pick<
@@ -102,6 +106,7 @@ type TScimServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
smtpService: Pick<TSmtpService, "sendMail">;
projectUserAdditionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">;
externalGroupOrgRoleMappingDAL: TExternalGroupOrgRoleMappingDALFactory;
};
export type TScimServiceFactory = ReturnType<typeof scimServiceFactory>;
@@ -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 }[] }
) => {
const updatedGroup = await groupDAL.transaction(async (tx) => {
const [group] = await groupDAL.update(
{
id: groupId,
orgId
},
{
name: displayName
}
);
let group = await groupDAL.findOne({
id: groupId,
orgId
});
if (!group) {
throw new ScimRequestError({
detail: "Group Not Found",
status: 404
});
if (!group) {
throw new ScimRequestError({
detail: "Group Not Found",
status: 404
});
}
const updatedGroup = await groupDAL.transaction(async (tx) => {
if (group.name !== displayName) {
await externalGroupOrgRoleMappingDAL.update(
{
groupName: group.name,
orgId
},
{
groupName: displayName
}
);
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;
};

View File

@@ -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[] = [];

View File

@@ -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: "POST", // using post 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;
}
});
};

View File

@@ -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" });
};

View File

@@ -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<typeof externalGroupOrgRoleMappingDALFactory>;
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 };
};

View File

@@ -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<string, TOrgRoles> = 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;
};

View File

@@ -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<typeof externalGroupOrgRoleMappingServiceFactory>;
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
};
};

View File

@@ -0,0 +1,8 @@
export type TExternalGroupOrgMembershipRoleMappingDTO = {
groupName: string;
roleSlug: string;
};
export type TSyncExternalGroupOrgMembershipRoleMappingsDTO = {
mappings: TExternalGroupOrgMembershipRoleMappingDTO[];
};

View File

@@ -5,6 +5,9 @@ import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal";
const RESERVED_ORG_ROLE_SLUGS = Object.values(OrgMembershipRole).filter((role) => role !== "custom");
export const isCustomOrgRole = (membershipSlug: string) =>
!RESERVED_ORG_ROLE_SLUGS.includes(membershipSlug as OrgMembershipRole);
// this is only for updating an org
export const getDefaultOrgMembershipRoleForUpdateOrg = async ({
membershipRoleSlug,
@@ -17,9 +20,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 +42,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

View File

@@ -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<typeof orgRoleServiceFactory>;
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" });

View File

@@ -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);

View File

@@ -0,0 +1,3 @@
export * from "./mutations";
export * from "./queries";
export * from "./types";

View File

@@ -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.post("/api/v1/external-group-mappings", payload);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries(externalGroupOrgRoleMappingKeys.list());
}
});
};

View File

@@ -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<typeof externalGroupOrgRoleMappingKeys.list>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: externalGroupOrgRoleMappingKeys.list(),
queryFn: async () => {
const { data } = await apiRequest.get<TExternalGroupOrgRoleMappingList>(
"/api/v1/external-group-mappings"
);
return data;
},
...options
});
};

View File

@@ -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[];

View File

@@ -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";

View File

@@ -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();

View File

@@ -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<typeof formSchema>;
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<TForm>({
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 (
<div className="py-4">
<h2 className="text-md text-mineshaft-100">SCIM Group to Organization Role Mappings</h2>
<p className="text-sm text-mineshaft-300">
Assign newly provisioned users a default organization role based on their SCIM group.
</p>
<form onSubmit={handleSubmit(handleUpdateMappings)} className="pt-4">
{isRolesLoading || isRolesLoading ? (
<Spinner className="self-center" size="sm" />
) : (
<div className="mb-2 flex flex-col space-y-2">
{mappingField.fields.map(({ id: scopeFieldId }, i) => (
<div key={scopeFieldId} className="flex items-end space-x-2">
<div className="flex-grow">
{i === 0 && (
<FormLabel
label="SCIM Group Name"
className="text-xs text-mineshaft-400"
tooltipClassName="max-w-md whitespace-pre-line"
tooltipText="The name associated with this group in your SCIM provider"
/>
)}
<Controller
control={control}
name={`mappings.${i}.groupName`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input
isDisabled={disableScimEdit}
{...field}
placeholder="SCIM group identifier..."
/>
</FormControl>
)}
/>
</div>
<div className="flex-1">
{i === 0 && (
<span className="text-xs text-mineshaft-400">
Role to Assign Users in this Group
</span>
)}
<Controller
control={control}
name={`mappings.${i}.roleSlug`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Select
isDisabled={disableScimEdit}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
{roles?.map((role) => (
<SelectItem value={role.slug} key={`role-${role.id}`}>
{role.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<IconButton
ariaLabel="delete key"
className="bottom-0.5 h-9"
variant="outline_bg"
isDisabled={disableScimEdit}
onClick={() => {
mappingField.remove(i);
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
isDisabled={disableScimEdit}
onClick={() =>
mappingField.append({
groupName: "",
roleSlug: isCustomOrgRole(currentOrg!.defaultMembershipRole)
? roles?.find((role) => currentOrg?.defaultMembershipRole === role.id)?.slug!
: currentOrg!.defaultMembershipRole
})
}
>
Add Mapping
</Button>
</div>
{isDirty && (
<div className="flex w-full justify-end">
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Scim}>
{(isAllowed) => (
<Button isDisabled={!isAllowed} colorSchema="secondary" type="submit">
Update Mappings
</Button>
)}
</OrgPermissionCan>
</div>
)}
</div>
)}
</form>
</div>
);
};

View File

@@ -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 = () => {
</div>
<p className="text-sm text-mineshaft-300">Manage SCIM configuration</p>
</div>
<ExternalGroupOrgRoleMappings />
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">Enable SCIM</h2>

View File

@@ -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