diff --git a/backend/package-lock.json b/backend/package-lock.json index 8a8e9d13d..28c22b79d 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 954f1181d..be0bbcd6d 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 2e8b2611d..31b80e0e3 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -32,6 +32,9 @@ import { TIdentityOrgMemberships, TIdentityOrgMembershipsInsert, TIdentityOrgMembershipsUpdate, + TIdentityProjectMembershipRole, + TIdentityProjectMembershipRoleInsert, + TIdentityProjectMembershipRoleUpdate, TIdentityProjectMemberships, TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsUpdate, @@ -83,6 +86,9 @@ import { TProjects, TProjectsInsert, TProjectsUpdate, + TProjectUserMembershipRoles, + TProjectUserMembershipRolesInsert, + TProjectUserMembershipRolesUpdate, TSamlConfigs, TSamlConfigsInsert, TSamlConfigsUpdate, @@ -221,6 +227,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; @@ -272,6 +283,11 @@ declare module "knex/types/tables" { TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsUpdate >; + [TableName.IdentityProjectMembershipRole]: Knex.CompositeTableType< + TIdentityProjectMembershipRole, + TIdentityProjectMembershipRoleInsert, + TIdentityProjectMembershipRoleUpdate + >; [TableName.ScimToken]: Knex.CompositeTableType; [TableName.SecretApprovalPolicy]: Knex.CompositeTableType< TSecretApprovalPolicies, diff --git a/backend/src/db/migrations/20240312162549_temp-roles.ts b/backend/src/db/migrations/20240312162549_temp-roles.ts new file mode 100644 index 000000000..2c5a04f05 --- /dev/null +++ b/backend/src/db/migrations/20240312162549_temp-roles.ts @@ -0,0 +1,61 @@ +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); + } + } + if (rows.length) await knex(TableName.ProjectUserMembershipRole).insert(rows); + // will be dropped later + // await knex.schema.alterTable(TableName.ProjectMembership, (t) => { + // t.dropColumn("roleId"); + // t.dropColumn("role"); + // }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.ProjectUserMembershipRole); + await dropOnUpdateTrigger(knex, TableName.ProjectUserMembershipRole); +} diff --git a/backend/src/db/migrations/20240312162556_temp-role-identity.ts b/backend/src/db/migrations/20240312162556_temp-role-identity.ts new file mode 100644 index 000000000..e94827fe6 --- /dev/null +++ b/backend/src/db/migrations/20240312162556_temp-role-identity.ts @@ -0,0 +1,63 @@ +import { Knex } from "knex"; + +import { TableName, TIdentityProjectMembershipRoleInsert } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + 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 { + await knex.schema.dropTableIfExists(TableName.IdentityProjectMembershipRole); + await dropOnUpdateTrigger(knex, TableName.IdentityProjectMembershipRole); +} diff --git a/backend/src/db/schemas/identity-project-membership-role.ts b/backend/src/db/schemas/identity-project-membership-role.ts new file mode 100644 index 000000000..90a0a3538 --- /dev/null +++ b/backend/src/db/schemas/identity-project-membership-role.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 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; +export type TIdentityProjectMembershipRoleInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TIdentityProjectMembershipRoleUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index fb717d344..001fdbf18 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -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"; @@ -25,6 +26,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 c85aad66a..f85feff9c 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", @@ -41,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", 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 000000000..bc7b67208 --- /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 fea71b557..d41efb71c 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,16 @@ 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, + role: ProjectMembershipRole.Admin + }) + .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/db/seeds/4-machine-identity.ts b/backend/src/db/seeds/4-machine-identity.ts index 149fba40a..618c47114 100644 --- a/backend/src/db/seeds/4-machine-identity.ts +++ b/backend/src/db/seeds/4-machine-identity.ts @@ -75,9 +75,16 @@ export async function seed(knex: Knex): Promise { } ]); - await knex(TableName.IdentityProjectMembership).insert({ - identityId: seedData1.machineIdentity.id, + const identityProjectMembership = await knex(TableName.IdentityProjectMembership) + .insert({ + identityId: seedData1.machineIdentity.id, + projectId: seedData1.project.id, + role: ProjectMembershipRole.Admin + }) + .returning("*"); + + await knex(TableName.IdentityProjectMembershipRole).insert({ role: ProjectMembershipRole.Admin, - projectId: seedData1.project.id + projectMembershipId: identityProjectMembership[0].id }); } diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index 46b80e7a9..1e40d2d80 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -1,6 +1,7 @@ +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -import { OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -13,7 +14,17 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { organizationId: z.string().trim() }), body: z.object({ - slug: z.string().trim(), + slug: z + .string() + .min(1) + .trim() + .refine( + (val) => Object.keys(OrgMembershipRole).includes(val), + "Please choose a different slug, the slug you have entered is reserved" + ) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid" + }), name: z.string().trim(), description: z.string().trim().optional(), permissions: z.any().array() @@ -45,7 +56,17 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { roleId: z.string().trim() }), body: z.object({ - slug: z.string().trim().optional(), + slug: z + .string() + .trim() + .optional() + .refine( + (val) => typeof val === "undefined" || Object.keys(OrgMembershipRole).includes(val), + "Please choose a different slug, the slug you have entered is reserved." + ) + .refine((val) => typeof val === "undefined" || slugify(val) === val, { + message: "Slug must be a valid" + }), name: z.string().trim().optional(), description: z.string().trim().optional(), permissions: z.any().array() diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index ea195bc06..d94589b43 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 { IdentityProjectMembershipRoleSchema, 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,72 @@ 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"), + // TODO(roll-forward-migration): remove this field when we drop this in next migration after a week + db.ref("role").withSchema(TableName.ProjectMembership).as("oldRoleField"), + 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, + oldRoleField + }) => ({ + orgId, + orgAuthEnforced, + userId, + role: oldRoleField, + 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" }); } @@ -65,18 +118,62 @@ 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("role").withSchema(TableName.IdentityProjectMembership).as("oldRoleField"), + 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, oldRoleField }) => ({ + id: membershipId, + identityId, + projectId, + role: oldRoleField, + 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" }); } diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 67db2473c..c7dcf4b8c 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,35 @@ 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 + const buildProjectPermission = (projectUserRoles: TBuildProjectPermissionDTO) => { + const rules = projectUserRoles + .map(({ role, permissions }) => { + 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>>( + permissions as PackRule>>[] + ); } - ); - default: - throw new BadRequestError({ - name: "ProjectRoleInvalid", - message: "Project role not found" - }); - } + default: + throw new BadRequestError({ + name: "ProjectRoleInvalid", + message: "Project role not found" + }); + } + }) + .reduce((curr, prev) => prev.concat(curr), []); + + return createMongoAbility(rules, { + conditionsMatcher + }); }; /* @@ -145,33 +150,56 @@ 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 getUserProjectPermission = async ( + userId: string, + projectId: string, + userOrgId?: string + ): Promise> => { + 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 }; }; - 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> => { + 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(membership.role, membership.permissions), - membership + permission: buildProjectPermission(identityProjectPermission.roles), + membership: identityProjectPermission, + hasRole: (role: string) => + identityProjectPermission.roles.findIndex( + ({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug + ) !== -1 }; }; @@ -191,14 +219,19 @@ export const permissionServiceFactory = ({ }; type TProjectPermissionRT = T extends ActorType.SERVICE - ? { permission: MongoAbility; membership: undefined } + ? { + permission: MongoAbility; + membership: undefined; + hasRole: (arg: string) => boolean; + } // 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 +261,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, permissions: projectRole.permissions } + ]), role: projectRole }; } - return { permission: buildProjectPermission(role, []) }; + return { permission: buildProjectPermission([{ role, permissions: [] }]) }; }; return { diff --git a/backend/src/ee/services/permission/permission-types.ts b/backend/src/ee/services/permission/permission-types.ts index e69de29bb..a35958ffd 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 = { + permissions?: unknown; + role: string; +}[]; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 5245c26e4..46dbdcc3b 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 d2203fea3..b48b6bf95 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 e7a0118d7..0a49806bd 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -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"; @@ -78,6 +79,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 +143,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); @@ -164,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); @@ -321,6 +325,7 @@ export const registerRoutes = async ( const projectMembershipService = projectMembershipServiceFactory({ projectMembershipDAL, + projectUserMembershipRoleDAL, projectDAL, permissionService, projectBotDAL, @@ -352,7 +357,8 @@ export const registerRoutes = async ( projectBotDAL, projectMembershipDAL, secretApprovalRequestDAL, - secretApprovalSecretDAL: sarSecretDAL + secretApprovalSecretDAL: sarSecretDAL, + projectUserMembershipRoleDAL }); const projectService = projectServiceFactory({ @@ -369,8 +375,11 @@ export const registerRoutes = async ( orgService, projectMembershipDAL, folderDAL, - licenseService + licenseService, + projectUserMembershipRoleDAL, + identityProjectMembershipRoleDAL }); + const projectEnvService = projectEnvServiceFactory({ permissionService, projectEnvDAL, @@ -521,7 +530,9 @@ export const registerRoutes = async ( permissionService, projectDAL, identityProjectDAL, - identityOrgMembershipDAL + identityOrgMembershipDAL, + identityProjectMembershipRoleDAL, + projectRoleDAL }); const identityUaService = identityUaServiceFactory({ identityOrgMembershipDAL, diff --git a/backend/src/server/routes/v1/project-membership-router.ts b/backend/src/server/routes/v1/project-membership-router.ts index afa2135c0..999a5d025 100644 --- a/backend/src/server/routes/v1/project-membership-router.ts +++ b/backend/src/server/routes/v1/project-membership-router.ts @@ -1,15 +1,17 @@ +import ms from "ms"; 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({ @@ -28,16 +30,31 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider }), response: { 200: z.object({ - memberships: ProjectMembershipsSchema.merge( - z.object({ - user: UsersSchema.pick({ - email: true, - firstName: true, - lastName: true, - id: true - }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })) - }) - ) + memberships: ProjectMembershipsSchema.omit({ role: true }) + .merge( + z.object({ + user: UsersSchema.pick({ + email: true, + firstName: true, + lastName: true, + id: 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 }) .array() }) @@ -86,10 +103,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 +138,56 @@ 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().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + ]) + ) + .min(1) + .refine((data) => data.some(({ isTemporary }) => !isTemporary), "At least long lived role is required") }), 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 ef928a4dc..dc32702ff 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -60,17 +60,32 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - users: ProjectMembershipsSchema.merge( - z.object({ - user: UsersSchema.pick({ - username: true, - email: true, - firstName: true, - lastName: true, - id: true - }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })) - }) - ) + users: ProjectMembershipsSchema.omit({ role: true }) + .merge( + z.object({ + user: UsersSchema.pick({ + username: true, + email: true, + firstName: true, + lastName: true, + id: 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 }) .array() }) diff --git a/backend/src/server/routes/v2/identity-project-router.ts b/backend/src/server/routes/v2/identity-project-router.ts index fdd810b4b..09e586839 100644 --- a/backend/src/server/routes/v2/identity-project-router.ts +++ b/backend/src/server/routes/v2/identity-project-router.ts @@ -1,13 +1,15 @@ +import ms from "ms"; import { z } from "zod"; 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 +59,40 @@ 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().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + ]) + ) + .min(1) }), 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() }) } }, diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index dbb864387..dd3ba04f4 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -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; @@ -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" }); } diff --git a/backend/src/services/identity-project/identity-project-membership-role-dal.ts b/backend/src/services/identity-project/identity-project-membership-role-dal.ts new file mode 100644 index 000000000..3f6c6b589 --- /dev/null +++ b/backend/src/services/identity-project/identity-project-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 TIdentityProjectMembershipRoleDALFactory = ReturnType; + +export const identityProjectMembershipRoleDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.IdentityProjectMembershipRole); + return orm; +}; diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index f9d21034f..b6f6e4343 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -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; + projectRoleDAL: Pick; identityOrgMembershipDAL: Pick; permissionService: Pick; }; @@ -30,7 +40,9 @@ export const identityProjectServiceFactory = ({ identityProjectDAL, permissionService, identityOrgMembershipDAL, - projectDAL + identityProjectMembershipRoleDAL, + projectDAL, + projectRoleDAL }: TIdentityProjectServiceFactoryDep) => { const createProjectIdentity = async ({ identityId, @@ -70,11 +82,26 @@ 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, + role: isCustomRole ? ProjectMembershipRole.Custom : role, + roleId: customRole?.id + }, + tx + ); + + await identityProjectMembershipRoleDAL.create( + { + projectMembershipId: identityProjectMembership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : role, + customRoleId: customRole?.id + }, + tx + ); + return identityProjectMembership; }); return projectIdentity; }; @@ -82,7 +109,7 @@ export const identityProjectServiceFactory = ({ const updateProjectIdentity = async ({ projectId, identityId, - role, + roles, actor, actorId, actorOrgId @@ -106,28 +133,51 @@ 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); + 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 ({ diff --git a/backend/src/services/identity-project/identity-project-types.ts b/backend/src/services/identity-project/identity-project-types.ts index 71e048c19..73e8ec246 100644 --- a/backend/src/services/identity-project/identity-project-types.ts +++ b/backend/src/services/identity-project/identity-project-types.ts @@ -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; diff --git a/backend/src/services/org/org-role-service.ts b/backend/src/services/org/org-role-service.ts index b3d8121c3..fb8a57440 100644 --- a/backend/src/services/org/org-role-service.ts +++ b/backend/src/services/org/org-role-service.ts @@ -58,7 +58,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol { id: roleId, orgId }, { ...data, permissions: data.permissions ? JSON.stringify(data.permissions) : undefined } ); - if (!updateRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); + if (!updatedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); return updatedRole; }; @@ -66,7 +66,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Role); const [deletedRole] = await orgRoleDAL.delete({ id: roleId, orgId }); - if (!deleteRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); + if (!deletedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); return deletedRole; }; diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 291eb1045..8faab487e 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, username, lastName, publicKey, isGhost, id, userId }) => ({ + id, + userId, + projectId, + user: { email, username, 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 70d1c8023..1f751ee89 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,16 @@ 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, + role: ProjectMembershipRole.Member + })), + tx + ); + await projectUserMembershipRoleDAL.insertMany( + projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })), tx ); const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId); @@ -296,7 +221,7 @@ 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, @@ -304,8 +229,12 @@ export const projectMembershipServiceFactory = ({ })), 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 +275,71 @@ 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); + 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 +438,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 abe4d2f72..2ba245c8c 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 000000000..b1cb55b9b --- /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 7da98b314..b45a6e8a5 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -76,7 +76,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Role); const [deletedRole] = await projectRoleDAL.delete({ id: roleId, projectId }); - if (!deleteRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); + if (!deletedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); return deletedRole; }; @@ -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 e1d45e96d..4431855ce 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,7 +229,7 @@ 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, @@ -235,6 +237,10 @@ export const projectQueueFactory = ({ }, 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 c7c3af6fa..873a7d36f 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -17,11 +17,13 @@ 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"; 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"; @@ -50,9 +52,11 @@ type TProjectServiceFactoryDep = { projectEnvDAL: Pick; identityOrgMembershipDAL: TIdentityOrgDALFactory; identityProjectDAL: TIdentityProjectDALFactory; + identityProjectMembershipRoleDAL: Pick; projectKeyDAL: Pick; projectBotDAL: Pick; projectMembershipDAL: Pick; + projectUserMembershipRoleDAL: Pick; secretBlindIndexDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; @@ -75,7 +79,9 @@ export const projectServiceFactory = ({ secretBlindIndexDAL, projectMembershipDAL, projectEnvDAL, - licenseService + licenseService, + projectUserMembershipRoleDAL, + identityProjectMembershipRoleDAL }: TProjectServiceFactoryDep) => { /* * Create workspace. Make user the admin @@ -114,14 +120,18 @@ 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 + projectId: project.id, + role: ProjectMembershipRole.Admin }, tx ); + await projectUserMembershipRoleDAL.create( + { projectMembershipId: projectMembership.id, role: ProjectMembershipRole.Admin }, + tx + ); // generate the blind index for project await secretBlindIndexDAL.create( @@ -213,7 +223,7 @@ export const projectServiceFactory = ({ }); // Create a membership for the user - await projectMembershipDAL.create( + const userProjectMembership = await projectMembershipDAL.create( { projectId: project.id, userId: user.id, @@ -221,6 +231,10 @@ export const projectServiceFactory = ({ }, tx ); + await projectUserMembershipRoleDAL.create( + { projectMembershipId: userProjectMembership.id, role: projectAdmin.projectRole }, + tx + ); // Create a project key for the user await projectKeyDAL.create( @@ -266,7 +280,7 @@ export const projectServiceFactory = ({ }); const isCustomRole = Boolean(customRole); - await identityProjectDAL.create( + const identityProjectMembership = await identityProjectDAL.create( { identityId: actorId, projectId: project.id, @@ -275,6 +289,15 @@ export const projectServiceFactory = ({ }, tx ); + + await identityProjectMembershipRoleDAL.create( + { + projectMembershipId: identityProjectMembership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : ProjectMembershipRole.Admin, + customRoleId: customRole?.id + }, + tx + ); } return { @@ -350,11 +373,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 da7a44df2..b681266fd 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" }); } diff --git a/frontend/src/components/v2/Popoverv2/Popoverv2.tsx b/frontend/src/components/v2/Popoverv2/Popoverv2.tsx index 2fea00206..d78ede201 100644 --- a/frontend/src/components/v2/Popoverv2/Popoverv2.tsx +++ b/frontend/src/components/v2/Popoverv2/Popoverv2.tsx @@ -12,6 +12,7 @@ export const Popover = PopoverPrimitive.Root; export type PopoverContentProps = { children?: ReactNode; + arrowClassName?: string; hideCloseBtn?: boolean; } & PopoverPrimitive.PopoverContentProps; @@ -19,6 +20,7 @@ export const PopoverContent = ({ children, className, hideCloseBtn, + arrowClassName, ...props }: PopoverContentProps) => ( @@ -48,7 +50,7 @@ export const PopoverContent = ({ )} - + ); diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 7a8e207e2..fd3e88614 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -1,4 +1,4 @@ -import { TOrgRole, TProjectRole } from "../roles/types"; +import { TOrgRole } from "../roles/types"; import { IdentityAuthMethod } from "./enums"; export type IdentityTrustedIp = { @@ -29,9 +29,18 @@ export type IdentityMembershipOrg = { export type IdentityMembership = { id: string; identity: Identity; - organization: string; - role: "admin" | "member" | "viewer" | "no-access" | "custom"; - customRole?: TProjectRole; + roles: { + id: string; + role: "owner" | "admin" | "member" | "no-access" | "custom"; + customRoleId: string; + customRoleName: string; + customRoleSlug: string; + isTemporary: boolean; + temporaryMode: string | null; + temporaryRange: string | null; + temporaryAccessStartTime: string | null; + temporaryAccessEndTime: string | null; + }[]; createdAt: string; updatedAt: string; }; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 65a603698..edc3bdf9e 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -64,7 +64,33 @@ export type TProjectMembership = { roleId: string; }; -export type TWorkspaceUser = OrgUser; +export type TWorkspaceUser = { + id: string; + user: { + email: string; + username: string; + firstName: string; + lastName: string; + id: string; + publicKey: string; + }; + inviteEmail: string; + organization: string; + roles: { + id: string; + role: "owner" | "admin" | "member" | "no-access" | "custom"; + customRoleId: string; + customRoleName: string; + customRoleSlug: string; + isTemporary: boolean; + temporaryMode: string | null; + temporaryRange: string | null; + temporaryAccessStartTime: string | null; + temporaryAccessEndTime: string | null; + }[]; + status: "invited" | "accepted" | "verified" | "completed"; + deniedPermissions: any[]; +}; export type AddUserToWsDTOE2EE = { workspaceId: string; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 2d28be501..37e512ac8 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -16,6 +16,8 @@ import { RenameWorkspaceDTO, TGetUpgradeProjectStatusDTO, ToggleAutoCapitalizationDTO, + TUpdateWorkspaceIdentityRoleDTO, + TUpdateWorkspaceUserRoleDTO, UpdateEnvironmentDTO, Workspace } from "./types"; @@ -340,27 +342,19 @@ export const useDeleteUserFromWorkspace = () => { export const useUpdateUserWorkspaceRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ - membershipId, - role, - workspaceId - }: { - membershipId: string; - role: string; - workspaceId: string; - }) => { + mutationFn: async ({ membershipId, roles, workspaceId }: TUpdateWorkspaceUserRoleDTO) => { const { data: { membership } } = await apiRequest.patch<{ membership: { projectId: string } }>( `/api/v1/workspace/${workspaceId}/memberships/${membershipId}`, { - role + roles } ); return membership; }, - onSuccess: (res) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(res.projectId)); + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId)); } }); }; @@ -400,18 +394,14 @@ export const useUpdateIdentityWorkspaceRole = () => { mutationFn: async ({ identityId, workspaceId, - role - }: { - identityId: string; - workspaceId: string; - role?: string; - }) => { + roles + }:TUpdateWorkspaceIdentityRoleDTO)=> { const { data: { identityMembership } } = await apiRequest.patch( `/api/v2/workspace/${workspaceId}/identity-memberships/${identityId}`, { - role + roles } ); diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index c4aada862..828a7a890 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -3,6 +3,10 @@ export enum ProjectVersion { V2 = 2 } +export enum ProjectUserMembershipTemporaryMode { + Relative = "relative" +} + export type Workspace = { __v: number; id: string; @@ -72,3 +76,39 @@ export type UpdateEnvironmentDTO = { }; export type DeleteEnvironmentDTO = { workspaceId: string; id: string }; + +export type TUpdateWorkspaceUserRoleDTO = { + membershipId: string; + workspaceId: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; +}; + +export type TUpdateWorkspaceIdentityRoleDTO = { + identityId: string; + workspaceId: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; +}; diff --git a/frontend/src/lib/fn/array.ts b/frontend/src/lib/fn/array.ts new file mode 100644 index 000000000..33409653a --- /dev/null +++ b/frontend/src/lib/fn/array.ts @@ -0,0 +1,15 @@ +/** + * Sorts an array of items into groups. The return value is a map where the keys are + * the group ids the given getGroupId function produced and the value is an array of + * each item in that group. + */ +export const groupBy = ( + array: readonly T[], + getGroupId: (item: T) => Key +): Record => + array.reduce((acc, item) => { + const groupId = getGroupId(item); + if (!acc[groupId]) acc[groupId] = []; + acc[groupId].push(item); + return acc; + }, {} as Record); diff --git a/frontend/src/views/Project/MembersPage/MembersPage.tsx b/frontend/src/views/Project/MembersPage/MembersPage.tsx index f4e88de4e..b565d426c 100644 --- a/frontend/src/views/Project/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Project/MembersPage/MembersPage.tsx @@ -5,12 +5,7 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { withProjectPermission } from "@app/hoc"; -import { - IdentityTab, - MemberListTab, - ProjectRoleListTab, - ServiceTokenTab -} from "./components"; +import { IdentityTab, MemberListTab, ProjectRoleListTab, ServiceTokenTab } from "./components"; enum TabSections { Member = "members", @@ -23,19 +18,14 @@ export const MembersPage = withProjectPermission( () => { return (
-
-

- Project Access Control -

+
+

Project Access Control

People

Machine Identities

-
- New -
Service Tokens diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityRoles.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityRoles.tsx new file mode 100644 index 000000000..c7a68a6b2 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityRoles.tsx @@ -0,0 +1,459 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + Checkbox, + FormControl, + HoverCard, + HoverCardContent, + HoverCardTrigger, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { TWorkspaceUser } from "@app/hooks/api/types"; +import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types"; +import { groupBy } from "@app/lib/fn/array"; + +const temporaryRoleFormSchema = z.object({ + temporaryRange: z.string().min(1, "Required") +}); + +type TTemporaryRoleFormSchema = z.infer; + +type TTemporaryRoleFormProps = { + temporaryConfig?: { + isTemporary?: boolean; + temporaryAccessEndTime?: string | null; + temporaryAccessStartTime?: string | null; + temporaryRange?: string | null; + }; + onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void; + onRemoveTemporary: () => void; +}; + +const IdentityTemporaryRoleForm = ({ + temporaryConfig: defaultValues = {}, + onSetTemporary, + onRemoveTemporary +}: TTemporaryRoleFormProps) => { + const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const); + const { control, handleSubmit } = useForm({ + resolver: zodResolver(temporaryRoleFormSchema), + values: { + temporaryRange: defaultValues.temporaryRange || "1h" + } + }); + const isTemporaryFieldValue = defaultValues.isTemporary; + const isExpired = + isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || ""); + + return ( + { + handlePopUpToggle("setTempRole", isOpen); + }} + > + + + + + + + + +
+
+ Set Role Temporarily +
+ {isExpired && Expired} + ( + + 1m, 2h, 3d.{" "} + + More + + + } + > + + + )} + /> +
+ {isTemporaryFieldValue && ( + + )} + {!isTemporaryFieldValue ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; + +const formSchema = z.record( + z.object({ + isChecked: z.boolean().optional(), + temporaryAccess: z.union([ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.boolean() + ]) + }) +); +type TForm = z.infer; + +export type TMemberRolesProp = { + disableEdit?: boolean; + identityId: string; + roles: TWorkspaceUser["roles"]; +}; + +const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; + +export const IdentityRoles = ({ + roles = [], + disableEdit = false, + identityId +}: TMemberRolesProp) => { + const { currentWorkspace } = useWorkspace(); + const { createNotification } = useNotificationContext(); + const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const); + const [searchRoles, setSearchRoles] = useState(""); + + const { + handleSubmit, + control, + reset, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(formSchema) + }); + + const workspaceId = currentWorkspace?.id || ""; + + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role); + + const updateIdentityWorkspaceRole = useUpdateIdentityWorkspaceRole(); + + const handleRoleUpdate = async (data: TForm) => { + const selectedRoles = Object.keys(data) + .filter((el) => Boolean(data[el].isChecked)) + .map((el) => { + const isTemporary = Boolean(data[el].temporaryAccess); + if (!isTemporary) { + return { role: el, isTemporary: false as const }; + } + + const tempCfg = data[el].temporaryAccess as { + temporaryRange: string; + temporaryAccessStartTime: string; + }; + + return { + role: el, + isTemporary: true as const, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: tempCfg.temporaryRange, + temporaryAccessStartTime: tempCfg.temporaryAccessStartTime + }; + }); + + try { + await updateIdentityWorkspaceRole.mutateAsync({ + workspaceId, + identityId, + roles: selectedRoles + }); + createNotification({ text: "Successfully updated identity role", type: "success" }); + handlePopUpToggle("editRole"); + setSearchRoles(""); + } catch (err) { + createNotification({ text: "Failed to update identity role", type: "error" }); + } + }; + + const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Member) return "Developer"; + return role; + }; + + return ( +
+ {roles + .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + +
+ )} +
+
+ ); + })} + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {roles + .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + new Date(temporaryAccessEndTime as string) && + "text-red-600" + )} + /> + +
+ )} +
+
+ ); + })}{" "} +
+
+ )} +
+ { + handlePopUpToggle("editRole", isOpen); + reset(); + }} + > + {!disableEdit && ( + + + + + + )} + + {isRolesLoading ? ( +
+ +
+ ) : ( +
+
+ {projectRoles + ?.filter( + ({ name, slug }) => + name.toLowerCase().includes(searchRoles.toLowerCase()) || + slug.toLowerCase().includes(searchRoles.toLowerCase()) + ) + ?.map(({ id, name, slug }) => { + const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0]; + + return ( +
+
+ ( + { + field.onChange(isChecked); + setValue(`${slug}.temporaryAccess`, false); + }} + > + {name} + + )} + /> +
+
+ ( + { + setValue(`${slug}.isChecked`, true, { shouldDirty: true }); + console.log(data); + field.onChange({ isTemporary: true, ...data }); + }} + onRemoveTemporary={() => { + setValue(`${slug}.isChecked`, false, { shouldDirty: true }); + field.onChange(false); + }} + /> + )} + /> +
+
+ ); + })} +
+
+
+ setSearchRoles(el.target.value)} + leftIcon={} + placeholder="Search roles.." + /> +
+
+ +
+
+
+ )} +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx index eb7320b89..24197e9fe 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx @@ -2,13 +2,10 @@ import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; -import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { ProjectPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, - Select, - SelectItem, Table, TableContainer, TableSkeleton, @@ -19,13 +16,11 @@ import { Tr } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { - useGetProjectRoles, - useGetWorkspaceIdentityMemberships, - useUpdateIdentityWorkspaceRole -} from "@app/hooks/api"; +import { useGetWorkspaceIdentityMemberships } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { IdentityRoles } from "./IdentityRoles"; + type Props = { handlePopUpOpen: ( popUpName: keyof UsePopUpState<["deleteIdentity", "identity"]>, @@ -37,39 +32,9 @@ type Props = { }; export const IdentityTable = ({ handlePopUpOpen }: Props) => { - const { createNotification } = useNotificationContext(); const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || ""); - const { data: roles } = useGetProjectRoles(workspaceId); - - const { mutateAsync: updateMutateAsync } = useUpdateIdentityWorkspaceRole(); - - const handleChangeRole = async ({ identityId, role }: { identityId: string; role: string }) => { - try { - await updateMutateAsync({ - identityId, - workspaceId, - role - }); - - createNotification({ - text: "Successfully updated identity role", - type: "success" - }); - } catch (err) { - console.error(err); - const error = err as any; - const text = error?.response?.data?.message ?? "Failed to update identity role"; - - createNotification({ - text, - type: "error" - }); - } - }; - return ( @@ -86,7 +51,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { {!isLoading && data && data.length > 0 && - data.map(({ identity: { id, name }, role, customRole, createdAt }) => { + data.map(({ identity: { id, name }, roles, createdAt }) => { return ( @@ -95,28 +60,9 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Identity} > - {(isAllowed) => { - return ( - - ); - }} + {(isAllowed) => ( + + )} diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index bae397f67..d892d42ae 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Link from "next/link"; @@ -34,7 +34,6 @@ import { ProjectPermissionActions, ProjectPermissionSub, useOrganization, - useSubscription, useUser, useWorkspace } from "@app/context"; @@ -44,14 +43,13 @@ import { useAddUserToWsNonE2EE, useDeleteUserFromWorkspace, useGetOrgUsers, - useGetProjectRoles, useGetUserWsKey, - useGetWorkspaceUsers, - useUpdateUserWorkspaceRole + useGetWorkspaceUsers } from "@app/hooks/api"; -import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; +import { MemberRoles } from "./MemberRoles"; + const addMemberFormSchema = z.object({ orgMembershipId: z.string().trim() }); @@ -60,7 +58,6 @@ type TAddMemberForm = z.infer; export const MemberListTab = () => { const { createNotification } = useNotificationContext(); - const { subscription } = useSubscription(); const { t } = useTranslation(); const { currentOrg } = useOrganization(); @@ -71,8 +68,6 @@ export const MemberListTab = () => { const orgId = currentOrg?.id || ""; const workspaceId = currentWorkspace?.id || ""; - const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); - const { data: wsKey } = useGetUserWsKey(workspaceId); const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId); const { data: orgUsers } = useGetOrgUsers(orgId); @@ -95,7 +90,6 @@ export const MemberListTab = () => { const { mutateAsync: addUserToWorkspace } = useAddUserToWsE2EE(); const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE(); const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace(); - const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole(); const onAddMember = async ({ orgMembershipId }: TAddMemberForm) => { if (!currentWorkspace) return; @@ -167,47 +161,6 @@ export const MemberListTab = () => { handlePopUpClose("removeMember"); }; - const isIamOwner = useMemo( - () => members?.find(({ user: u }) => userId === u?.id)?.role === "owner", - [userId, members] - ); - - const findRoleFromId = useCallback( - (roleId: string) => { - return (roles || []).find(({ id }) => id === roleId); - }, - [roles] - ); - - const onRoleChange = async (membershipId: string, role: string) => { - if (!currentOrg?.id) return; - - try { - const isCustomRole = !Object.values(ProjectMembershipRole).includes( - role as ProjectMembershipRole - ); - - if (isCustomRole && subscription && !subscription?.rbac) { - handlePopUpOpen("upgradePlan", { - description: "You can assign custom roles to members if you upgrade your Infisical plan." - }); - return; - } - - await updateUserWorkspaceRole({ membershipId, role, workspaceId }); - createNotification({ - text: "Successfully updated user role", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to update user role", - type: "error" - }); - } - }; - const filterdUsers = useMemo( () => members?.filter( @@ -231,8 +184,6 @@ export const MemberListTab = () => { ); }, [orgUsers, members]); - const isLoading = isMembersLoading || isRolesLoading; - return (
@@ -269,75 +220,61 @@ export const MemberListTab = () => {
- {isLoading && } - {!isLoading && - filterdUsers?.map( - ({ user: u, id: membershipId, roleId, role }) => { - const name = u ? `${u.firstName} ${u.lastName}` : "-"; - const username = u?.username ?? "-"; - return ( - - - - + + + + - - - ); - } - )} + )} + + + ); + })}
{name}{format(new Date(createdAt), "yyyy-MM-dd")}
{name}{username} + {isMembersLoading && } + {!isMembersLoading && + filterdUsers?.map(({ user: u, inviteEmail, id: membershipId, roles }) => { + const name = u ? `${u.firstName} ${u.lastName}` : "-"; + const email = u?.email || inviteEmail; + + return ( +
{name}{email} + + {(isAllowed) => ( + + handlePopUpOpen("upgradePlan", { description }) + } + membershipId={membershipId} + /> + )} + + + {userId !== u?.id && ( {(isAllowed) => ( - + handlePopUpOpen("removeMember", { email: u.email })} + > + + )} - - {userId !== u?.id && ( - - {(isAllowed) => ( - - handlePopUpOpen("removeMember", { username: u.username }) - } - > - - - )} - - )} -
- {!isLoading && filterdUsers?.length === 0 && ( + {!isMembersLoading && filterdUsers?.length === 0 && ( )}
diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx new file mode 100644 index 000000000..fbc36aa95 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx @@ -0,0 +1,473 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + Checkbox, + FormControl, + HoverCard, + HoverCardContent, + HoverCardTrigger, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { useSubscription, useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { TWorkspaceUser } from "@app/hooks/api/types"; +import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types"; +import { groupBy } from "@app/lib/fn/array"; + +const temporaryRoleFormSchema = z.object({ + temporaryRange: z.string().min(1, "Required") +}); + +type TTemporaryRoleFormSchema = z.infer; + +type TTemporaryRoleFormProps = { + temporaryConfig?: { + isTemporary?: boolean; + temporaryAccessEndTime?: string | null; + temporaryAccessStartTime?: string | null; + temporaryRange?: string | null; + }; + onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void; + onRemoveTemporary: () => void; +}; + +const TemporaryRoleForm = ({ + temporaryConfig: defaultValues = {}, + onSetTemporary, + onRemoveTemporary +}: TTemporaryRoleFormProps) => { + const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const); + const { control, handleSubmit } = useForm({ + resolver: zodResolver(temporaryRoleFormSchema), + values: { + temporaryRange: defaultValues.temporaryRange || "1h" + } + }); + const isTemporaryFieldValue = defaultValues.isTemporary; + const isExpired = + isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || ""); + + return ( + { + handlePopUpToggle("setTempRole", isOpen); + }} + > + + + + + + + + +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + + 1m, 2h, 3d.{" "} + + More + + + } + > + + + )} + /> +
+ {isTemporaryFieldValue && ( + + )} + {!isTemporaryFieldValue ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; + +const formSchema = z.record( + z.object({ + isChecked: z.boolean().optional(), + temporaryAccess: z.union([ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.boolean() + ]) + }) +); +type TForm = z.infer; + +export type TMemberRolesProp = { + disableEdit?: boolean; + membershipId: string; + onOpenUpgradeModal: (description: string) => void; + roles: TWorkspaceUser["roles"]; +}; + +const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; + +export const MemberRoles = ({ + roles = [], + disableEdit = false, + membershipId, + onOpenUpgradeModal +}: TMemberRolesProp) => { + const { currentWorkspace } = useWorkspace(); + const { createNotification } = useNotificationContext(); + const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const); + const [searchRoles, setSearchRoles] = useState(""); + const { subscription } = useSubscription(); + + const { + handleSubmit, + control, + reset, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(formSchema) + }); + + const workspaceId = currentWorkspace?.id || ""; + + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role); + + const updateMembershipRole = useUpdateUserWorkspaceRole(); + + const handleRoleUpdate = async (data: TForm) => { + const selectedRoles = Object.keys(data) + .filter((el) => Boolean(data[el].isChecked)) + .map((el) => { + const isTemporary = Boolean(data[el].temporaryAccess); + if (!isTemporary) { + return { role: el, isTemporary: false as const }; + } + + const tempCfg = data[el].temporaryAccess as { + temporaryRange: string; + temporaryAccessStartTime: string; + }; + + return { + role: el, + isTemporary: true as const, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: tempCfg.temporaryRange, + temporaryAccessStartTime: tempCfg.temporaryAccessStartTime + }; + }); + + const hasCustomRoleSelected = selectedRoles.some( + (el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole) + ); + + if (hasCustomRoleSelected && subscription && !subscription?.rbac) { + onOpenUpgradeModal( + "You can assign custom roles to members if you upgrade your Infisical plan." + ); + return; + } + + try { + await updateMembershipRole.mutateAsync({ + workspaceId, + membershipId, + roles: selectedRoles + }); + createNotification({ text: "Successfully updated role", type: "success" }); + handlePopUpToggle("editRole"); + setSearchRoles(""); + } catch (err) { + createNotification({ text: "Failed to update role", type: "error" }); + } + }; + + const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Member) return "Developer"; + return role; + }; + + return ( +
+ {roles + .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + +
+ )} +
+
+ ); + })} + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {roles + .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + new Date(temporaryAccessEndTime as string) && + "text-red-600" + )} + /> + +
+ )} +
+
+ ); + })}{" "} +
+
+ )} +
+ { + handlePopUpToggle("editRole", isOpen); + reset(); + }} + > + {!disableEdit && ( + + + + + + )} + + {isRolesLoading ? ( +
+ +
+ ) : ( +
+
+ {projectRoles + ?.filter( + ({ name, slug }) => + name.toLowerCase().includes(searchRoles.toLowerCase()) || + slug.toLowerCase().includes(searchRoles.toLowerCase()) + ) + ?.map(({ id, name, slug }) => { + const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0]; + + return ( +
+
+ ( + { + field.onChange(isChecked); + setValue(`${slug}.temporaryAccess`, false); + }} + > + {name} + + )} + /> +
+
+ ( + { + setValue(`${slug}.isChecked`, true, { shouldDirty: true }); + console.log(data); + field.onChange({ isTemporary: true, ...data }); + }} + onRemoveTemporary={() => { + setValue(`${slug}.isChecked`, false, { shouldDirty: true }); + field.onChange(false); + }} + /> + )} + /> +
+
+ ); + })} +
+
+
+ setSearchRoles(el.target.value)} + leftIcon={} + placeholder="Search roles.." + /> +
+
+ +
+
+
+ )} +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx index 4ef08696c..f9ff5761f 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx @@ -78,18 +78,26 @@ export const ProjectNameChangeSection = () => {
-
- ( - - - + + {(isAllowed) => ( + ( + + + + )} + control={control} + name="name" + /> )} - control={control} - name="name" - /> +
{(isAllowed) => (