mirror of
https://github.com/Infisical/infisical.git
synced 2025-03-28 15:29:21 +00:00
feat(server): multi role with temporary support for identity
This commit is contained in:
8
backend/src/@types/knex.d.ts
vendored
8
backend/src/@types/knex.d.ts
vendored
@ -32,6 +32,9 @@ import {
|
||||
TIdentityOrgMemberships,
|
||||
TIdentityOrgMembershipsInsert,
|
||||
TIdentityOrgMembershipsUpdate,
|
||||
TIdentityProjectMembershipRole,
|
||||
TIdentityProjectMembershipRoleInsert,
|
||||
TIdentityProjectMembershipRoleUpdate,
|
||||
TIdentityProjectMemberships,
|
||||
TIdentityProjectMembershipsInsert,
|
||||
TIdentityProjectMembershipsUpdate,
|
||||
@ -280,6 +283,11 @@ declare module "knex/types/tables" {
|
||||
TIdentityProjectMembershipsInsert,
|
||||
TIdentityProjectMembershipsUpdate
|
||||
>;
|
||||
[TableName.IdentityProjectMembershipRole]: Knex.CompositeTableType<
|
||||
TIdentityProjectMembershipRole,
|
||||
TIdentityProjectMembershipRoleInsert,
|
||||
TIdentityProjectMembershipRoleUpdate
|
||||
>;
|
||||
[TableName.ScimToken]: Knex.CompositeTableType<TScimTokens, TScimTokensInsert, TScimTokensUpdate>;
|
||||
[TableName.SecretApprovalPolicy]: Knex.CompositeTableType<
|
||||
TSecretApprovalPolicies,
|
||||
|
@ -21,37 +21,37 @@ export async function up(knex: Knex): Promise<void> {
|
||||
t.datetime("temporaryAccessEndTime");
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.ProjectUserMembershipRole);
|
||||
|
||||
const projectMembershipStream = knex.select("*").from(TableName.ProjectMembership).stream();
|
||||
const chunkSize = 1000;
|
||||
let rows: TProjectUserMembershipRolesInsert[] = [];
|
||||
for await (const row of projectMembershipStream) {
|
||||
// disabling eslint just this part because the latest ts type doesn't have these values after migration as they are removed
|
||||
/* eslint-disable */
|
||||
// @ts-ignore - created at is inserted from old data
|
||||
rows = rows.concat({
|
||||
// @ts-ignore - missing in ts type post migration
|
||||
role: row.role,
|
||||
// @ts-ignore - missing in ts type post migration
|
||||
customRoleId: row.roleId,
|
||||
projectMembershipId: row.id,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt
|
||||
});
|
||||
/* eslint-disable */
|
||||
if (rows.length >= chunkSize) {
|
||||
await knex(TableName.ProjectUserMembershipRole).insert(rows);
|
||||
rows.splice(0, rows.length);
|
||||
}
|
||||
}
|
||||
await knex(TableName.ProjectUserMembershipRole).insert(rows);
|
||||
await knex.schema.alterTable(TableName.ProjectMembership, (t) => {
|
||||
t.dropColumn("roleId");
|
||||
t.dropColumn("role");
|
||||
});
|
||||
}
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.ProjectUserMembershipRole);
|
||||
|
||||
const projectMembershipStream = knex.select("*").from(TableName.ProjectMembership).stream();
|
||||
const chunkSize = 1000;
|
||||
let rows: TProjectUserMembershipRolesInsert[] = [];
|
||||
for await (const row of projectMembershipStream) {
|
||||
// disabling eslint just this part because the latest ts type doesn't have these values after migration as they are removed
|
||||
/* eslint-disable */
|
||||
// @ts-ignore - created at is inserted from old data
|
||||
rows = rows.concat({
|
||||
// @ts-ignore - missing in ts type post migration
|
||||
role: row.role,
|
||||
// @ts-ignore - missing in ts type post migration
|
||||
customRoleId: row.roleId,
|
||||
projectMembershipId: row.id,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt
|
||||
});
|
||||
/* eslint-disable */
|
||||
if (rows.length >= chunkSize) {
|
||||
await knex(TableName.ProjectUserMembershipRole).insert(rows);
|
||||
rows.splice(0, rows.length);
|
||||
}
|
||||
}
|
||||
if (rows.length) await knex(TableName.ProjectUserMembershipRole).insert(rows);
|
||||
await knex.schema.alterTable(TableName.ProjectMembership, (t) => {
|
||||
t.dropColumn("roleId");
|
||||
t.dropColumn("role");
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
|
@ -0,0 +1,80 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName, TIdentityProjectMembershipRoleInsert } from "../schemas";
|
||||
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const doesTableExist = await knex.schema.hasTable(TableName.IdentityProjectMembershipRole);
|
||||
if (!doesTableExist) {
|
||||
await knex.schema.createTable(TableName.IdentityProjectMembershipRole, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.string("role").notNullable();
|
||||
t.uuid("projectMembershipId").notNullable();
|
||||
t.foreign("projectMembershipId")
|
||||
.references("id")
|
||||
.inTable(TableName.IdentityProjectMembership)
|
||||
.onDelete("CASCADE");
|
||||
// until role is changed/removed the role should not deleted
|
||||
t.uuid("customRoleId");
|
||||
t.foreign("customRoleId").references("id").inTable(TableName.ProjectRoles);
|
||||
t.boolean("isTemporary").notNullable().defaultTo(false);
|
||||
t.string("temporaryMode");
|
||||
t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc
|
||||
t.datetime("temporaryAccessStartTime");
|
||||
t.datetime("temporaryAccessEndTime");
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
}
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.IdentityProjectMembershipRole);
|
||||
|
||||
const projectMembershipStream = knex.select("*").from(TableName.IdentityProjectMembership).stream();
|
||||
const chunkSize = 1000;
|
||||
let rows: TIdentityProjectMembershipRoleInsert[] = [];
|
||||
for await (const row of projectMembershipStream) {
|
||||
// disabling eslint just this part because the latest ts type doesn't have these values after migration as they are removed
|
||||
/* eslint-disable */
|
||||
// @ts-ignore - created at is inserted from old data
|
||||
rows = rows.concat({
|
||||
// @ts-ignore - missing in ts type post migration
|
||||
role: row.role,
|
||||
// @ts-ignore - missing in ts type post migration
|
||||
customRoleId: row.roleId,
|
||||
projectMembershipId: row.id,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt
|
||||
});
|
||||
/* eslint-disable */
|
||||
if (rows.length >= chunkSize) {
|
||||
await knex(TableName.IdentityProjectMembershipRole).insert(rows);
|
||||
rows.splice(0, rows.length);
|
||||
}
|
||||
}
|
||||
if(rows.length) await knex(TableName.IdentityProjectMembershipRole).insert(rows);
|
||||
await knex.schema.alterTable(TableName.IdentityProjectMembership, (t) => {
|
||||
t.dropColumn("roleId");
|
||||
t.dropColumn("role");
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const projectIdentityMembershipRoleStream = knex.select("*").from(TableName.IdentityProjectMembershipRole).stream();
|
||||
await knex.schema.alterTable(TableName.IdentityProjectMembership, (t) => {
|
||||
t.string("role");
|
||||
t.uuid("roleId");
|
||||
t.foreign("roleId").references("id").inTable(TableName.ProjectRoles);
|
||||
});
|
||||
for await (const row of projectIdentityMembershipRoleStream) {
|
||||
await knex(TableName.IdentityProjectMembership).where({ id: row.projectMembershipId }).update({
|
||||
// @ts-ignore - since the latest one doesn't have roleId anymore there will be type error here
|
||||
roleId: row.customRoleId,
|
||||
role: row.role
|
||||
});
|
||||
}
|
||||
await knex.schema.alterTable(TableName.IdentityProjectMembership, (t) => {
|
||||
t.string("role").notNullable().alter({ alterNullable: true });
|
||||
});
|
||||
|
||||
await knex.schema.dropTableIfExists(TableName.IdentityProjectMembershipRole);
|
||||
await dropOnUpdateTrigger(knex, TableName.IdentityProjectMembershipRole);
|
||||
}
|
31
backend/src/db/schemas/identity-project-membership-role.ts
Normal file
31
backend/src/db/schemas/identity-project-membership-role.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const IdentityProjectMembershipRoleSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
role: z.string(),
|
||||
projectMembershipId: z.string().uuid(),
|
||||
customRoleId: z.string().uuid().nullable().optional(),
|
||||
isTemporary: z.boolean().default(false),
|
||||
temporaryMode: z.string().nullable().optional(),
|
||||
temporaryRange: z.string().nullable().optional(),
|
||||
temporaryAccessStartTime: z.date().nullable().optional(),
|
||||
temporaryAccessEndTime: z.date().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TIdentityProjectMembershipRole = z.infer<typeof IdentityProjectMembershipRoleSchema>;
|
||||
export type TIdentityProjectMembershipRoleInsert = Omit<
|
||||
z.input<typeof IdentityProjectMembershipRoleSchema>,
|
||||
TImmutableDBKeys
|
||||
>;
|
||||
export type TIdentityProjectMembershipRoleUpdate = Partial<
|
||||
Omit<z.input<typeof IdentityProjectMembershipRoleSchema>, TImmutableDBKeys>
|
||||
>;
|
@ -9,8 +9,6 @@ import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const IdentityProjectMembershipsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
role: z.string(),
|
||||
roleId: z.string().uuid().nullable().optional(),
|
||||
projectId: z.string(),
|
||||
identityId: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
|
@ -8,6 +8,7 @@ export * from "./git-app-org";
|
||||
export * from "./identities";
|
||||
export * from "./identity-access-tokens";
|
||||
export * from "./identity-org-memberships";
|
||||
export * from "./identity-project-membership-role";
|
||||
export * from "./identity-project-memberships";
|
||||
export * from "./identity-ua-client-secrets";
|
||||
export * from "./identity-universal-auths";
|
||||
|
@ -42,6 +42,7 @@ export enum TableName {
|
||||
IdentityUaClientSecret = "identity_ua_client_secrets",
|
||||
IdentityOrgMembership = "identity_org_memberships",
|
||||
IdentityProjectMembership = "identity_project_memberships",
|
||||
IdentityProjectMembershipRole = "identity_project_membership_role",
|
||||
ScimToken = "scim_tokens",
|
||||
SecretApprovalPolicy = "secret_approval_policies",
|
||||
SecretApprovalPolicyApprover = "secret_approval_policies_approvers",
|
||||
|
@ -75,9 +75,15 @@ export async function seed(knex: Knex): Promise<void> {
|
||||
}
|
||||
]);
|
||||
|
||||
await knex(TableName.IdentityProjectMembership).insert({
|
||||
identityId: seedData1.machineIdentity.id,
|
||||
const identityProjectMembership = await knex(TableName.IdentityProjectMembership)
|
||||
.insert({
|
||||
identityId: seedData1.machineIdentity.id,
|
||||
projectId: seedData1.project.id
|
||||
})
|
||||
.returning("*");
|
||||
|
||||
await knex(TableName.IdentityProjectMembershipRole).insert({
|
||||
role: ProjectMembershipRole.Admin,
|
||||
projectId: seedData1.project.id
|
||||
projectMembershipId: identityProjectMembership[0].id
|
||||
});
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
||||
secretVersioning: true,
|
||||
pitRecovery: false,
|
||||
ipAllowlisting: false,
|
||||
rbac: true,
|
||||
rbac: false,
|
||||
customRateLimits: false,
|
||||
customAlerts: false,
|
||||
auditLogs: false,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { ProjectUserMembershipRolesSchema, TableName } from "@app/db/schemas";
|
||||
import { IdentityProjectMembershipRoleSchema, ProjectUserMembershipRolesSchema, TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
|
||||
|
||||
@ -108,18 +108,60 @@ export const permissionDALFactory = (db: TDbClient) => {
|
||||
|
||||
const getProjectIdentityPermission = async (identityId: string, projectId: string) => {
|
||||
try {
|
||||
const membership = await db(TableName.IdentityProjectMembership)
|
||||
const docs = await db(TableName.IdentityProjectMembership)
|
||||
.join(
|
||||
TableName.IdentityProjectMembershipRole,
|
||||
`${TableName.IdentityProjectMembershipRole}.projectMembershipId`,
|
||||
`${TableName.IdentityProjectMembership}.id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.ProjectRoles,
|
||||
`${TableName.IdentityProjectMembership}.roleId`,
|
||||
`${TableName.IdentityProjectMembershipRole}.customRoleId`,
|
||||
`${TableName.ProjectRoles}.id`
|
||||
)
|
||||
.where("identityId", identityId)
|
||||
.where(`${TableName.IdentityProjectMembership}.projectId`, projectId)
|
||||
.select(selectAllTableCols(TableName.IdentityProjectMembership))
|
||||
.select("permissions")
|
||||
.first();
|
||||
return membership;
|
||||
.select(selectAllTableCols(TableName.IdentityProjectMembershipRole))
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.IdentityProjectMembership).as("membershipId"),
|
||||
db.ref("createdAt").withSchema(TableName.IdentityProjectMembership).as("membershipCreatedAt"),
|
||||
db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership).as("membershipUpdatedAt"),
|
||||
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug")
|
||||
)
|
||||
.select("permissions");
|
||||
|
||||
const permission = sqlNestRelationships({
|
||||
data: docs,
|
||||
key: "membershipId",
|
||||
parentMapper: ({ membershipId, membershipCreatedAt, membershipUpdatedAt }) => ({
|
||||
id: membershipId,
|
||||
identityId,
|
||||
projectId,
|
||||
createdAt: membershipCreatedAt,
|
||||
updatedAt: membershipUpdatedAt,
|
||||
// just a prefilled value
|
||||
orgAuthEnforced: false,
|
||||
orgId: ""
|
||||
}),
|
||||
childrenMapper: [
|
||||
{
|
||||
key: "id",
|
||||
label: "roles" as const,
|
||||
mapper: (data) =>
|
||||
IdentityProjectMembershipRoleSchema.extend({
|
||||
permissions: z.unknown(),
|
||||
customRoleSlug: z.string().optional().nullable()
|
||||
}).parse(data)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// when introducting cron mode change it here
|
||||
const activeRoles = permission?.[0]?.roles.filter(
|
||||
({ isTemporary, temporaryAccessEndTime }) =>
|
||||
!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime)
|
||||
);
|
||||
return permission?.[0] ? { ...permission[0], roles: activeRoles } : undefined;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "GetProjectIdentityPermission" });
|
||||
}
|
||||
|
@ -67,7 +67,7 @@ export const permissionServiceFactory = ({
|
||||
|
||||
const buildProjectPermission = (projectUserRoles: TBuildProjectPermissionDTO) => {
|
||||
const rules = projectUserRoles
|
||||
.map(({ role, permission }) => {
|
||||
.map(({ role, permissions }) => {
|
||||
switch (role) {
|
||||
case ProjectMembershipRole.Admin:
|
||||
return projectAdminPermissions;
|
||||
@ -77,10 +77,11 @@ export const permissionServiceFactory = ({
|
||||
return projectViewerPermission;
|
||||
case ProjectMembershipRole.NoAccess:
|
||||
return projectNoAccessPermissions;
|
||||
case ProjectMembershipRole.Custom:
|
||||
case ProjectMembershipRole.Custom: {
|
||||
return unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>(
|
||||
permission as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[]
|
||||
permissions as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[]
|
||||
);
|
||||
}
|
||||
default:
|
||||
throw new BadRequestError({
|
||||
name: "ProjectRoleInvalid",
|
||||
@ -149,7 +150,11 @@ export const permissionServiceFactory = ({
|
||||
};
|
||||
|
||||
// user permission for a project in an organization
|
||||
const getUserProjectPermission = async (userId: string, projectId: string, userOrgId?: string) => {
|
||||
const getUserProjectPermission = async (
|
||||
userId: string,
|
||||
projectId: string,
|
||||
userOrgId?: string
|
||||
): Promise<TProjectPermissionRT<ActorType.USER>> => {
|
||||
const userProjectPermission = await permissionDAL.getProjectPermission(userId, projectId);
|
||||
if (!userProjectPermission) throw new UnauthorizedError({ name: "User not in project" });
|
||||
|
||||
@ -173,16 +178,28 @@ export const permissionServiceFactory = ({
|
||||
};
|
||||
};
|
||||
|
||||
const getIdentityProjectPermission = async (identityId: string, projectId: string) => {
|
||||
const membership = await permissionDAL.getProjectIdentityPermission(identityId, projectId);
|
||||
if (!membership) throw new UnauthorizedError({ name: "Identity not in project" });
|
||||
if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) {
|
||||
const getIdentityProjectPermission = async (
|
||||
identityId: string,
|
||||
projectId: string
|
||||
): Promise<TProjectPermissionRT<ActorType.IDENTITY>> => {
|
||||
const identityProjectPermission = await permissionDAL.getProjectIdentityPermission(identityId, projectId);
|
||||
if (!identityProjectPermission) throw new UnauthorizedError({ name: "Identity not in project" });
|
||||
|
||||
if (
|
||||
identityProjectPermission.roles.some(
|
||||
({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions
|
||||
)
|
||||
) {
|
||||
throw new BadRequestError({ name: "Custom permission not found" });
|
||||
}
|
||||
|
||||
return {
|
||||
permission: buildProjectPermission([{ role: membership.role, permission: membership.permissions }]),
|
||||
membership
|
||||
permission: buildProjectPermission(identityProjectPermission.roles),
|
||||
membership: identityProjectPermission,
|
||||
hasRole: (role: string) =>
|
||||
identityProjectPermission.roles.findIndex(
|
||||
({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug
|
||||
) !== -1
|
||||
};
|
||||
};
|
||||
|
||||
@ -202,7 +219,11 @@ export const permissionServiceFactory = ({
|
||||
};
|
||||
|
||||
type TProjectPermissionRT<T extends ActorType> = T extends ActorType.SERVICE
|
||||
? { permission: MongoAbility<ProjectPermissionSet, MongoQuery>; membership: undefined; hasRole: () => false } // service token doesn't have both membership and roles
|
||||
? {
|
||||
permission: MongoAbility<ProjectPermissionSet, MongoQuery>;
|
||||
membership: undefined;
|
||||
hasRole: (arg: string) => boolean;
|
||||
} // service token doesn't have both membership and roles
|
||||
: {
|
||||
permission: MongoAbility<ProjectPermissionSet, MongoQuery>;
|
||||
membership: (T extends ActorType.USER ? TProjectMemberships : TIdentityProjectMemberships) & {
|
||||
@ -241,12 +262,12 @@ export const permissionServiceFactory = ({
|
||||
if (!projectRole) throw new BadRequestError({ message: "Role not found" });
|
||||
return {
|
||||
permission: buildProjectPermission([
|
||||
{ role: ProjectMembershipRole.Custom, permission: projectRole.permissions }
|
||||
{ role: ProjectMembershipRole.Custom, permissions: projectRole.permissions }
|
||||
]),
|
||||
role: projectRole
|
||||
};
|
||||
}
|
||||
return { permission: buildProjectPermission([{ role, permission: [] }]) };
|
||||
return { permission: buildProjectPermission([{ role, permissions: [] }]) };
|
||||
};
|
||||
|
||||
return {
|
||||
|
@ -1,4 +1,4 @@
|
||||
export type TBuildProjectPermissionDTO = {
|
||||
permission?: unknown;
|
||||
permissions?: unknown;
|
||||
role: string;
|
||||
}[];
|
||||
|
@ -53,6 +53,7 @@ import { identityServiceFactory } from "@app/services/identity/identity-service"
|
||||
import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal";
|
||||
import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service";
|
||||
import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
|
||||
import { identityProjectMembershipRoleDALFactory } from "@app/services/identity-project/identity-project-membership-role-dal";
|
||||
import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service";
|
||||
import { identityUaClientSecretDALFactory } from "@app/services/identity-ua/identity-ua-client-secret-dal";
|
||||
import { identityUaDALFactory } from "@app/services/identity-ua/identity-ua-dal";
|
||||
@ -166,6 +167,7 @@ export const registerRoutes = async (
|
||||
const identityAccessTokenDAL = identityAccessTokenDALFactory(db);
|
||||
const identityOrgMembershipDAL = identityOrgDALFactory(db);
|
||||
const identityProjectDAL = identityProjectDALFactory(db);
|
||||
const identityProjectMembershipRoleDAL = identityProjectMembershipRoleDALFactory(db);
|
||||
|
||||
const identityUaDAL = identityUaDALFactory(db);
|
||||
const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db);
|
||||
@ -374,8 +376,10 @@ export const registerRoutes = async (
|
||||
projectMembershipDAL,
|
||||
folderDAL,
|
||||
licenseService,
|
||||
projectUserMembershipRoleDAL
|
||||
projectUserMembershipRoleDAL,
|
||||
identityProjectMembershipRoleDAL
|
||||
});
|
||||
|
||||
const projectEnvService = projectEnvServiceFactory({
|
||||
permissionService,
|
||||
projectEnvDAL,
|
||||
@ -526,7 +530,9 @@ export const registerRoutes = async (
|
||||
permissionService,
|
||||
projectDAL,
|
||||
identityProjectDAL,
|
||||
identityOrgMembershipDAL
|
||||
identityOrgMembershipDAL,
|
||||
identityProjectMembershipRoleDAL,
|
||||
projectRoleDAL
|
||||
});
|
||||
const identityUaService = identityUaServiceFactory({
|
||||
identityOrgMembershipDAL,
|
||||
|
@ -136,21 +136,24 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
|
||||
membershipId: z.string().trim()
|
||||
}),
|
||||
body: z.object({
|
||||
roles: z.array(
|
||||
z.union([
|
||||
z.object({
|
||||
role: z.string(),
|
||||
isTemporary: z.literal(false).default(false)
|
||||
}),
|
||||
z.object({
|
||||
role: z.string(),
|
||||
isTemporary: z.literal(true),
|
||||
temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode),
|
||||
temporaryRange: z.string(),
|
||||
temporaryAccessStartTime: z.string().datetime()
|
||||
})
|
||||
])
|
||||
)
|
||||
roles: z
|
||||
.array(
|
||||
z.union([
|
||||
z.object({
|
||||
role: z.string(),
|
||||
isTemporary: z.literal(false).default(false)
|
||||
}),
|
||||
z.object({
|
||||
role: z.string(),
|
||||
isTemporary: z.literal(true),
|
||||
temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode),
|
||||
temporaryRange: z.string(),
|
||||
temporaryAccessStartTime: z.string().datetime()
|
||||
})
|
||||
])
|
||||
)
|
||||
.min(1)
|
||||
.refine((data) => data.some(({ isTemporary }) => !isTemporary), "Atleast one permanent role required")
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
@ -4,10 +4,11 @@ import {
|
||||
IdentitiesSchema,
|
||||
IdentityProjectMembershipsSchema,
|
||||
ProjectMembershipRole,
|
||||
ProjectRolesSchema
|
||||
ProjectUserMembershipRolesSchema
|
||||
} from "@app/db/schemas";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types";
|
||||
|
||||
export const registerIdentityProjectRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
@ -57,24 +58,41 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider)
|
||||
identityId: z.string().trim()
|
||||
}),
|
||||
body: z.object({
|
||||
role: z.string().trim().min(1).default(ProjectMembershipRole.NoAccess)
|
||||
roles: z
|
||||
.array(
|
||||
z.union([
|
||||
z.object({
|
||||
role: z.string(),
|
||||
isTemporary: z.literal(false).default(false)
|
||||
}),
|
||||
z.object({
|
||||
role: z.string(),
|
||||
isTemporary: z.literal(true),
|
||||
temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode),
|
||||
temporaryRange: z.string(),
|
||||
temporaryAccessStartTime: z.string().datetime()
|
||||
})
|
||||
])
|
||||
)
|
||||
.min(1)
|
||||
.refine((data) => data.some(({ isTemporary }) => !isTemporary), "Atleast one permanent role required")
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
identityMembership: IdentityProjectMembershipsSchema
|
||||
roles: ProjectUserMembershipRolesSchema.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const identityMembership = await server.services.identityProject.updateProjectIdentity({
|
||||
const roles = await server.services.identityProject.updateProjectIdentity({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorOrgId: req.permission.orgId,
|
||||
identityId: req.params.identityId,
|
||||
projectId: req.params.projectId,
|
||||
role: req.body.role
|
||||
roles: req.body.roles
|
||||
});
|
||||
return { identityMembership };
|
||||
return { roles };
|
||||
}
|
||||
});
|
||||
|
||||
@ -127,18 +145,29 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
identityMemberships: IdentityProjectMembershipsSchema.merge(
|
||||
z.object({
|
||||
customRole: ProjectRolesSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
permissions: true,
|
||||
description: true
|
||||
}).optional(),
|
||||
identityMemberships: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
identityId: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
roles: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
role: z.string(),
|
||||
customRoleId: z.string().optional().nullable(),
|
||||
customRoleName: z.string().optional().nullable(),
|
||||
customRoleSlug: z.string().optional().nullable(),
|
||||
isTemporary: z.boolean(),
|
||||
temporaryMode: z.string().optional().nullable(),
|
||||
temporaryRange: z.string().nullable().optional(),
|
||||
temporaryAccessStartTime: z.date().nullable().optional(),
|
||||
temporaryAccessEndTime: z.date().nullable().optional()
|
||||
})
|
||||
),
|
||||
identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true })
|
||||
})
|
||||
).array()
|
||||
.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
|
@ -3,7 +3,7 @@ import { Knex } from "knex";
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
import { ormify, sqlNestRelationships } from "@app/lib/knex";
|
||||
|
||||
export type TIdentityProjectDALFactory = ReturnType<typeof identityProjectDALFactory>;
|
||||
|
||||
@ -15,52 +15,81 @@ export const identityProjectDALFactory = (db: TDbClient) => {
|
||||
const docs = await (tx || db)(TableName.IdentityProjectMembership)
|
||||
.where(`${TableName.IdentityProjectMembership}.projectId`, projectId)
|
||||
.join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`)
|
||||
.join(
|
||||
TableName.IdentityProjectMembershipRole,
|
||||
`${TableName.IdentityProjectMembershipRole}.projectMembershipId`,
|
||||
`${TableName.IdentityProjectMembership}.id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.ProjectRoles,
|
||||
`${TableName.IdentityProjectMembership}.roleId`,
|
||||
`${TableName.IdentityProjectMembershipRole}.customRoleId`,
|
||||
`${TableName.ProjectRoles}.id`
|
||||
)
|
||||
.select(selectAllTableCols(TableName.IdentityProjectMembership))
|
||||
// cr stands for custom role
|
||||
.select(db.ref("id").as("crId").withSchema(TableName.ProjectRoles))
|
||||
.select(db.ref("name").as("crName").withSchema(TableName.ProjectRoles))
|
||||
.select(db.ref("slug").as("crSlug").withSchema(TableName.ProjectRoles))
|
||||
.select(db.ref("description").as("crDescription").withSchema(TableName.ProjectRoles))
|
||||
.select(db.ref("permissions").as("crPermission").withSchema(TableName.ProjectRoles))
|
||||
.select(db.ref("permissions").as("crPermission").withSchema(TableName.ProjectRoles))
|
||||
.select(db.ref("id").as("identityId").withSchema(TableName.Identity))
|
||||
.select(db.ref("name").as("identityName").withSchema(TableName.Identity))
|
||||
.select(db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity));
|
||||
return docs.map(
|
||||
({
|
||||
crId,
|
||||
crDescription,
|
||||
crSlug,
|
||||
crPermission,
|
||||
crName,
|
||||
identityId,
|
||||
identityName,
|
||||
identityAuthMethod,
|
||||
...el
|
||||
}) => ({
|
||||
...el,
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.IdentityProjectMembership),
|
||||
db.ref("createdAt").withSchema(TableName.IdentityProjectMembership),
|
||||
db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership),
|
||||
db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity),
|
||||
db.ref("id").as("identityId").withSchema(TableName.Identity),
|
||||
db.ref("name").as("identityName").withSchema(TableName.Identity),
|
||||
db.ref("id").withSchema(TableName.IdentityProjectMembership),
|
||||
db.ref("role").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("id").withSchema(TableName.IdentityProjectMembershipRole).as("membershipRoleId"),
|
||||
db.ref("customRoleId").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"),
|
||||
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"),
|
||||
db.ref("temporaryMode").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("isTemporary").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("temporaryRange").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("temporaryAccessStartTime").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("temporaryAccessEndTime").withSchema(TableName.IdentityProjectMembershipRole)
|
||||
);
|
||||
|
||||
const members = sqlNestRelationships({
|
||||
data: docs,
|
||||
parentMapper: ({ identityId, identityName, identityAuthMethod, id, createdAt, updatedAt }) => ({
|
||||
id,
|
||||
identityId,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
identity: {
|
||||
id: identityId,
|
||||
name: identityName,
|
||||
authMethod: identityAuthMethod
|
||||
},
|
||||
customRole: el.roleId
|
||||
? {
|
||||
id: crId,
|
||||
name: crName,
|
||||
slug: crSlug,
|
||||
permissions: crPermission,
|
||||
description: crDescription
|
||||
}
|
||||
: undefined
|
||||
})
|
||||
);
|
||||
}
|
||||
}),
|
||||
key: "id",
|
||||
childrenMapper: [
|
||||
{
|
||||
label: "roles" as const,
|
||||
key: "membershipRoleId",
|
||||
mapper: ({
|
||||
role,
|
||||
customRoleId,
|
||||
customRoleName,
|
||||
customRoleSlug,
|
||||
membershipRoleId,
|
||||
temporaryRange,
|
||||
temporaryMode,
|
||||
temporaryAccessEndTime,
|
||||
temporaryAccessStartTime,
|
||||
isTemporary
|
||||
}) => ({
|
||||
id: membershipRoleId,
|
||||
role,
|
||||
customRoleId,
|
||||
customRoleName,
|
||||
customRoleSlug,
|
||||
temporaryRange,
|
||||
temporaryMode,
|
||||
temporaryAccessEndTime,
|
||||
temporaryAccessStartTime,
|
||||
isTemporary
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
return members;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindByProjectId" });
|
||||
}
|
||||
|
@ -0,0 +1,10 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TIdentityProjectMembershipRoleDALFactory = ReturnType<typeof identityProjectMembershipRoleDALFactory>;
|
||||
|
||||
export const identityProjectMembershipRoleDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.IdentityProjectMembershipRole);
|
||||
return orm;
|
||||
};
|
@ -1,15 +1,20 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import ms from "ms";
|
||||
|
||||
import { ProjectMembershipRole, TProjectRoles } from "@app/db/schemas";
|
||||
import { ProjectMembershipRole } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
|
||||
import { groupBy } from "@app/lib/fn";
|
||||
|
||||
import { ActorType } from "../auth/auth-type";
|
||||
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types";
|
||||
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
|
||||
import { TIdentityProjectDALFactory } from "./identity-project-dal";
|
||||
import { TIdentityProjectMembershipRoleDALFactory } from "./identity-project-membership-role-dal";
|
||||
import {
|
||||
TCreateProjectIdentityDTO,
|
||||
TDeleteProjectIdentityDTO,
|
||||
@ -19,7 +24,12 @@ import {
|
||||
|
||||
type TIdentityProjectServiceFactoryDep = {
|
||||
identityProjectDAL: TIdentityProjectDALFactory;
|
||||
identityProjectMembershipRoleDAL: Pick<
|
||||
TIdentityProjectMembershipRoleDALFactory,
|
||||
"create" | "transaction" | "insertMany" | "delete"
|
||||
>;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById">;
|
||||
projectRoleDAL: Pick<TProjectRoleDALFactory, "find">;
|
||||
identityOrgMembershipDAL: Pick<TIdentityOrgDALFactory, "findOne">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getProjectPermissionByRole">;
|
||||
};
|
||||
@ -30,7 +40,9 @@ export const identityProjectServiceFactory = ({
|
||||
identityProjectDAL,
|
||||
permissionService,
|
||||
identityOrgMembershipDAL,
|
||||
projectDAL
|
||||
identityProjectMembershipRoleDAL,
|
||||
projectDAL,
|
||||
projectRoleDAL
|
||||
}: TIdentityProjectServiceFactoryDep) => {
|
||||
const createProjectIdentity = async ({
|
||||
identityId,
|
||||
@ -70,11 +82,24 @@ export const identityProjectServiceFactory = ({
|
||||
});
|
||||
const isCustomRole = Boolean(customRole);
|
||||
|
||||
const projectIdentity = await identityProjectDAL.create({
|
||||
identityId,
|
||||
projectId: project.id,
|
||||
role: isCustomRole ? ProjectMembershipRole.Custom : role,
|
||||
roleId: customRole?.id
|
||||
const projectIdentity = await identityProjectDAL.transaction(async (tx) => {
|
||||
const identityProjectMembership = await identityProjectDAL.create(
|
||||
{
|
||||
identityId,
|
||||
projectId: project.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await identityProjectMembershipRoleDAL.create(
|
||||
{
|
||||
projectMembershipId: identityProjectMembership.id,
|
||||
role: isCustomRole ? ProjectMembershipRole.Custom : role,
|
||||
customRoleId: customRole?.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
return identityProjectMembership;
|
||||
});
|
||||
return projectIdentity;
|
||||
};
|
||||
@ -82,7 +107,7 @@ export const identityProjectServiceFactory = ({
|
||||
const updateProjectIdentity = async ({
|
||||
projectId,
|
||||
identityId,
|
||||
role,
|
||||
roles,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId
|
||||
@ -106,28 +131,54 @@ export const identityProjectServiceFactory = ({
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" });
|
||||
|
||||
let customRole: TProjectRoles | undefined;
|
||||
if (role) {
|
||||
const { permission: rolePermission, role: customOrgRole } = await permissionService.getProjectPermissionByRole(
|
||||
role,
|
||||
projectIdentity.projectId
|
||||
);
|
||||
|
||||
const isCustomRole = Boolean(customOrgRole);
|
||||
const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasRequiredNewRolePermission)
|
||||
throw new BadRequestError({ message: "Failed to create a more privileged identity" });
|
||||
if (isCustomRole) customRole = customOrgRole;
|
||||
}
|
||||
|
||||
const [updatedProjectIdentity] = await identityProjectDAL.update(
|
||||
{ projectId, identityId: projectIdentity.identityId },
|
||||
{
|
||||
role: customRole ? ProjectMembershipRole.Custom : role,
|
||||
roleId: customRole ? customRole.id : null
|
||||
}
|
||||
// validate custom roles input
|
||||
const customInputRoles = roles.filter(
|
||||
({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole)
|
||||
);
|
||||
return updatedProjectIdentity;
|
||||
const hasCustomRole = Boolean(customInputRoles.length);
|
||||
const customRoles = hasCustomRole
|
||||
? await projectRoleDAL.find({
|
||||
projectId,
|
||||
$in: { slug: customInputRoles.map(({ role }) => role) }
|
||||
})
|
||||
: [];
|
||||
if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" });
|
||||
|
||||
const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug);
|
||||
|
||||
const santiziedProjectMembershipRoles = roles.map((inputRole) => {
|
||||
const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]);
|
||||
if (!inputRole.isTemporary) {
|
||||
return {
|
||||
projectMembershipId: projectIdentity.id,
|
||||
role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role,
|
||||
customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null
|
||||
};
|
||||
}
|
||||
|
||||
// check cron or relative here later for now its just relative
|
||||
const relativeTimeInMs = ms(inputRole.temporaryRange);
|
||||
if (relativeTimeInMs <= 0) {
|
||||
throw new BadRequestError({ message: "Temporary relative time range must be positive" });
|
||||
}
|
||||
return {
|
||||
projectMembershipId: projectIdentity.id,
|
||||
role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role,
|
||||
customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null,
|
||||
isTemporary: true,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: inputRole.temporaryRange,
|
||||
temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime),
|
||||
temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs)
|
||||
};
|
||||
});
|
||||
|
||||
const updatedRoles = await identityProjectMembershipRoleDAL.transaction(async (tx) => {
|
||||
await identityProjectMembershipRoleDAL.delete({ projectMembershipId: projectIdentity.id }, tx);
|
||||
return identityProjectMembershipRoleDAL.insertMany(santiziedProjectMembershipRoles, tx);
|
||||
});
|
||||
|
||||
return updatedRoles;
|
||||
};
|
||||
|
||||
const deleteProjectIdentity = async ({
|
||||
|
@ -1,12 +1,26 @@
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types";
|
||||
|
||||
export type TCreateProjectIdentityDTO = {
|
||||
identityId: string;
|
||||
role: string;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TUpdateProjectIdentityDTO = {
|
||||
role: string;
|
||||
roles: (
|
||||
| {
|
||||
role: string;
|
||||
isTemporary?: false;
|
||||
}
|
||||
| {
|
||||
role: string;
|
||||
isTemporary: true;
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative;
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
}
|
||||
)[];
|
||||
identityId: string;
|
||||
} & TProjectPermission;
|
||||
|
||||
|
@ -17,6 +17,7 @@ import { TProjectPermission } from "@app/lib/types";
|
||||
import { ActorType } from "../auth/auth-type";
|
||||
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
|
||||
import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal";
|
||||
import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal";
|
||||
import { TOrgServiceFactory } from "../org/org-service";
|
||||
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
@ -51,6 +52,7 @@ type TProjectServiceFactoryDep = {
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "insertMany" | "find">;
|
||||
identityOrgMembershipDAL: TIdentityOrgDALFactory;
|
||||
identityProjectDAL: TIdentityProjectDALFactory;
|
||||
identityProjectMembershipRoleDAL: Pick<TIdentityProjectMembershipRoleDALFactory, "create">;
|
||||
projectKeyDAL: Pick<TProjectKeyDALFactory, "create" | "findLatestProjectKey" | "delete" | "find" | "insertMany">;
|
||||
projectBotDAL: Pick<TProjectBotDALFactory, "create" | "findById" | "delete" | "findOne">;
|
||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create" | "findProjectGhostUser" | "findOne">;
|
||||
@ -78,7 +80,8 @@ export const projectServiceFactory = ({
|
||||
projectMembershipDAL,
|
||||
projectEnvDAL,
|
||||
licenseService,
|
||||
projectUserMembershipRoleDAL
|
||||
projectUserMembershipRoleDAL,
|
||||
identityProjectMembershipRoleDAL
|
||||
}: TProjectServiceFactoryDep) => {
|
||||
/*
|
||||
* Create workspace. Make user the admin
|
||||
@ -275,12 +278,19 @@ export const projectServiceFactory = ({
|
||||
});
|
||||
const isCustomRole = Boolean(customRole);
|
||||
|
||||
await identityProjectDAL.create(
|
||||
const identityProjectMembership = await identityProjectDAL.create(
|
||||
{
|
||||
identityId: actorId,
|
||||
projectId: project.id,
|
||||
projectId: project.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await identityProjectMembershipRoleDAL.create(
|
||||
{
|
||||
projectMembershipId: identityProjectMembership.id,
|
||||
role: isCustomRole ? ProjectMembershipRole.Custom : ProjectMembershipRole.Admin,
|
||||
roleId: customRole?.id
|
||||
customRoleId: customRole?.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
Reference in New Issue
Block a user