Skip to content

Commit

Permalink
Merge pull request #2549 from scott-ray-wilson/org-default-role
Browse files Browse the repository at this point in the history
Feat: Default Org Membership Role
  • Loading branch information
scott-ray-wilson authored Oct 9, 2024
2 parents 2ae91db + e386285 commit 75d71d4
Show file tree
Hide file tree
Showing 18 changed files with 383 additions and 56 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Knex } from "knex";

import { TableName } from "@app/db/schemas";

export async function up(knex: Knex): Promise<void> {
// org default role
if (await knex.schema.hasTable(TableName.Organization)) {
const hasDefaultRoleCol = await knex.schema.hasColumn(TableName.Organization, "defaultMembershipRole");

if (!hasDefaultRoleCol) {
await knex.schema.alterTable(TableName.Organization, (tb) => {
tb.string("defaultMembershipRole").notNullable().defaultTo("member");
});
}
}
}

export async function down(knex: Knex): Promise<void> {
// org default role
if (await knex.schema.hasTable(TableName.Organization)) {
const hasDefaultRoleCol = await knex.schema.hasColumn(TableName.Organization, "defaultMembershipRole");

if (hasDefaultRoleCol) {
await knex.schema.alterTable(TableName.Organization, (tb) => {
tb.dropColumn("defaultMembershipRole");
});
}
}
}
3 changes: 2 additions & 1 deletion backend/src/db/schemas/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export const OrganizationsSchema = z.object({
authEnforced: z.boolean().default(false).nullable().optional(),
scimEnabled: z.boolean().default(false).nullable().optional(),
kmsDefaultKeyId: z.string().uuid().nullable().optional(),
kmsEncryptedDataKey: zodBuffer.nullable().optional()
kmsEncryptedDataKey: zodBuffer.nullable().optional(),
defaultMembershipRole: z.string().default("member")
});

export type TOrganizations = z.infer<typeof OrganizationsSchema>;
Expand Down
20 changes: 10 additions & 10 deletions backend/src/ee/services/ldap-config/ldap-config-service.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import { ForbiddenError } from "@casl/ability";
import jwt from "jsonwebtoken";

import {
OrgMembershipRole,
OrgMembershipStatus,
SecretKeyEncoding,
TableName,
TLdapConfigsUpdate,
TUsers
} from "@app/db/schemas";
import { OrgMembershipStatus, SecretKeyEncoding, TableName, TLdapConfigsUpdate, 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";
Expand All @@ -28,6 +21,7 @@ import { TokenType } from "@app/services/auth-token/auth-token-types";
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
Expand Down Expand Up @@ -444,11 +438,14 @@ export const ldapConfigServiceFactory = ({
{ tx }
);
if (!orgMembership) {
const { role, roleId } = await getDefaultOrgMembershipRole(organization.defaultMembershipRole);

await orgDAL.createMembership(
{
userId: userAlias.userId,
orgId,
role: OrgMembershipRole.Member,
role,
roleId,
status: OrgMembershipStatus.Accepted,
isActive: true
},
Expand Down Expand Up @@ -529,12 +526,15 @@ export const ldapConfigServiceFactory = ({
);

if (!orgMembership) {
const { role, roleId } = await getDefaultOrgMembershipRole(organization.defaultMembershipRole);

await orgMembershipDAL.create(
{
userId: newUser.id,
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
role,
roleId,
status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
Expand Down
13 changes: 10 additions & 3 deletions backend/src/ee/services/oidc/oidc-config-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ForbiddenError } from "@casl/ability";
import jwt from "jsonwebtoken";
import { Issuer, Issuer as OpenIdIssuer, Strategy as OpenIdStrategy, TokenSet } from "openid-client";

import { OrgMembershipRole, OrgMembershipStatus, SecretKeyEncoding, TableName, TUsers } from "@app/db/schemas";
import { OrgMembershipStatus, SecretKeyEncoding, TableName, TUsers } from "@app/db/schemas";
import { TOidcConfigsUpdate } from "@app/db/schemas/oidc-configs";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
Expand All @@ -23,6 +23,7 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se
import { TokenType } from "@app/services/auth-token/auth-token-types";
import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { getServerCfg } from "@app/services/super-admin/super-admin-service";
Expand Down Expand Up @@ -187,12 +188,15 @@ export const oidcConfigServiceFactory = ({
{ tx }
);
if (!orgMembership) {
const { role, roleId } = await getDefaultOrgMembershipRole(organization.defaultMembershipRole);

await orgMembershipDAL.create(
{
userId: userAlias.userId,
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
role,
roleId,
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
Expand Down Expand Up @@ -261,12 +265,15 @@ export const oidcConfigServiceFactory = ({
);

if (!orgMembership) {
const { role, roleId } = await getDefaultOrgMembershipRole(organization.defaultMembershipRole);

await orgMembershipDAL.create(
{
userId: newUser.id,
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
role,
roleId,
status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
Expand Down
12 changes: 9 additions & 3 deletions backend/src/ee/services/saml-config/saml-config-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { ForbiddenError } from "@casl/ability";
import jwt from "jsonwebtoken";

import {
OrgMembershipRole,
OrgMembershipStatus,
SecretKeyEncoding,
TableName,
Expand All @@ -26,6 +25,7 @@ import { TokenType } from "@app/services/auth-token/auth-token-types";
import { TIdentityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal";
import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { getServerCfg } from "@app/services/super-admin/super-admin-service";
Expand Down Expand Up @@ -369,12 +369,15 @@ export const samlConfigServiceFactory = ({
{ tx }
);
if (!orgMembership) {
const { role, roleId } = await getDefaultOrgMembershipRole(organization.defaultMembershipRole);

await orgMembershipDAL.create(
{
userId: userAlias.userId,
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
role,
roleId,
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
Expand Down Expand Up @@ -472,12 +475,15 @@ export const samlConfigServiceFactory = ({
);

if (!orgMembership) {
const { role, roleId } = await getDefaultOrgMembershipRole(organization.defaultMembershipRole);

await orgMembershipDAL.create(
{
userId: newUser.id,
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
role,
roleId,
status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
Expand Down
11 changes: 9 additions & 2 deletions backend/src/ee/services/scim/scim-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { AuthTokenType } from "@app/services/auth/auth-type";
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";
import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
Expand Down Expand Up @@ -318,12 +319,15 @@ export const scimServiceFactory = ({
);

if (!orgMembership) {
const { role, roleId } = await getDefaultOrgMembershipRole(org.defaultMembershipRole);

orgMembership = await orgMembershipDAL.create(
{
userId: userAlias.userId,
inviteEmail: email,
orgId,
role: OrgMembershipRole.NoAccess,
role,
roleId,
status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
Expand Down Expand Up @@ -391,12 +395,15 @@ export const scimServiceFactory = ({
orgMembership = foundOrgMembership;

if (!orgMembership) {
const { role, roleId } = await getDefaultOrgMembershipRole(org.defaultMembershipRole);

orgMembership = await orgMembershipDAL.create(
{
userId: user.id,
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
role,
roleId,
status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
isActive: true
},
Expand Down
2 changes: 1 addition & 1 deletion backend/src/server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ export const registerRoutes = async (
orgService,
licenseService
});
const orgRoleService = orgRoleServiceFactory({ permissionService, orgRoleDAL });
const orgRoleService = orgRoleServiceFactory({ permissionService, orgRoleDAL, orgDAL });
const superAdminService = superAdminServiceFactory({
userDAL,
authService: loginService,
Expand Down
11 changes: 10 additions & 1 deletion backend/src/server/routes/v1/organization-router.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import slugify from "@sindresorhus/slugify";
import { z } from "zod";

import {
Expand Down Expand Up @@ -217,7 +218,15 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
.regex(/^[a-zA-Z0-9-]+$/, "Slug must only contain alphanumeric characters or hyphens")
.optional(),
authEnforced: z.boolean().optional(),
scimEnabled: z.boolean().optional()
scimEnabled: z.boolean().optional(),
defaultMembershipRoleSlug: z
.string()
.min(1)
.trim()
.refine((v) => slugify(v) === v, {
message: "Membership role must be a valid slug"
})
.optional()
}),
response: {
200: z.object({
Expand Down
1 change: 1 addition & 0 deletions backend/src/services/org/org-dal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ export const orgDALFactory = (db: TDbClient) => {
db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").withSchema(TableName.Users),
db.ref("scimEnabled").withSchema(TableName.Organization),
db.ref("defaultMembershipRole").withSchema(TableName.Organization),
db.ref("externalId").withSchema(TableName.UserAliases)
)
.where({ isGhost: false });
Expand Down
54 changes: 54 additions & 0 deletions backend/src/services/org/org-role-fns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { OrgMembershipRole } from "@app/db/schemas";
import { TFeatureSet } from "@app/ee/services/license/license-types";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal";

const RESERVED_ORG_ROLE_SLUGS = Object.values(OrgMembershipRole).filter((role) => role !== "custom");

// this is only for updating an org
export const getDefaultOrgMembershipRoleForUpdateOrg = async ({
membershipRoleSlug,
orgRoleDAL,
plan,
orgId
}: {
orgId: string;
membershipRoleSlug: string;
orgRoleDAL: TOrgRoleDALFactory;
plan: TFeatureSet;
}) => {
const isCustomRole = !RESERVED_ORG_ROLE_SLUGS.includes(membershipRoleSlug as OrgMembershipRole);

if (isCustomRole) {
if (!plan?.rbac)
throw new BadRequestError({
message:
"Failed to set custom default role due to plan RBAC restriction. Upgrade plan to set custom default org membership role."
});

const customRole = await orgRoleDAL.findOne({ slug: membershipRoleSlug, orgId });
if (!customRole) throw new NotFoundError({ name: "UpdateOrg", message: "Organization role not found" });

// use ID for default role
return customRole.id;
}

// not custom, use reserved slug
return membershipRoleSlug;
};

// this is only for creating an org membership
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)
return {
roleId: defaultOrgMembershipRole,
role: OrgMembershipRole.Custom
};

// will be reserved slug
return { roleId: undefined, role: defaultOrgMembershipRole as OrgMembershipRole };
};
17 changes: 16 additions & 1 deletion backend/src/services/org/org-role-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,20 @@ 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 { TOrgDALFactory } from "@app/services/org/org-dal";

import { ActorAuthMethod } from "../auth/auth-type";
import { TOrgRoleDALFactory } from "./org-role-dal";

type TOrgRoleServiceFactoryDep = {
orgRoleDAL: TOrgRoleDALFactory;
permissionService: TPermissionServiceFactory;
orgDAL: TOrgDALFactory;
};

export type TOrgRoleServiceFactory = ReturnType<typeof orgRoleServiceFactory>;

export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRoleServiceFactoryDep) => {
export const orgRoleServiceFactory = ({ orgRoleDAL, orgDAL, permissionService }: TOrgRoleServiceFactoryDep) => {
const createRole = async (
userId: string,
orgId: string,
Expand Down Expand Up @@ -129,6 +131,19 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol
) => {
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Role);

const org = await orgDAL.findOrgById(orgId);

if (!org)
throw new NotFoundError({
message: "Failed to find organization"
});

if (org.defaultMembershipRole === roleId)
throw new BadRequestError({
message: "Cannot delete default org membership role. Please re-assign and try again."
});

const [deletedRole] = await orgRoleDAL.delete({ id: roleId, orgId });
if (!deletedRole) throw new NotFoundError({ message: "Organization role not found", name: "Update role" });

Expand Down
Loading

0 comments on commit 75d71d4

Please sign in to comment.