diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 5b1b3770e..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, @@ -280,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/20240308154949_temp-roles.ts b/backend/src/db/migrations/20240308154949_temp-roles.ts index e37dc8f4b..462aec229 100644 --- a/backend/src/db/migrations/20240308154949_temp-roles.ts +++ b/backend/src/db/migrations/20240308154949_temp-roles.ts @@ -21,37 +21,37 @@ export async function up(knex: Knex): Promise { t.datetime("temporaryAccessEndTime"); t.timestamps(true, true, true); }); - - await createOnUpdateTrigger(knex, TableName.ProjectUserMembershipRole); - - const projectMembershipStream = knex.select("*").from(TableName.ProjectMembership).stream(); - const chunkSize = 1000; - let rows: TProjectUserMembershipRolesInsert[] = []; - for await (const row of projectMembershipStream) { - // disabling eslint just this part because the latest ts type doesn't have these values after migration as they are removed - /* eslint-disable */ - // @ts-ignore - created at is inserted from old data - rows = rows.concat({ - // @ts-ignore - missing in ts type post migration - role: row.role, - // @ts-ignore - missing in ts type post migration - customRoleId: row.roleId, - projectMembershipId: row.id, - createdAt: row.createdAt, - updatedAt: row.updatedAt - }); - /* eslint-disable */ - if (rows.length >= chunkSize) { - await knex(TableName.ProjectUserMembershipRole).insert(rows); - rows.splice(0, rows.length); - } - } - await knex(TableName.ProjectUserMembershipRole).insert(rows); - await knex.schema.alterTable(TableName.ProjectMembership, (t) => { - t.dropColumn("roleId"); - t.dropColumn("role"); - }); } + + await createOnUpdateTrigger(knex, TableName.ProjectUserMembershipRole); + + const projectMembershipStream = knex.select("*").from(TableName.ProjectMembership).stream(); + const chunkSize = 1000; + let rows: TProjectUserMembershipRolesInsert[] = []; + for await (const row of projectMembershipStream) { + // disabling eslint just this part because the latest ts type doesn't have these values after migration as they are removed + /* eslint-disable */ + // @ts-ignore - created at is inserted from old data + rows = rows.concat({ + // @ts-ignore - missing in ts type post migration + role: row.role, + // @ts-ignore - missing in ts type post migration + customRoleId: row.roleId, + projectMembershipId: row.id, + createdAt: row.createdAt, + updatedAt: row.updatedAt + }); + /* eslint-disable */ + if (rows.length >= chunkSize) { + await knex(TableName.ProjectUserMembershipRole).insert(rows); + rows.splice(0, rows.length); + } + } + if (rows.length) await knex(TableName.ProjectUserMembershipRole).insert(rows); + await knex.schema.alterTable(TableName.ProjectMembership, (t) => { + t.dropColumn("roleId"); + t.dropColumn("role"); + }); } export async function down(knex: Knex): Promise { diff --git a/backend/src/db/migrations/20240311093939_temp-role-identity.ts b/backend/src/db/migrations/20240311093939_temp-role-identity.ts new file mode 100644 index 000000000..b788bf259 --- /dev/null +++ b/backend/src/db/migrations/20240311093939_temp-role-identity.ts @@ -0,0 +1,80 @@ +import { Knex } from "knex"; + +import { TableName, TIdentityProjectMembershipRoleInsert } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + 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 { + const projectIdentityMembershipRoleStream = knex.select("*").from(TableName.IdentityProjectMembershipRole).stream(); + await knex.schema.alterTable(TableName.IdentityProjectMembership, (t) => { + t.string("role"); + t.uuid("roleId"); + t.foreign("roleId").references("id").inTable(TableName.ProjectRoles); + }); + for await (const row of projectIdentityMembershipRoleStream) { + await knex(TableName.IdentityProjectMembership).where({ id: row.projectMembershipId }).update({ + // @ts-ignore - since the latest one doesn't have roleId anymore there will be type error here + roleId: row.customRoleId, + role: row.role + }); + } + await knex.schema.alterTable(TableName.IdentityProjectMembership, (t) => { + t.string("role").notNullable().alter({ alterNullable: true }); + }); + + await knex.schema.dropTableIfExists(TableName.IdentityProjectMembershipRole); + await dropOnUpdateTrigger(knex, TableName.IdentityProjectMembershipRole); +} 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/identity-project-memberships.ts b/backend/src/db/schemas/identity-project-memberships.ts index 276c9581e..2f17c36d8 100644 --- a/backend/src/db/schemas/identity-project-memberships.ts +++ b/backend/src/db/schemas/identity-project-memberships.ts @@ -9,8 +9,6 @@ import { TImmutableDBKeys } from "./models"; export const IdentityProjectMembershipsSchema = z.object({ id: z.string().uuid(), - role: z.string(), - roleId: z.string().uuid().nullable().optional(), projectId: z.string(), identityId: z.string().uuid(), createdAt: z.date(), diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index f39d54ed3..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"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 7a14695fa..f85feff9c 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -42,6 +42,7 @@ export enum TableName { IdentityUaClientSecret = "identity_ua_client_secrets", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", + IdentityProjectMembershipRole = "identity_project_membership_role", ScimToken = "scim_tokens", SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicyApprover = "secret_approval_policies_approvers", diff --git a/backend/src/db/seeds/4-machine-identity.ts b/backend/src/db/seeds/4-machine-identity.ts index 149fba40a..662232e02 100644 --- a/backend/src/db/seeds/4-machine-identity.ts +++ b/backend/src/db/seeds/4-machine-identity.ts @@ -75,9 +75,15 @@ 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 + }) + .returning("*"); + + await knex(TableName.IdentityProjectMembershipRole).insert({ role: ProjectMembershipRole.Admin, - projectId: seedData1.project.id + projectMembershipId: identityProjectMembership[0].id }); } diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 0f0409ca2..8dca96737 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -18,7 +18,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ secretVersioning: true, pitRecovery: false, ipAllowlisting: false, - rbac: true, + rbac: false, customRateLimits: false, customAlerts: false, auditLogs: false, diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 1b01c30de..3cce464b0 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { TDbClient } from "@app/db"; -import { ProjectUserMembershipRolesSchema, TableName } from "@app/db/schemas"; +import { IdentityProjectMembershipRoleSchema, ProjectUserMembershipRolesSchema, TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; @@ -108,18 +108,60 @@ export const permissionDALFactory = (db: TDbClient) => { const getProjectIdentityPermission = async (identityId: string, projectId: string) => { try { - const membership = await db(TableName.IdentityProjectMembership) + const docs = await db(TableName.IdentityProjectMembership) + .join( + TableName.IdentityProjectMembershipRole, + `${TableName.IdentityProjectMembershipRole}.projectMembershipId`, + `${TableName.IdentityProjectMembership}.id` + ) .leftJoin( TableName.ProjectRoles, - `${TableName.IdentityProjectMembership}.roleId`, + `${TableName.IdentityProjectMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) .where("identityId", identityId) .where(`${TableName.IdentityProjectMembership}.projectId`, projectId) - .select(selectAllTableCols(TableName.IdentityProjectMembership)) - .select("permissions") - .first(); - return membership; + .select(selectAllTableCols(TableName.IdentityProjectMembershipRole)) + .select( + db.ref("id").withSchema(TableName.IdentityProjectMembership).as("membershipId"), + db.ref("createdAt").withSchema(TableName.IdentityProjectMembership).as("membershipCreatedAt"), + db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership).as("membershipUpdatedAt"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug") + ) + .select("permissions"); + + const permission = sqlNestRelationships({ + data: docs, + key: "membershipId", + parentMapper: ({ membershipId, membershipCreatedAt, membershipUpdatedAt }) => ({ + id: membershipId, + identityId, + projectId, + createdAt: membershipCreatedAt, + updatedAt: membershipUpdatedAt, + // just a prefilled value + orgAuthEnforced: false, + orgId: "" + }), + childrenMapper: [ + { + key: "id", + label: "roles" as const, + mapper: (data) => + IdentityProjectMembershipRoleSchema.extend({ + permissions: z.unknown(), + customRoleSlug: z.string().optional().nullable() + }).parse(data) + } + ] + }); + + // when introducting cron mode change it here + const activeRoles = permission?.[0]?.roles.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ); + return permission?.[0] ? { ...permission[0], roles: activeRoles } : undefined; } catch (error) { throw new DatabaseError({ error, name: "GetProjectIdentityPermission" }); } diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 02adc5069..c7dcf4b8c 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -67,7 +67,7 @@ export const permissionServiceFactory = ({ const buildProjectPermission = (projectUserRoles: TBuildProjectPermissionDTO) => { const rules = projectUserRoles - .map(({ role, permission }) => { + .map(({ role, permissions }) => { switch (role) { case ProjectMembershipRole.Admin: return projectAdminPermissions; @@ -77,10 +77,11 @@ export const permissionServiceFactory = ({ return projectViewerPermission; case ProjectMembershipRole.NoAccess: return projectNoAccessPermissions; - case ProjectMembershipRole.Custom: + case ProjectMembershipRole.Custom: { return unpackRules>>( - permission as PackRule>>[] + permissions as PackRule>>[] ); + } default: throw new BadRequestError({ name: "ProjectRoleInvalid", @@ -149,7 +150,11 @@ export const permissionServiceFactory = ({ }; // user permission for a project in an organization - const getUserProjectPermission = async (userId: string, projectId: string, userOrgId?: string) => { + const getUserProjectPermission = async ( + userId: string, + projectId: string, + userOrgId?: string + ): Promise> => { const userProjectPermission = await permissionDAL.getProjectPermission(userId, projectId); if (!userProjectPermission) throw new UnauthorizedError({ name: "User not in project" }); @@ -173,16 +178,28 @@ export const permissionServiceFactory = ({ }; }; - const getIdentityProjectPermission = async (identityId: string, projectId: string) => { - const membership = await permissionDAL.getProjectIdentityPermission(identityId, projectId); - if (!membership) throw new UnauthorizedError({ name: "Identity not in project" }); - if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) { + const getIdentityProjectPermission = async ( + identityId: string, + projectId: string + ): Promise> => { + const identityProjectPermission = await permissionDAL.getProjectIdentityPermission(identityId, projectId); + if (!identityProjectPermission) throw new UnauthorizedError({ name: "Identity not in project" }); + + if ( + identityProjectPermission.roles.some( + ({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions + ) + ) { throw new BadRequestError({ name: "Custom permission not found" }); } return { - permission: buildProjectPermission([{ role: membership.role, permission: membership.permissions }]), - membership + permission: buildProjectPermission(identityProjectPermission.roles), + membership: identityProjectPermission, + hasRole: (role: string) => + identityProjectPermission.roles.findIndex( + ({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug + ) !== -1 }; }; @@ -202,7 +219,11 @@ export const permissionServiceFactory = ({ }; type TProjectPermissionRT = T extends ActorType.SERVICE - ? { permission: MongoAbility; membership: undefined; hasRole: () => false } // service token doesn't have both membership and roles + ? { + 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) & { @@ -241,12 +262,12 @@ export const permissionServiceFactory = ({ if (!projectRole) throw new BadRequestError({ message: "Role not found" }); return { permission: buildProjectPermission([ - { role: ProjectMembershipRole.Custom, permission: projectRole.permissions } + { role: ProjectMembershipRole.Custom, permissions: projectRole.permissions } ]), role: projectRole }; } - return { permission: buildProjectPermission([{ role, permission: [] }]) }; + return { permission: buildProjectPermission([{ role, permissions: [] }]) }; }; return { diff --git a/backend/src/ee/services/permission/permission-types.ts b/backend/src/ee/services/permission/permission-types.ts index ea3d5f1fc..a35958ffd 100644 --- a/backend/src/ee/services/permission/permission-types.ts +++ b/backend/src/ee/services/permission/permission-types.ts @@ -1,4 +1,4 @@ export type TBuildProjectPermissionDTO = { - permission?: unknown; + permissions?: unknown; role: string; }[]; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 1a9d2ed37..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"; @@ -166,6 +167,7 @@ export const registerRoutes = async ( const identityAccessTokenDAL = identityAccessTokenDALFactory(db); const identityOrgMembershipDAL = identityOrgDALFactory(db); const identityProjectDAL = identityProjectDALFactory(db); + const identityProjectMembershipRoleDAL = identityProjectMembershipRoleDALFactory(db); const identityUaDAL = identityUaDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); @@ -374,8 +376,10 @@ export const registerRoutes = async ( projectMembershipDAL, folderDAL, licenseService, - projectUserMembershipRoleDAL + projectUserMembershipRoleDAL, + identityProjectMembershipRoleDAL }); + const projectEnvService = projectEnvServiceFactory({ permissionService, projectEnvDAL, @@ -526,7 +530,9 @@ export const registerRoutes = async ( permissionService, projectDAL, identityProjectDAL, - identityOrgMembershipDAL + identityOrgMembershipDAL, + identityProjectMembershipRoleDAL, + projectRoleDAL }); const identityUaService = identityUaServiceFactory({ identityOrgMembershipDAL, diff --git a/backend/src/server/routes/v1/project-membership-router.ts b/backend/src/server/routes/v1/project-membership-router.ts index 80d1a11bd..1c7bd8770 100644 --- a/backend/src/server/routes/v1/project-membership-router.ts +++ b/backend/src/server/routes/v1/project-membership-router.ts @@ -136,21 +136,24 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider membershipId: z.string().trim() }), body: z.object({ - roles: z.array( - z.union([ - z.object({ - role: z.string(), - isTemporary: z.literal(false).default(false) - }), - z.object({ - role: z.string(), - isTemporary: z.literal(true), - temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), - temporaryRange: z.string(), - temporaryAccessStartTime: z.string().datetime() - }) - ]) - ) + roles: z + .array( + z.union([ + z.object({ + role: z.string(), + isTemporary: z.literal(false).default(false) + }), + z.object({ + role: z.string(), + isTemporary: z.literal(true), + temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryRange: z.string(), + temporaryAccessStartTime: z.string().datetime() + }) + ]) + ) + .min(1) + .refine((data) => data.some(({ isTemporary }) => !isTemporary), "Atleast one permanent role required") }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v2/identity-project-router.ts b/backend/src/server/routes/v2/identity-project-router.ts index fdd810b4b..460a48aa3 100644 --- a/backend/src/server/routes/v2/identity-project-router.ts +++ b/backend/src/server/routes/v2/identity-project-router.ts @@ -4,10 +4,11 @@ import { IdentitiesSchema, IdentityProjectMembershipsSchema, ProjectMembershipRole, - ProjectRolesSchema + ProjectUserMembershipRolesSchema } from "@app/db/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; export const registerIdentityProjectRouter = async (server: FastifyZodProvider) => { server.route({ @@ -57,24 +58,41 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) identityId: z.string().trim() }), body: z.object({ - role: z.string().trim().min(1).default(ProjectMembershipRole.NoAccess) + roles: z + .array( + z.union([ + z.object({ + role: z.string(), + isTemporary: z.literal(false).default(false) + }), + z.object({ + role: z.string(), + isTemporary: z.literal(true), + temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryRange: z.string(), + temporaryAccessStartTime: z.string().datetime() + }) + ]) + ) + .min(1) + .refine((data) => data.some(({ isTemporary }) => !isTemporary), "Atleast one permanent role required") }), response: { 200: z.object({ - identityMembership: IdentityProjectMembershipsSchema + roles: ProjectUserMembershipRolesSchema.array() }) } }, handler: async (req) => { - const identityMembership = await server.services.identityProject.updateProjectIdentity({ + const roles = await server.services.identityProject.updateProjectIdentity({ actor: req.permission.type, actorId: req.permission.id, actorOrgId: req.permission.orgId, identityId: req.params.identityId, projectId: req.params.projectId, - role: req.body.role + roles: req.body.roles }); - return { identityMembership }; + return { roles }; } }); @@ -127,18 +145,29 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) }), response: { 200: z.object({ - identityMemberships: IdentityProjectMembershipsSchema.merge( - z.object({ - customRole: ProjectRolesSchema.pick({ - id: true, - name: true, - slug: true, - permissions: true, - description: true - }).optional(), + identityMemberships: z + .object({ + id: z.string(), + identityId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }) }) - ).array() + .array() }) } }, 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..6b751b4fa 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,24 @@ export const identityProjectServiceFactory = ({ }); const isCustomRole = Boolean(customRole); - const projectIdentity = await identityProjectDAL.create({ - identityId, - projectId: project.id, - role: isCustomRole ? ProjectMembershipRole.Custom : role, - roleId: customRole?.id + const projectIdentity = await identityProjectDAL.transaction(async (tx) => { + const identityProjectMembership = await identityProjectDAL.create( + { + identityId, + projectId: project.id + }, + tx + ); + + await identityProjectMembershipRoleDAL.create( + { + projectMembershipId: identityProjectMembership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : role, + customRoleId: customRole?.id + }, + tx + ); + return identityProjectMembership; }); return projectIdentity; }; @@ -82,7 +107,7 @@ export const identityProjectServiceFactory = ({ const updateProjectIdentity = async ({ projectId, identityId, - role, + roles, actor, actorId, actorOrgId @@ -106,28 +131,54 @@ export const identityProjectServiceFactory = ({ if (!hasRequiredPriviledges) throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); - let customRole: TProjectRoles | undefined; - if (role) { - const { permission: rolePermission, role: customOrgRole } = await permissionService.getProjectPermissionByRole( - role, - projectIdentity.projectId - ); - - const isCustomRole = Boolean(customOrgRole); - const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasRequiredNewRolePermission) - throw new BadRequestError({ message: "Failed to create a more privileged identity" }); - if (isCustomRole) customRole = customOrgRole; - } - - const [updatedProjectIdentity] = await identityProjectDAL.update( - { projectId, identityId: projectIdentity.identityId }, - { - role: customRole ? ProjectMembershipRole.Custom : role, - roleId: customRole ? customRole.id : null - } + // validate custom roles input + const customInputRoles = roles.filter( + ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) ); - return updatedProjectIdentity; + const hasCustomRole = Boolean(customInputRoles.length); + const customRoles = hasCustomRole + ? await projectRoleDAL.find({ + projectId, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" }); + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const santiziedProjectMembershipRoles = roles.map((inputRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); + if (!inputRole.isTemporary) { + return { + projectMembershipId: projectIdentity.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null + }; + } + + // check cron or relative here later for now its just relative + const relativeTimeInMs = ms(inputRole.temporaryRange); + if (relativeTimeInMs <= 0) { + throw new BadRequestError({ message: "Temporary relative time range must be positive" }); + } + return { + projectMembershipId: projectIdentity.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, + isTemporary: true, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: inputRole.temporaryRange, + temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) + }; + }); + + const updatedRoles = await identityProjectMembershipRoleDAL.transaction(async (tx) => { + await identityProjectMembershipRoleDAL.delete({ projectMembershipId: projectIdentity.id }, tx); + return identityProjectMembershipRoleDAL.insertMany(santiziedProjectMembershipRoles, tx); + }); + + return updatedRoles; }; const deleteProjectIdentity = async ({ 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/project/project-service.ts b/backend/src/services/project/project-service.ts index f5409b6f3..76b3cba72 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -17,6 +17,7 @@ import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal"; +import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; import { TOrgServiceFactory } from "../org/org-service"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; @@ -51,6 +52,7 @@ type TProjectServiceFactoryDep = { projectEnvDAL: Pick; identityOrgMembershipDAL: TIdentityOrgDALFactory; identityProjectDAL: TIdentityProjectDALFactory; + identityProjectMembershipRoleDAL: Pick; projectKeyDAL: Pick; projectBotDAL: Pick; projectMembershipDAL: Pick; @@ -78,7 +80,8 @@ export const projectServiceFactory = ({ projectMembershipDAL, projectEnvDAL, licenseService, - projectUserMembershipRoleDAL + projectUserMembershipRoleDAL, + identityProjectMembershipRoleDAL }: TProjectServiceFactoryDep) => { /* * Create workspace. Make user the admin @@ -275,12 +278,19 @@ export const projectServiceFactory = ({ }); const isCustomRole = Boolean(customRole); - await identityProjectDAL.create( + const identityProjectMembership = await identityProjectDAL.create( { identityId: actorId, - projectId: project.id, + projectId: project.id + }, + tx + ); + + await identityProjectMembershipRoleDAL.create( + { + projectMembershipId: identityProjectMembership.id, role: isCustomRole ? ProjectMembershipRole.Custom : ProjectMembershipRole.Admin, - roleId: customRole?.id + customRoleId: customRole?.id }, tx );