From edbb7e2b1e8ab240f62d6facf38632e578c91e4e Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 11 Mar 2024 15:08:42 +0530 Subject: [PATCH] feat(server): completed user multi role with temporary access --- backend/package-lock.json | 21 ++ backend/package.json | 2 + backend/src/@types/knex.d.ts | 8 + .../migrations/20240308154949_temp-roles.ts | 77 +++++++ backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 1 + backend/src/db/schemas/project-memberships.ts | 4 +- .../schemas/project-user-membership-roles.ts | 31 +++ backend/src/db/seeds/3-project.ts | 15 +- .../src/ee/services/license/licence-fns.ts | 2 +- .../ee/services/permission/permission-dal.ts | 61 +++++- .../services/permission/permission-service.ts | 88 ++++---- .../services/permission/permission-types.ts | 4 + .../services/permission/project-permission.ts | 28 +-- .../secret-approval-request-service.ts | 21 +- backend/src/server/routes/index.ts | 9 +- .../routes/v1/project-membership-router.ts | 74 ++++--- .../src/server/routes/v1/project-router.ts | 16 +- .../project-membership-dal.ts | 76 ++++++- .../project-membership-service.ts | 191 +++++++----------- .../project-membership-types.ts | 19 +- .../project-user-membership-role-dal.ts | 10 + .../project-role/project-role-service.ts | 8 +- backend/src/services/project/project-queue.ts | 15 +- .../src/services/project/project-service.ts | 25 ++- .../secret-blind-index-service.ts | 8 +- 26 files changed, 559 insertions(+), 256 deletions(-) create mode 100644 backend/src/db/migrations/20240308154949_temp-roles.ts create mode 100644 backend/src/db/schemas/project-user-membership-roles.ts create mode 100644 backend/src/services/project-membership/project-user-membership-role-dal.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 8a8e9d13dc..28c22b79d5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -45,6 +45,7 @@ "knex": "^3.0.1", "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", + "ms": "^2.1.3", "mysql2": "^3.9.1", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", @@ -54,6 +55,7 @@ "passport-google-oauth20": "^2.0.0", "passport-ldapauth": "^3.0.1", "pg": "^8.11.3", + "pg-query-stream": "^4.5.3", "picomatch": "^3.0.1", "pino": "^8.16.2", "posthog-node": "^3.6.2", @@ -10028,6 +10030,14 @@ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" }, + "node_modules/pg-cursor": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.10.3.tgz", + "integrity": "sha512-rDyBVoqPVnx/PTmnwQAYgusSeAKlTL++gmpf5klVK+mYMFEqsOc6VHHZnPKc/4lOvr4r6fiMuoxSFuBF1dx4FQ==", + "peerDependencies": { + "pg": "^8" + } + }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -10058,6 +10068,17 @@ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.0.tgz", "integrity": "sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==" }, + "node_modules/pg-query-stream": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.5.3.tgz", + "integrity": "sha512-ufa94r/lHJdjAm3+zPZEO0gXAmCb4tZPaOt7O76mjcxdL/HxwTuryy76km+u0odBBgtfdKFYq/9XGfiYeQF0yA==", + "dependencies": { + "pg-cursor": "^2.10.3" + }, + "peerDependencies": { + "pg": "^8" + } + }, "node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 954f1181d4..be0bbcd6d2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -106,6 +106,7 @@ "knex": "^3.0.1", "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", + "ms": "^2.1.3", "mysql2": "^3.9.1", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", @@ -115,6 +116,7 @@ "passport-google-oauth20": "^2.0.0", "passport-ldapauth": "^3.0.1", "pg": "^8.11.3", + "pg-query-stream": "^4.5.3", "picomatch": "^3.0.1", "pino": "^8.16.2", "posthog-node": "^3.6.2", diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 2e8b2611d9..5b1b3770ec 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -83,6 +83,9 @@ import { TProjects, TProjectsInsert, TProjectsUpdate, + TProjectUserMembershipRoles, + TProjectUserMembershipRolesInsert, + TProjectUserMembershipRolesUpdate, TSamlConfigs, TSamlConfigsInsert, TSamlConfigsUpdate, @@ -221,6 +224,11 @@ declare module "knex/types/tables" { TProjectEnvironmentsUpdate >; [TableName.ProjectBot]: Knex.CompositeTableType; + [TableName.ProjectUserMembershipRole]: Knex.CompositeTableType< + TProjectUserMembershipRoles, + TProjectUserMembershipRolesInsert, + TProjectUserMembershipRolesUpdate + >; [TableName.ProjectRoles]: Knex.CompositeTableType; [TableName.ProjectKeys]: Knex.CompositeTableType; [TableName.Secret]: Knex.CompositeTableType; diff --git a/backend/src/db/migrations/20240308154949_temp-roles.ts b/backend/src/db/migrations/20240308154949_temp-roles.ts new file mode 100644 index 0000000000..e37dc8f4b5 --- /dev/null +++ b/backend/src/db/migrations/20240308154949_temp-roles.ts @@ -0,0 +1,77 @@ +import { Knex } from "knex"; + +import { TableName, TProjectUserMembershipRolesInsert } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + const doesTableExist = await knex.schema.hasTable(TableName.ProjectUserMembershipRole); + if (!doesTableExist) { + await knex.schema.createTable(TableName.ProjectUserMembershipRole, (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.ProjectMembership).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.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"); + }); + } +} + +export async function down(knex: Knex): Promise { + const projectUserMembershipRoleStream = knex.select("*").from(TableName.ProjectUserMembershipRole).stream(); + await knex.schema.alterTable(TableName.ProjectMembership, (t) => { + t.string("role"); + t.uuid("roleId"); + t.foreign("roleId").references("id").inTable(TableName.ProjectRoles); + }); + for await (const row of projectUserMembershipRoleStream) { + await knex(TableName.ProjectMembership).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.ProjectMembership, (t) => { + t.string("role").notNullable().alter({ alterNullable: true }); + }); + + await knex.schema.dropTableIfExists(TableName.ProjectUserMembershipRole); + await dropOnUpdateTrigger(knex, TableName.ProjectUserMembershipRole); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index fb717d344c..f39d54ed39 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -25,6 +25,7 @@ export * from "./project-environments"; export * from "./project-keys"; export * from "./project-memberships"; export * from "./project-roles"; +export * from "./project-user-membership-roles"; export * from "./projects"; export * from "./saml-configs"; export * from "./scim-tokens"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index c85aad66ad..7a14695faa 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -20,6 +20,7 @@ export enum TableName { Environment = "project_environments", ProjectMembership = "project_memberships", ProjectRoles = "project_roles", + ProjectUserMembershipRole = "project_user_membership_roles", ProjectKeys = "project_keys", Secret = "secrets", SecretBlindIndex = "secret_blind_indexes", diff --git a/backend/src/db/schemas/project-memberships.ts b/backend/src/db/schemas/project-memberships.ts index 8576a318e5..e522d62806 100644 --- a/backend/src/db/schemas/project-memberships.ts +++ b/backend/src/db/schemas/project-memberships.ts @@ -9,12 +9,10 @@ import { TImmutableDBKeys } from "./models"; export const ProjectMembershipsSchema = z.object({ id: z.string().uuid(), - role: z.string(), createdAt: z.date(), updatedAt: z.date(), userId: z.string().uuid(), - projectId: z.string(), - roleId: z.string().uuid().nullable().optional() + projectId: z.string() }); export type TProjectMemberships = z.infer; diff --git a/backend/src/db/schemas/project-user-membership-roles.ts b/backend/src/db/schemas/project-user-membership-roles.ts new file mode 100644 index 0000000000..bc7b672084 --- /dev/null +++ b/backend/src/db/schemas/project-user-membership-roles.ts @@ -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 ProjectUserMembershipRolesSchema = 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 TProjectUserMembershipRoles = z.infer; +export type TProjectUserMembershipRolesInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TProjectUserMembershipRolesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/seeds/3-project.ts b/backend/src/db/seeds/3-project.ts index fea71b557f..9341304945 100644 --- a/backend/src/db/seeds/3-project.ts +++ b/backend/src/db/seeds/3-project.ts @@ -4,7 +4,7 @@ import { Knex } from "knex"; import { encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; -import { OrgMembershipRole, SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas"; +import { ProjectMembershipRole, SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas"; import { buildUserProjectKey, getUserPrivateKey, seedData1 } from "../seed-data"; export const DEFAULT_PROJECT_ENVS = [ @@ -30,10 +30,15 @@ export async function seed(knex: Knex): Promise { }) .returning("*"); - await knex(TableName.ProjectMembership).insert({ - projectId: project.id, - role: OrgMembershipRole.Admin, - userId: seedData1.id + const projectMembership = await knex(TableName.ProjectMembership) + .insert({ + projectId: project.id, + userId: seedData1.id + }) + .returning("*"); + await knex(TableName.ProjectUserMembershipRole).insert({ + role: ProjectMembershipRole.Admin, + projectMembershipId: projectMembership[0].id }); const user = await knex(TableName.UserEncryptionKey).where({ userId: seedData1.id }).first(); diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 8dca967370..0f0409ca23 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -18,7 +18,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ secretVersioning: true, pitRecovery: false, ipAllowlisting: false, - rbac: false, + rbac: true, customRateLimits: false, customAlerts: false, auditLogs: false, diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index ea195bc06e..1b01c30de6 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -1,7 +1,9 @@ +import { z } from "zod"; + import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { ProjectUserMembershipRolesSchema, TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { selectAllTableCols } from "@app/lib/knex"; +import { selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; export type TPermissionDALFactory = ReturnType; @@ -43,21 +45,62 @@ export const permissionDALFactory = (db: TDbClient) => { const getProjectPermission = async (userId: string, projectId: string) => { try { - const membership = await db(TableName.ProjectMembership) - .leftJoin(TableName.ProjectRoles, `${TableName.ProjectMembership}.roleId`, `${TableName.ProjectRoles}.id`) + const docs = await db(TableName.ProjectMembership) + .join( + TableName.ProjectUserMembershipRole, + `${TableName.ProjectUserMembershipRole}.projectMembershipId`, + `${TableName.ProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.ProjectUserMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) .where("userId", userId) .where(`${TableName.ProjectMembership}.projectId`, projectId) - .select(selectAllTableCols(TableName.ProjectMembership)) + .select(selectAllTableCols(TableName.ProjectUserMembershipRole)) .select( + db.ref("id").withSchema(TableName.ProjectMembership).as("membershipId"), + db.ref("createdAt").withSchema(TableName.ProjectMembership).as("membershipCreatedAt"), + db.ref("updatedAt").withSchema(TableName.ProjectMembership).as("membershipUpdatedAt"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), - db.ref("orgId").withSchema(TableName.Project) + db.ref("orgId").withSchema(TableName.Project), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug") ) - .select("permissions") - .first(); + .select("permissions"); - return membership; + const permission = sqlNestRelationships({ + data: docs, + key: "membershipId", + parentMapper: ({ orgId, orgAuthEnforced, membershipId, membershipCreatedAt, membershipUpdatedAt }) => ({ + orgId, + orgAuthEnforced, + userId, + id: membershipId, + projectId, + createdAt: membershipCreatedAt, + updatedAt: membershipUpdatedAt + }), + childrenMapper: [ + { + key: "id", + label: "roles" as const, + mapper: (data) => + ProjectUserMembershipRolesSchema.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: "GetProjectPermission" }); } diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 67db2473cf..02adc5069a 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -18,6 +18,7 @@ import { TServiceTokenDALFactory } from "@app/services/service-token/service-tok import { orgAdminPermissions, orgMemberPermissions, orgNoAccessPermissions, OrgPermissionSet } from "./org-permission"; import { TPermissionDALFactory } from "./permission-dal"; +import { TBuildProjectPermissionDTO } from "./permission-types"; import { buildServiceTokenProjectPermission, projectAdminPermissions, @@ -64,31 +65,34 @@ export const permissionServiceFactory = ({ } }; - const buildProjectPermission = (role: string, permission?: unknown) => { - switch (role) { - case ProjectMembershipRole.Admin: - return projectAdminPermissions; - case ProjectMembershipRole.Member: - return projectMemberPermissions; - case ProjectMembershipRole.Viewer: - return projectViewerPermission; - case ProjectMembershipRole.NoAccess: - return projectNoAccessPermissions; - case ProjectMembershipRole.Custom: - return createMongoAbility( - unpackRules>>( - permission as PackRule>>[] - ), - { - conditionsMatcher - } - ); - default: - throw new BadRequestError({ - name: "ProjectRoleInvalid", - message: "Project role not found" - }); - } + const buildProjectPermission = (projectUserRoles: TBuildProjectPermissionDTO) => { + const rules = projectUserRoles + .map(({ role, permission }) => { + switch (role) { + case ProjectMembershipRole.Admin: + return projectAdminPermissions; + case ProjectMembershipRole.Member: + return projectMemberPermissions; + case ProjectMembershipRole.Viewer: + return projectViewerPermission; + case ProjectMembershipRole.NoAccess: + return projectNoAccessPermissions; + case ProjectMembershipRole.Custom: + return unpackRules>>( + permission as PackRule>>[] + ); + default: + throw new BadRequestError({ + name: "ProjectRoleInvalid", + message: "Project role not found" + }); + } + }) + .reduce((curr, prev) => prev.concat(curr), []); + + return createMongoAbility(rules, { + conditionsMatcher + }); }; /* @@ -146,19 +150,26 @@ export const permissionServiceFactory = ({ // user permission for a project in an organization const getUserProjectPermission = async (userId: string, projectId: string, userOrgId?: string) => { - const membership = await permissionDAL.getProjectPermission(userId, projectId); - if (!membership) throw new UnauthorizedError({ name: "User not in project" }); - if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) { + const userProjectPermission = await permissionDAL.getProjectPermission(userId, projectId); + if (!userProjectPermission) throw new UnauthorizedError({ name: "User not in project" }); + + if ( + userProjectPermission.roles.some(({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions) + ) { throw new BadRequestError({ name: "Custom permission not found" }); } - if (membership.orgAuthEnforced && membership.orgId !== userOrgId) { + if (userProjectPermission.orgAuthEnforced && userProjectPermission.orgId !== userOrgId) { throw new BadRequestError({ name: "Cannot access org-scoped resource" }); } return { - permission: buildProjectPermission(membership.role, membership.permissions), - membership + permission: buildProjectPermission(userProjectPermission.roles), + membership: userProjectPermission, + hasRole: (role: string) => + userProjectPermission.roles.findIndex( + ({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug + ) !== -1 }; }; @@ -170,7 +181,7 @@ export const permissionServiceFactory = ({ } return { - permission: buildProjectPermission(membership.role, membership.permissions), + permission: buildProjectPermission([{ role: membership.role, permission: membership.permissions }]), membership }; }; @@ -191,14 +202,15 @@ export const permissionServiceFactory = ({ }; type TProjectPermissionRT = T extends ActorType.SERVICE - ? { permission: MongoAbility; membership: undefined } + ? { permission: MongoAbility; membership: undefined; hasRole: () => false } // service token doesn't have both membership and roles : { permission: MongoAbility; membership: (T extends ActorType.USER ? TProjectMemberships : TIdentityProjectMemberships) & { - orgAuthEnforced: boolean; + orgAuthEnforced: boolean | null | undefined; orgId: string; - permissions?: unknown; + roles: Array<{ role: string }>; }; + hasRole: (role: string) => boolean; }; const getProjectPermission = async ( @@ -228,11 +240,13 @@ export const permissionServiceFactory = ({ const projectRole = await projectRoleDAL.findOne({ slug: role, projectId }); if (!projectRole) throw new BadRequestError({ message: "Role not found" }); return { - permission: buildProjectPermission(ProjectMembershipRole.Custom, projectRole.permissions), + permission: buildProjectPermission([ + { role: ProjectMembershipRole.Custom, permission: projectRole.permissions } + ]), role: projectRole }; } - return { permission: buildProjectPermission(role, []) }; + return { permission: buildProjectPermission([{ role, permission: [] }]) }; }; return { diff --git a/backend/src/ee/services/permission/permission-types.ts b/backend/src/ee/services/permission/permission-types.ts index e69de29bb2..ea3d5f1fc8 100644 --- a/backend/src/ee/services/permission/permission-types.ts +++ b/backend/src/ee/services/permission/permission-types.ts @@ -0,0 +1,4 @@ +export type TBuildProjectPermissionDTO = { + permission?: unknown; + role: string; +}[]; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 5245c26e47..46dbdcc3b3 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -56,8 +56,8 @@ export type ProjectPermissionSet = | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback]; -const buildAdminPermission = () => { - const { can, build } = new AbilityBuilder>(createMongoAbility); +const buildAdminPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets); @@ -135,13 +135,13 @@ const buildAdminPermission = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Project); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); - return build({ conditionsMatcher }); + return rules; }; -export const projectAdminPermissions = buildAdminPermission(); +export const projectAdminPermissions = buildAdminPermissionRules(); -const buildMemberPermission = () => { - const { can, build } = new AbilityBuilder>(createMongoAbility); +const buildMemberPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets); @@ -196,13 +196,13 @@ const buildMemberPermission = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); - return build({ conditionsMatcher }); + return rules; }; -export const projectMemberPermissions = buildMemberPermission(); +export const projectMemberPermissions = buildMemberPermissionRules(); -const buildViewerPermission = () => { - const { can, build } = new AbilityBuilder>(createMongoAbility); +const buildViewerPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); @@ -220,14 +220,14 @@ const buildViewerPermission = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); - return build({ conditionsMatcher }); + return rules; }; -export const projectViewerPermission = buildViewerPermission(); +export const projectViewerPermission = buildViewerPermissionRules(); const buildNoAccessProjectPermission = () => { - const { build } = new AbilityBuilder>(createMongoAbility); - return build({ conditionsMatcher }); + const { rules } = new AbilityBuilder>(createMongoAbility); + return rules; }; export const buildServiceTokenProjectPermission = ( diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index d2203fea37..b48b6bf951 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -129,14 +129,14 @@ export const secretApprovalRequestServiceFactory = ({ if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" }); const { policy } = secretApprovalRequest; - const { membership } = await permissionService.getProjectPermission( + const { membership, hasRole } = await permissionService.getProjectPermission( actor, actorId, secretApprovalRequest.projectId, actorOrgId ); if ( - membership.role !== ProjectMembershipRole.Admin && + !hasRole(ProjectMembershipRole.Admin) && secretApprovalRequest.committerId !== membership.id && !policy.approvers.find((approverId) => approverId === membership.id) ) { @@ -156,14 +156,14 @@ export const secretApprovalRequestServiceFactory = ({ if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); const { policy } = secretApprovalRequest; - const { membership } = await permissionService.getProjectPermission( + const { membership, hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, secretApprovalRequest.projectId, actorOrgId ); if ( - membership.role !== ProjectMembershipRole.Admin && + !hasRole(ProjectMembershipRole.Admin) && secretApprovalRequest.committerId !== membership.id && !policy.approvers.find((approverId) => approverId === membership.id) ) { @@ -198,14 +198,14 @@ export const secretApprovalRequestServiceFactory = ({ if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); const { policy } = secretApprovalRequest; - const { membership } = await permissionService.getProjectPermission( + const { membership, hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, secretApprovalRequest.projectId, actorOrgId ); if ( - membership.role !== ProjectMembershipRole.Admin && + !hasRole(ProjectMembershipRole.Admin) && secretApprovalRequest.committerId !== membership.id && !policy.approvers.find((approverId) => approverId === membership.id) ) { @@ -236,9 +236,14 @@ export const secretApprovalRequestServiceFactory = ({ if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); const { policy, folderId, projectId } = secretApprovalRequest; - const { membership } = await permissionService.getProjectPermission(ActorType.USER, actorId, projectId, actorOrgId); + const { membership, hasRole } = await permissionService.getProjectPermission( + ActorType.USER, + actorId, + projectId, + actorOrgId + ); if ( - membership.role !== ProjectMembershipRole.Admin && + !hasRole(ProjectMembershipRole.Admin) && secretApprovalRequest.committerId !== membership.id && !policy.approvers.find((approverId) => approverId === membership.id) ) { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index e7a0118d74..1a9d2ed377 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -78,6 +78,7 @@ import { projectKeyDALFactory } from "@app/services/project-key/project-key-dal" import { projectKeyServiceFactory } from "@app/services/project-key/project-key-service"; import { projectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { projectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service"; +import { projectUserMembershipRoleDALFactory } from "@app/services/project-membership/project-user-membership-role-dal"; import { projectRoleDALFactory } from "@app/services/project-role/project-role-dal"; import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service"; import { secretDALFactory } from "@app/services/secret/secret-dal"; @@ -141,6 +142,7 @@ export const registerRoutes = async ( const projectDAL = projectDALFactory(db); const projectMembershipDAL = projectMembershipDALFactory(db); + const projectUserMembershipRoleDAL = projectUserMembershipRoleDALFactory(db); const projectRoleDAL = projectRoleDALFactory(db); const projectEnvDAL = projectEnvDALFactory(db); const projectKeyDAL = projectKeyDALFactory(db); @@ -321,6 +323,7 @@ export const registerRoutes = async ( const projectMembershipService = projectMembershipServiceFactory({ projectMembershipDAL, + projectUserMembershipRoleDAL, projectDAL, permissionService, projectBotDAL, @@ -352,7 +355,8 @@ export const registerRoutes = async ( projectBotDAL, projectMembershipDAL, secretApprovalRequestDAL, - secretApprovalSecretDAL: sarSecretDAL + secretApprovalSecretDAL: sarSecretDAL, + projectUserMembershipRoleDAL }); const projectService = projectServiceFactory({ @@ -369,7 +373,8 @@ export const registerRoutes = async ( orgService, projectMembershipDAL, folderDAL, - licenseService + licenseService, + projectUserMembershipRoleDAL }); const projectEnvService = projectEnvServiceFactory({ permissionService, diff --git a/backend/src/server/routes/v1/project-membership-router.ts b/backend/src/server/routes/v1/project-membership-router.ts index afa2135c08..80d1a11bdd 100644 --- a/backend/src/server/routes/v1/project-membership-router.ts +++ b/backend/src/server/routes/v1/project-membership-router.ts @@ -2,14 +2,15 @@ import { z } from "zod"; import { OrgMembershipsSchema, - ProjectMembershipRole, ProjectMembershipsSchema, + ProjectUserMembershipRolesSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; 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 registerProjectMembershipRouter = async (server: FastifyZodProvider) => { server.route({ @@ -35,7 +36,21 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider firstName: true, lastName: true, id: true - }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })) + }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + 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() + }) + ) }) ) .omit({ createdAt: true, updatedAt: true }) @@ -86,10 +101,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider actor: req.permission.type, actorOrgId: req.permission.orgId, projectId: req.params.workspaceId, - members: req.body.members.map((member) => ({ - ...member, - projectRole: ProjectMembershipRole.Member - })) + members: req.body.members }); await server.services.auditLog.createAuditLog({ @@ -124,39 +136,53 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider membershipId: z.string().trim() }), body: z.object({ - role: z.string().trim() + 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() + }) + ]) + ) }), response: { 200: z.object({ - membership: ProjectMembershipsSchema + roles: ProjectUserMembershipRolesSchema.array() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const membership = await server.services.projectMembership.updateProjectMembership({ + const roles = await server.services.projectMembership.updateProjectMembership({ actorId: req.permission.id, actor: req.permission.type, actorOrgId: req.permission.orgId, projectId: req.params.workspaceId, membershipId: req.params.membershipId, - role: req.body.role + roles: req.body.roles }); - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: req.params.workspaceId, - event: { - type: EventType.UPDATE_USER_WORKSPACE_ROLE, - metadata: { - userId: membership.userId, - newRole: req.body.role, - oldRole: membership.role, - email: "" - } - } - }); - return { membership }; + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // projectId: req.params.workspaceId, + // event: { + // type: EventType.UPDATE_USER_WORKSPACE_ROLE, + // metadata: { + // userId: membership.userId, + // newRole: req.body.role, + // oldRole: membership.role, + // email: "" + // } + // } + // }); + return { roles }; } }); diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index ef928a4dcd..9346785ef4 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -68,7 +68,21 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { firstName: true, lastName: true, id: true - }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })) + }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + 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() + }) + ) }) ) .omit({ createdAt: true, updatedAt: true }) diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 291eb1045a..b3a2d52dcc 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -1,7 +1,7 @@ import { TDbClient } from "@app/db"; import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; export type TProjectMembershipDALFactory = ReturnType; @@ -11,32 +11,86 @@ export const projectMembershipDALFactory = (db: TDbClient) => { // special query const findAllProjectMembers = async (projectId: string) => { try { - const members = await db(TableName.ProjectMembership) - .where({ projectId }) + const docs = await db(TableName.ProjectMembership) + .where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId }) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) .join( TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id` ) + .join( + TableName.ProjectUserMembershipRole, + `${TableName.ProjectUserMembershipRole}.projectMembershipId`, + `${TableName.ProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.ProjectUserMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) .select( db.ref("id").withSchema(TableName.ProjectMembership), - db.ref("projectId").withSchema(TableName.ProjectMembership), - db.ref("role").withSchema(TableName.ProjectMembership), - db.ref("roleId").withSchema(TableName.ProjectMembership), db.ref("isGhost").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("email").withSchema(TableName.Users), db.ref("publicKey").withSchema(TableName.UserEncryptionKey), db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), - db.ref("id").withSchema(TableName.Users).as("userId") + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("role").withSchema(TableName.ProjectUserMembershipRole), + db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.ProjectUserMembershipRole), + db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.ProjectUserMembershipRole), + db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole) ) .where({ isGhost: false }); - return members.map(({ username, email, firstName, lastName, publicKey, isGhost, ...data }) => ({ - ...data, - user: { username, email, firstName, lastName, id: data.userId, publicKey, isGhost } - })); + + const members = sqlNestRelationships({ + data: docs, + parentMapper: ({ email, firstName, lastName, publicKey, isGhost, id, userId }) => ({ + id, + userId, + projectId, + user: { email, firstName, lastName, id: userId, publicKey, isGhost } + }), + 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: "Find all project members" }); } diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 70d1c80237..1170640cae 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -1,14 +1,13 @@ /* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; import { - OrgMembershipStatus, ProjectMembershipRole, ProjectVersion, SecretKeyEncoding, TableName, - TProjectMemberships, - TUsers + TProjectMemberships } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -29,22 +28,24 @@ import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal"; import { + ProjectUserMembershipTemporaryMode, TAddUsersToWorkspaceDTO, TAddUsersToWorkspaceNonE2EEDTO, TDeleteProjectMembershipOldDTO, TDeleteProjectMembershipsDTO, TGetProjectMembershipDTO, - TInviteUserToProjectDTO, TUpdateProjectMembershipDTO } from "./project-membership-types"; +import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal"; type TProjectMembershipServiceFactoryDep = { permissionService: Pick; smtpService: TSmtpService; projectBotDAL: TProjectBotDALFactory; projectMembershipDAL: TProjectMembershipDALFactory; + projectUserMembershipRoleDAL: Pick; userDAL: Pick; - projectRoleDAL: Pick; + projectRoleDAL: Pick; orgDAL: Pick; projectDAL: Pick; projectKeyDAL: Pick; @@ -56,6 +57,7 @@ export type TProjectMembershipServiceFactory = ReturnType { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); - - const invitees: TUsers[] = []; - - const project = await projectDAL.findById(projectId); - const users = await userDAL.find({ - $in: { email: emails } - }); - - await projectDAL.transaction(async (tx) => { - for (const invitee of users) { - if (!invitee.isAccepted) - throw new BadRequestError({ - message: "Failed to validate invitee", - name: "Invite user to project" - }); - - const inviteeMembership = await projectMembershipDAL.findOne( - { - userId: invitee.id, - projectId - }, - tx - ); - - if (inviteeMembership) { - throw new BadRequestError({ - message: "Existing member of project", - name: "Invite user to project" - }); - } - - const inviteeMembershipOrg = await orgDAL.findMembership({ - userId: invitee.id, - orgId: project.orgId, - status: OrgMembershipStatus.Accepted - }); - - if (!inviteeMembershipOrg) { - throw new BadRequestError({ - message: "Failed to validate invitee org membership", - name: "Invite user to project" - }); - } - - await projectMembershipDAL.create( - { - userId: invitee.id, - projectId, - role: ProjectMembershipRole.Member - }, - tx - ); - - invitees.push(invitee); - } - - const appCfg = getConfig(); - await smtpService.sendMail({ - template: SmtpTemplates.WorkspaceInvite, - subjectLine: "Infisical project invitation", - recipients: invitees.filter((i) => i.email).map((i) => i.email as string), - substitutions: { - workspaceName: project.name, - callback_url: `${appCfg.SITE_URL}/login` - } - }); - }); - - const latestKey = await projectKeyDAL.findLatestProjectKey(actorId, projectId); - - return { invitees, latestKey }; - }; - const addUsersToProject = async ({ projectId, actorId, @@ -176,17 +102,15 @@ export const projectMembershipServiceFactory = ({ if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); await projectMembershipDAL.transaction(async (tx) => { - await projectMembershipDAL.insertMany( - orgMembers.map(({ userId, id: membershipId }) => { - const role = - members.find((i) => i.orgMembershipId === membershipId)?.projectRole || ProjectMembershipRole.Member; - - return { - projectId, - userId: userId as string, - role - }; - }), + const projectMemberships = await projectMembershipDAL.insertMany( + orgMembers.map(({ userId }) => ({ + projectId, + userId: userId as string + })), + tx + ); + await projectUserMembershipRoleDAL.insertMany( + projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })), tx ); const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId); @@ -296,16 +220,19 @@ export const projectMembershipServiceFactory = ({ const members: TProjectMemberships[] = []; await projectMembershipDAL.transaction(async (tx) => { - const result = await projectMembershipDAL.insertMany( + const projectMemberships = await projectMembershipDAL.insertMany( orgMembers.map(({ user }) => ({ projectId, - userId: user.id, - role: ProjectMembershipRole.Member + userId: user.id })), tx ); + await projectUserMembershipRoleDAL.insertMany( + projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })), + tx + ); - members.push(...result); + members.push(...projectMemberships); const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId); await projectKeyDAL.insertMany( @@ -346,43 +273,74 @@ export const projectMembershipServiceFactory = ({ actorOrgId, projectId, membershipId, - role + roles }: TUpdateProjectMembershipDTO) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); const membershipUser = await userDAL.findUserByProjectMembershipId(membershipId); - - if (membershipUser?.isGhost) { + if (membershipUser?.isGhost || membershipUser?.projectId !== projectId) { throw new BadRequestError({ message: "Unauthorized member update", name: "Update project membership" }); } - const isCustomRole = !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole); - if (isCustomRole) { - const customRole = await projectRoleDAL.findOne({ slug: role, projectId }); - if (!customRole) throw new BadRequestError({ name: "Update project membership", message: "Role not found" }); - const project = await projectDAL.findById(customRole.projectId); - const plan = await licenseService.getPlan(project.orgId); + // validate custom roles input + const customInputRoles = roles.filter( + ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) + ); + const hasCustomRole = Boolean(customInputRoles.length); + if (hasCustomRole) { + const plan = await licenseService.getPlan(actorOrgId as string); if (!plan?.rbac) throw new BadRequestError({ message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." }); - - const [membership] = await projectMembershipDAL.update( - { id: membershipId, projectId }, - { - role: ProjectMembershipRole.Custom, - roleId: customRole.id - } - ); - return membership; } - const [membership] = await projectMembershipDAL.update({ id: membershipId, projectId }, { role, roleId: null }); - return membership; + 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: membershipId, + 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: membershipId, + 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 projectMembershipDAL.transaction(async (tx) => { + await projectUserMembershipRoleDAL.delete({ projectMembershipId: membershipId }, tx); + return projectUserMembershipRoleDAL.insertMany(santiziedProjectMembershipRoles, tx); + }); + + return updatedRoles; }; // This is old and should be removed later. Its not used anywhere, but it is exposed in our API. So to avoid breaking changes, we are keeping it for now. @@ -481,7 +439,6 @@ export const projectMembershipServiceFactory = ({ return { getProjectMemberships, - inviteUserToProject, updateProjectMembership, addUsersToProjectNonE2EE, deleteProjectMemberships, diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index abe4d2f72c..2ba245c8c3 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -1,7 +1,9 @@ -import { ProjectMembershipRole } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; export type TGetProjectMembershipDTO = TProjectPermission; +export enum ProjectUserMembershipTemporaryMode { + Relative = "relative" +} export type TInviteUserToProjectDTO = { emails: string[]; @@ -9,7 +11,19 @@ export type TInviteUserToProjectDTO = { export type TUpdateProjectMembershipDTO = { membershipId: string; - role: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; } & TProjectPermission; export type TDeleteProjectMembershipOldDTO = { @@ -27,7 +41,6 @@ export type TAddUsersToWorkspaceDTO = { orgMembershipId: string; workspaceEncryptedKey: string; workspaceEncryptedNonce: string; - projectRole: ProjectMembershipRole; }[]; } & TProjectPermission; diff --git a/backend/src/services/project-membership/project-user-membership-role-dal.ts b/backend/src/services/project-membership/project-user-membership-role-dal.ts new file mode 100644 index 0000000000..b1cb55b9be --- /dev/null +++ b/backend/src/services/project-membership/project-user-membership-role-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TProjectUserMembershipRoleDALFactory = ReturnType; + +export const projectUserMembershipRoleDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ProjectUserMembershipRole); + return orm; +}; diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index 7da98b314d..720c808ff2 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -92,7 +92,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: name: "Admin", slug: ProjectMembershipRole.Admin, description: "Complete administration access over the project", - permissions: packRules(projectAdminPermissions.rules), + permissions: packRules(projectAdminPermissions), createdAt: new Date(), updatedAt: new Date() }, @@ -102,7 +102,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: name: "Developer", slug: ProjectMembershipRole.Member, description: "Non-administrative role in an project", - permissions: packRules(projectMemberPermissions.rules), + permissions: packRules(projectMemberPermissions), createdAt: new Date(), updatedAt: new Date() }, @@ -112,7 +112,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: name: "Viewer", slug: ProjectMembershipRole.Viewer, description: "Non-administrative role in an project", - permissions: packRules(projectViewerPermission.rules), + permissions: packRules(projectViewerPermission), createdAt: new Date(), updatedAt: new Date() }, @@ -122,7 +122,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: name: "No Access", slug: "no-access", description: "No access to any resources in the project", - permissions: packRules(projectNoAccessPermissions.rules), + permissions: packRules(projectNoAccessPermissions), createdAt: new Date(), updatedAt: new Date() }, diff --git a/backend/src/services/project/project-queue.ts b/backend/src/services/project/project-queue.ts index e1d45e96d5..81ecd6da12 100644 --- a/backend/src/services/project/project-queue.ts +++ b/backend/src/services/project/project-queue.ts @@ -38,6 +38,7 @@ import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TSecretDALFactory } from "../secret/secret-dal"; import { TSecretVersionDALFactory } from "../secret/secret-version-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; @@ -58,9 +59,9 @@ type TProjectQueueFactoryDep = { projectBotDAL: Pick; orgService: Pick; projectMembershipDAL: Pick; + projectUserMembershipRoleDAL: Pick; integrationAuthDAL: TIntegrationAuthDALFactory; userDAL: Pick; - projectEnvDAL: Pick; projectDAL: Pick; orgDAL: Pick; @@ -81,7 +82,8 @@ export const projectQueueFactory = ({ orgDAL, projectDAL, orgService, - projectMembershipDAL + projectMembershipDAL, + projectUserMembershipRoleDAL }: TProjectQueueFactoryDep) => { const upgradeProject = async (dto: TQueueJobTypes["upgrade-project-to-ghost"]["payload"]) => { await queueService.queue(QueueName.UpgradeProjectToGhost, QueueJobs.UpgradeProjectToGhost, dto, { @@ -227,14 +229,17 @@ export const projectQueueFactory = ({ ); // Create a membership for the ghost user - await projectMembershipDAL.create( + const projectMembership = await projectMembershipDAL.create( { projectId: project.id, - userId: ghostUser.user.id, - role: ProjectMembershipRole.Admin + userId: ghostUser.user.id }, tx ); + await projectUserMembershipRoleDAL.create( + { projectMembershipId: projectMembership.id, role: ProjectMembershipRole.Admin }, + tx + ); // If a bot already exists, delete it if (existingBot) { diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index c7c3af6fa9..f5409b6f39 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -22,6 +22,7 @@ import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TUserDALFactory } from "../user/user-dal"; @@ -53,6 +54,7 @@ type TProjectServiceFactoryDep = { projectKeyDAL: Pick; projectBotDAL: Pick; projectMembershipDAL: Pick; + projectUserMembershipRoleDAL: Pick; secretBlindIndexDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; @@ -75,7 +77,8 @@ export const projectServiceFactory = ({ secretBlindIndexDAL, projectMembershipDAL, projectEnvDAL, - licenseService + licenseService, + projectUserMembershipRoleDAL }: TProjectServiceFactoryDep) => { /* * Create workspace. Make user the admin @@ -114,14 +117,17 @@ export const projectServiceFactory = ({ tx ); // set ghost user as admin of project - await projectMembershipDAL.create( + const projectMembership = await projectMembershipDAL.create( { userId: ghostUser.user.id, - role: ProjectMembershipRole.Admin, projectId: project.id }, tx ); + await projectUserMembershipRoleDAL.create( + { projectMembershipId: projectMembership.id, role: ProjectMembershipRole.Admin }, + tx + ); // generate the blind index for project await secretBlindIndexDAL.create( @@ -213,14 +219,17 @@ export const projectServiceFactory = ({ }); // Create a membership for the user - await projectMembershipDAL.create( + const userProjectMembership = await projectMembershipDAL.create( { projectId: project.id, - userId: user.id, - role: projectAdmin.projectRole + userId: user.id }, tx ); + await projectUserMembershipRoleDAL.create( + { projectMembershipId: userProjectMembership.id, role: projectAdmin.projectRole }, + tx + ); // Create a project key for the user await projectKeyDAL.create( @@ -350,11 +359,11 @@ export const projectServiceFactory = ({ }; const upgradeProject = async ({ projectId, actor, actorId, userPrivateKey }: TUpgradeProjectDTO) => { - const { permission, membership } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission, hasRole } = await permissionService.getProjectPermission(actor, actorId, projectId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); - if (membership?.role !== ProjectMembershipRole.Admin) { + if (!hasRole(ProjectMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "User must be admin" }); diff --git a/backend/src/services/secret-blind-index/secret-blind-index-service.ts b/backend/src/services/secret-blind-index/secret-blind-index-service.ts index da7a44df2b..b681266fd9 100644 --- a/backend/src/services/secret-blind-index/secret-blind-index-service.ts +++ b/backend/src/services/secret-blind-index/secret-blind-index-service.ts @@ -37,8 +37,8 @@ export const secretBlindIndexServiceFactory = ({ }; const getProjectSecrets = async ({ projectId, actorId, actor }: TGetProjectSecretsDTO) => { - const { membership } = await permissionService.getProjectPermission(actor, actorId, projectId); - if (membership?.role !== ProjectMembershipRole.Admin) { + const { hasRole } = await permissionService.getProjectPermission(actor, actorId, projectId); + if (!hasRole(ProjectMembershipRole.Admin)) { throw new UnauthorizedError({ message: "User must be admin" }); } @@ -53,8 +53,8 @@ export const secretBlindIndexServiceFactory = ({ actorOrgId, secretsToUpdate }: TUpdateProjectSecretNameDTO) => { - const { membership } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); - if (membership?.role !== ProjectMembershipRole.Admin) { + const { hasRole } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); + if (!hasRole(ProjectMembershipRole.Admin)) { throw new UnauthorizedError({ message: "User must be admin" }); }