diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 9ed72e782..0a9aff8f0 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -5,9 +5,11 @@ import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-se import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; +import { TIdentityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { TProjectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; import { TScimServiceFactory } from "@app/ee/services/scim/scim-service"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; @@ -121,6 +123,8 @@ declare module "fastify" { telemetry: TTelemetryServiceFactory; dynamicSecret: TDynamicSecretServiceFactory; dynamicSecretLease: TDynamicSecretLeaseServiceFactory; + projectUserAdditionalPrivilege: TProjectUserAdditionalPrivilegeServiceFactory; + identityProjectAdditionalPrivilege: TIdentityProjectAdditionalPrivilegeServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 29d526e52..7c14eb781 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -38,6 +38,9 @@ import { TIdentityOrgMemberships, TIdentityOrgMembershipsInsert, TIdentityOrgMembershipsUpdate, + TIdentityProjectAdditionalPrivilege, + TIdentityProjectAdditionalPrivilegeInsert, + TIdentityProjectAdditionalPrivilegeUpdate, TIdentityProjectMembershipRole, TIdentityProjectMembershipRoleInsert, TIdentityProjectMembershipRoleUpdate, @@ -92,6 +95,9 @@ import { TProjects, TProjectsInsert, TProjectsUpdate, + TProjectUserAdditionalPrivilege, + TProjectUserAdditionalPrivilegeInsert, + TProjectUserAdditionalPrivilegeUpdate, TProjectUserMembershipRoles, TProjectUserMembershipRolesInsert, TProjectUserMembershipRolesUpdate, @@ -239,6 +245,11 @@ declare module "knex/types/tables" { TProjectUserMembershipRolesUpdate >; [TableName.ProjectRoles]: Knex.CompositeTableType; + [TableName.ProjectUserAdditionalPrivilege]: Knex.CompositeTableType< + TProjectUserAdditionalPrivilege, + TProjectUserAdditionalPrivilegeInsert, + TProjectUserAdditionalPrivilegeUpdate + >; [TableName.ProjectKeys]: Knex.CompositeTableType; [TableName.Secret]: Knex.CompositeTableType; [TableName.SecretBlindIndex]: Knex.CompositeTableType< @@ -294,6 +305,11 @@ declare module "knex/types/tables" { TIdentityProjectMembershipRoleInsert, TIdentityProjectMembershipRoleUpdate >; + [TableName.IdentityProjectAdditionalPrivilege]: Knex.CompositeTableType< + TIdentityProjectAdditionalPrivilege, + TIdentityProjectAdditionalPrivilegeInsert, + TIdentityProjectAdditionalPrivilegeUpdate + >; [TableName.ScimToken]: Knex.CompositeTableType; [TableName.SecretApprovalPolicy]: Knex.CompositeTableType< TSecretApprovalPolicies, diff --git a/backend/src/db/migrations/20240326172010_project-user-additional-privilege.ts b/backend/src/db/migrations/20240326172010_project-user-additional-privilege.ts new file mode 100644 index 000000000..0366ba507 --- /dev/null +++ b/backend/src/db/migrations/20240326172010_project-user-additional-privilege.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.ProjectUserAdditionalPrivilege))) { + await knex.schema.createTable(TableName.ProjectUserAdditionalPrivilege, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("slug", 60).notNullable(); + t.uuid("projectMembershipId").notNullable(); + t.foreign("projectMembershipId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + 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.jsonb("permissions").notNullable(); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.ProjectUserAdditionalPrivilege); +} + +export async function down(knex: Knex): Promise { + await dropOnUpdateTrigger(knex, TableName.ProjectUserAdditionalPrivilege); + await knex.schema.dropTableIfExists(TableName.ProjectUserAdditionalPrivilege); +} diff --git a/backend/src/db/migrations/20240326172011_machine-identity-additional-privilege.ts b/backend/src/db/migrations/20240326172011_machine-identity-additional-privilege.ts new file mode 100644 index 000000000..c59fc685a --- /dev/null +++ b/backend/src/db/migrations/20240326172011_machine-identity-additional-privilege.ts @@ -0,0 +1,32 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityProjectAdditionalPrivilege))) { + await knex.schema.createTable(TableName.IdentityProjectAdditionalPrivilege, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("slug", 60).notNullable(); + t.uuid("projectMembershipId").notNullable(); + t.foreign("projectMembershipId") + .references("id") + .inTable(TableName.IdentityProjectMembership) + .onDelete("CASCADE"); + 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.jsonb("permissions").notNullable(); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityProjectAdditionalPrivilege); +} + +export async function down(knex: Knex): Promise { + await dropOnUpdateTrigger(knex, TableName.IdentityProjectAdditionalPrivilege); + await knex.schema.dropTableIfExists(TableName.IdentityProjectAdditionalPrivilege); +} diff --git a/backend/src/db/schemas/identity-project-additional-privilege.ts b/backend/src/db/schemas/identity-project-additional-privilege.ts new file mode 100644 index 000000000..7a9dbe19e --- /dev/null +++ b/backend/src/db/schemas/identity-project-additional-privilege.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 IdentityProjectAdditionalPrivilegeSchema = z.object({ + id: z.string().uuid(), + slug: z.string(), + projectMembershipId: z.string().uuid(), + 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(), + permissions: z.unknown(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityProjectAdditionalPrivilege = z.infer; +export type TIdentityProjectAdditionalPrivilegeInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TIdentityProjectAdditionalPrivilegeUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index efa80cfac..361ae2e0d 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -10,6 +10,7 @@ export * from "./git-app-org"; export * from "./identities"; export * from "./identity-access-tokens"; export * from "./identity-org-memberships"; +export * from "./identity-project-additional-privilege"; export * from "./identity-project-membership-role"; export * from "./identity-project-memberships"; export * from "./identity-ua-client-secrets"; @@ -28,6 +29,7 @@ export * from "./project-environments"; export * from "./project-keys"; export * from "./project-memberships"; export * from "./project-roles"; +export * from "./project-user-additional-privilege"; export * from "./project-user-membership-roles"; export * from "./projects"; export * from "./saml-configs"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index bda66682e..3cb11d694 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", + ProjectUserAdditionalPrivilege = "project_user_additional_privilege", ProjectUserMembershipRole = "project_user_membership_roles", ProjectKeys = "project_keys", Secret = "secrets", @@ -43,6 +44,7 @@ export enum TableName { IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", + IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege", ScimToken = "scim_tokens", SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicyApprover = "secret_approval_policies_approvers", diff --git a/backend/src/db/schemas/project-user-additional-privilege.ts b/backend/src/db/schemas/project-user-additional-privilege.ts new file mode 100644 index 000000000..0fd0e5faa --- /dev/null +++ b/backend/src/db/schemas/project-user-additional-privilege.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 ProjectUserAdditionalPrivilegeSchema = z.object({ + id: z.string().uuid(), + slug: z.string(), + projectMembershipId: z.string().uuid(), + 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(), + permissions: z.unknown(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TProjectUserAdditionalPrivilege = z.infer; +export type TProjectUserAdditionalPrivilegeInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TProjectUserAdditionalPrivilegeUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts new file mode 100644 index 000000000..4863a506e --- /dev/null +++ b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts @@ -0,0 +1,272 @@ +import { MongoAbility, RawRuleOf } from "@casl/ability"; +import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; +import slugify from "@sindresorhus/slugify"; +import ms from "ms"; +import { z } from "zod"; + +import { IdentityProjectAdditionalPrivilegeSchema } from "@app/db/schemas"; +import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types"; +import { ProjectPermissionSet } from "@app/ee/services/permission/project-permission"; +import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/permanent", + method: "POST", + schema: { + body: z.object({ + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), + slug: z + .string() + .min(1) + .max(60) + .trim() + .default(slugify(alphaNumericNanoId(12))) + .refine((val) => val.toLowerCase() === val, "Must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), + permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + }), + response: { + 200: z.object({ + privilege: IdentityProjectAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const privilege = await server.services.identityProjectAdditionalPrivilege.create({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + isTemporary: false, + permissions: JSON.stringify(packRules(req.body.permissions)) + }); + return { privilege }; + } + }); + + server.route({ + url: "/temporary", + method: "POST", + schema: { + body: z.object({ + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), + slug: z + .string() + .min(1) + .max(60) + .trim() + .default(slugify(alphaNumericNanoId(12))) + .refine((val) => val.toLowerCase() === val, "Must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), + permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions), + temporaryMode: z + .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryAccessStartTime) + }), + response: { + 200: z.object({ + privilege: IdentityProjectAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const privilege = await server.services.identityProjectAdditionalPrivilege.create({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + isTemporary: true, + permissions: JSON.stringify(packRules(req.body.permissions)) + }); + return { privilege }; + } + }); + + server.route({ + url: "/", + method: "PATCH", + schema: { + body: z.object({ + // disallow empty string + privilegeSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.slug), + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.projectSlug), + privilegeDetails: z + .object({ + slug: z + .string() + .min(1) + .max(60) + .trim() + .refine((val) => val.toLowerCase() === val, "Must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), + permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions), + isTemporary: z.boolean().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary), + temporaryMode: z + .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryAccessStartTime) + }) + .partial() + }), + response: { + 200: z.object({ + privilege: IdentityProjectAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const updatedInfo = req.body.privilegeDetails; + const privilege = await server.services.identityProjectAdditionalPrivilege.updateBySlug({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + slug: req.body.privilegeSlug, + identityId: req.body.identityId, + projectSlug: req.body.projectSlug, + data: { + ...updatedInfo, + permissions: updatedInfo?.permissions ? JSON.stringify(packRules(updatedInfo.permissions)) : undefined + } + }); + return { privilege }; + } + }); + + server.route({ + url: "/", + method: "DELETE", + schema: { + body: z.object({ + privilegeSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.slug), + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.projectSlug) + }), + response: { + 200: z.object({ + privilege: IdentityProjectAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const privilege = await server.services.identityProjectAdditionalPrivilege.deleteBySlug({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + slug: req.body.privilegeSlug, + identityId: req.body.identityId, + projectSlug: req.body.projectSlug + }); + return { privilege }; + } + }); + + server.route({ + url: "/:privilegeSlug", + method: "GET", + schema: { + params: z.object({ + privilegeSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.slug) + }), + querystring: z.object({ + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.projectSlug) + }), + response: { + 200: z.object({ + privilege: IdentityProjectAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const privilege = await server.services.identityProjectAdditionalPrivilege.getPrivilegeDetailsBySlug({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + slug: req.params.privilegeSlug, + ...req.query + }); + return { privilege }; + } + }); + + server.route({ + url: "/", + method: "GET", + schema: { + querystring: z.object({ + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.projectSlug), + unpacked: z + .enum(["false", "true"]) + .transform((el) => el === "true") + .default("true") + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.unpacked) + }), + response: { + 200: z.object({ + privileges: IdentityProjectAdditionalPrivilegeSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const privileges = await server.services.identityProjectAdditionalPrivilege.listIdentityProjectPrivileges({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + if (req.query.unpacked) { + return { + privileges: privileges.map(({ permissions, ...el }) => ({ + ...el, + permissions: unpackRules(permissions as PackRule>>[]) + })) + }; + } + return { privileges }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index b3fafcfdf..72021c81f 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,5 +1,6 @@ import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerDynamicSecretRouter } from "./dynamic-secret-router"; +import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; import { registerLdapRouter } from "./ldap-router"; import { registerLicenseRouter } from "./license-router"; import { registerOrgRoleRouter } from "./org-role-router"; @@ -15,6 +16,7 @@ import { registerSecretScanningRouter } from "./secret-scanning-router"; import { registerSecretVersionRouter } from "./secret-version-router"; import { registerSnapshotRouter } from "./snapshot-router"; import { registerTrustedIpRouter } from "./trusted-ip-router"; +import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router"; export const registerV1EERoutes = async (server: FastifyZodProvider) => { // org role starts with organization @@ -51,4 +53,11 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" }); await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" }); await server.register(registerSecretVersionRouter, { prefix: "/secret" }); + await server.register( + async (privilegeRouter) => { + await privilegeRouter.register(registerUserAdditionalPrivilegeRouter, { prefix: "/users" }); + await privilegeRouter.register(registerIdentityProjectAdditionalPrivilegeRouter, { prefix: "/identity" }); + }, + { prefix: "/additional-privilege" } + ); }; diff --git a/backend/src/ee/routes/v1/user-additional-privilege-router.ts b/backend/src/ee/routes/v1/user-additional-privilege-router.ts new file mode 100644 index 000000000..9b6bfb6fb --- /dev/null +++ b/backend/src/ee/routes/v1/user-additional-privilege-router.ts @@ -0,0 +1,235 @@ +import slugify from "@sindresorhus/slugify"; +import ms from "ms"; +import { z } from "zod"; + +import { ProjectUserAdditionalPrivilegeSchema } from "@app/db/schemas"; +import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-types"; +import { PROJECT_USER_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/permanent", + method: "POST", + schema: { + body: z.object({ + projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId), + slug: z + .string() + .min(1) + .max(60) + .trim() + .default(slugify(alphaNumericNanoId(12))) + .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), + permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions) + }), + response: { + 200: z.object({ + privilege: ProjectUserAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privilege = await server.services.projectUserAdditionalPrivilege.create({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + isTemporary: false, + permissions: JSON.stringify(req.body.permissions) + }); + return { privilege }; + } + }); + + server.route({ + url: "/temporary", + method: "POST", + schema: { + body: z.object({ + projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId), + slug: z + .string() + .min(1) + .max(60) + .trim() + .default(`privilege-${slugify(alphaNumericNanoId(12))}`) + .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), + permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions), + temporaryMode: z + .nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode) + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryAccessStartTime) + }), + response: { + 200: z.object({ + privilege: ProjectUserAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privilege = await server.services.projectUserAdditionalPrivilege.create({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + isTemporary: true, + permissions: JSON.stringify(req.body.permissions) + }); + return { privilege }; + } + }); + + server.route({ + url: "/:privilegeId", + method: "PATCH", + schema: { + params: z.object({ + privilegeId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.privilegeId) + }), + body: z + .object({ + slug: z + .string() + .max(60) + .trim() + .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug), + permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.permissions), + isTemporary: z.boolean().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary), + temporaryMode: z + .nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode) + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.temporaryAccessStartTime) + }) + .partial(), + response: { + 200: z.object({ + privilege: ProjectUserAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privilege = await server.services.projectUserAdditionalPrivilege.updateById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + permissions: req.body.permissions ? JSON.stringify(req.body.permissions) : undefined, + privilegeId: req.params.privilegeId + }); + return { privilege }; + } + }); + + server.route({ + url: "/:privilegeId", + method: "DELETE", + schema: { + params: z.object({ + privilegeId: z.string().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.DELETE.privilegeId) + }), + response: { + 200: z.object({ + privilege: ProjectUserAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privilege = await server.services.projectUserAdditionalPrivilege.deleteById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + privilegeId: req.params.privilegeId + }); + return { privilege }; + } + }); + + server.route({ + url: "/", + method: "GET", + schema: { + querystring: z.object({ + projectMembershipId: z.string().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.LIST.projectMembershipId) + }), + response: { + 200: z.object({ + privileges: ProjectUserAdditionalPrivilegeSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privileges = await server.services.projectUserAdditionalPrivilege.listPrivileges({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + projectMembershipId: req.query.projectMembershipId + }); + return { privileges }; + } + }); + + server.route({ + url: "/:privilegeId", + method: "GET", + schema: { + params: z.object({ + privilegeId: z.string().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.GET_BY_PRIVILEGEID.privilegeId) + }), + response: { + 200: z.object({ + privilege: ProjectUserAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privilege = await server.services.projectUserAdditionalPrivilege.getPrivilegeDetailsById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + privilegeId: req.params.privilegeId + }); + return { privilege }; + } + }); +}; diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal.ts new file mode 100644 index 000000000..26252f2d1 --- /dev/null +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal.ts @@ -0,0 +1,12 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityProjectAdditionalPrivilegeDALFactory = ReturnType< + typeof identityProjectAdditionalPrivilegeDALFactory +>; + +export const identityProjectAdditionalPrivilegeDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.IdentityProjectAdditionalPrivilege); + return orm; +}; diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts new file mode 100644 index 000000000..81dc11a00 --- /dev/null +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts @@ -0,0 +1,297 @@ +import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; + +import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; + +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { TIdentityProjectAdditionalPrivilegeDALFactory } from "./identity-project-additional-privilege-dal"; +import { + IdentityProjectAdditionalPrivilegeTemporaryMode, + TCreateIdentityPrivilegeDTO, + TDeleteIdentityPrivilegeDTO, + TGetIdentityPrivilegeDetailsDTO, + TListIdentityPrivilegesDTO, + TUpdateIdentityPrivilegeDTO +} from "./identity-project-additional-privilege-types"; + +type TIdentityProjectAdditionalPrivilegeServiceFactoryDep = { + identityProjectAdditionalPrivilegeDAL: TIdentityProjectAdditionalPrivilegeDALFactory; + identityProjectDAL: Pick; + projectDAL: Pick; + permissionService: Pick; +}; + +export type TIdentityProjectAdditionalPrivilegeServiceFactory = ReturnType< + typeof identityProjectAdditionalPrivilegeServiceFactory +>; + +export const identityProjectAdditionalPrivilegeServiceFactory = ({ + identityProjectAdditionalPrivilegeDAL, + identityProjectDAL, + permissionService, + projectDAL +}: TIdentityProjectAdditionalPrivilegeServiceFactoryDep) => { + const create = async ({ + slug, + actor, + actorId, + identityId, + projectSlug, + permissions: customPermission, + actorOrgId, + actorAuthMethod, + ...dto + }: TCreateIdentityPrivilegeDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); + if (!identityProjectMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + const { permission: identityRolePermission } = await permissionService.getProjectPermission( + ActorType.IDENTITY, + identityId, + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + + const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ + slug, + projectMembershipId: identityProjectMembership.id + }); + if (existingSlug) throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); + + if (!dto.isTemporary) { + const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({ + projectMembershipId: identityProjectMembership.id, + slug, + permissions: customPermission + }); + return additionalPrivilege; + } + + const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange); + const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({ + projectMembershipId: identityProjectMembership.id, + slug, + permissions: customPermission, + isTemporary: true, + temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative, + temporaryRange: dto.temporaryRange, + temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs) + }); + return additionalPrivilege; + }; + + const updateBySlug = async ({ + projectSlug, + slug, + identityId, + data, + actorOrgId, + actor, + actorId, + actorAuthMethod + }: TUpdateIdentityPrivilegeDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); + if (!identityProjectMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + const { permission: identityRolePermission } = await permissionService.getProjectPermission( + ActorType.IDENTITY, + identityProjectMembership.identityId, + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + + const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ + slug, + projectMembershipId: identityProjectMembership.id + }); + if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); + if (data?.slug) { + const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ + slug: data.slug, + projectMembershipId: identityProjectMembership.id + }); + if (existingSlug && existingSlug.id !== identityPrivilege.id) + throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); + } + + const isTemporary = typeof data?.isTemporary !== "undefined" ? data.isTemporary : identityPrivilege.isTemporary; + if (isTemporary) { + const temporaryAccessStartTime = data?.temporaryAccessStartTime || identityPrivilege?.temporaryAccessStartTime; + const temporaryRange = data?.temporaryRange || identityPrivilege?.temporaryRange; + const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, { + ...data, + temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""), + temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || "")) + }); + return additionalPrivilege; + } + + const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, { + ...data, + isTemporary: false, + temporaryAccessStartTime: null, + temporaryAccessEndTime: null, + temporaryRange: null, + temporaryMode: null + }); + return additionalPrivilege; + }; + + const deleteBySlug = async ({ + actorId, + slug, + identityId, + projectSlug, + actor, + actorOrgId, + actorAuthMethod + }: TDeleteIdentityPrivilegeDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); + if (!identityProjectMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + const { permission: identityRolePermission } = await permissionService.getProjectPermission( + ActorType.IDENTITY, + identityProjectMembership.identityId, + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to edit more privileged identity" }); + + const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ + slug, + projectMembershipId: identityProjectMembership.id + }); + if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); + + const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id); + return deletedPrivilege; + }; + + const getPrivilegeDetailsBySlug = async ({ + projectSlug, + identityId, + slug, + actorOrgId, + actor, + actorId, + actorAuthMethod + }: TGetIdentityPrivilegeDetailsDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); + if (!identityProjectMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + + const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ + slug, + projectMembershipId: identityProjectMembership.id + }); + if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); + + return identityPrivilege; + }; + + const listIdentityProjectPrivileges = async ({ + identityId, + actorOrgId, + actor, + actorId, + actorAuthMethod, + projectSlug + }: TListIdentityPrivilegesDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); + if (!identityProjectMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + + const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find({ + projectMembershipId: identityProjectMembership.id + }); + return identityPrivileges; + }; + + return { + create, + updateBySlug, + deleteBySlug, + getPrivilegeDetailsBySlug, + listIdentityProjectPrivileges + }; +}; diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts new file mode 100644 index 000000000..88ff01d7d --- /dev/null +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts @@ -0,0 +1,54 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum IdentityProjectAdditionalPrivilegeTemporaryMode { + Relative = "relative" +} + +export type TCreateIdentityPrivilegeDTO = { + permissions: unknown; + identityId: string; + projectSlug: string; + slug: string; +} & ( + | { + isTemporary: false; + } + | { + isTemporary: true; + temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } +) & + Omit; + +export type TUpdateIdentityPrivilegeDTO = { slug: string; identityId: string; projectSlug: string } & Omit< + TProjectPermission, + "projectId" +> & { + data: Partial<{ + permissions: unknown; + slug: string; + isTemporary: boolean; + temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + }>; + }; + +export type TDeleteIdentityPrivilegeDTO = Omit & { + slug: string; + identityId: string; + projectSlug: string; +}; + +export type TGetIdentityPrivilegeDetailsDTO = Omit & { + slug: string; + identityId: string; + projectSlug: string; +}; + +export type TListIdentityPrivilegesDTO = Omit & { + identityId: string; + projectSlug: string; +}; diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index fc0a85ac6..a8072c0d3 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -56,6 +56,11 @@ export const permissionDALFactory = (db: TDbClient) => { `${TableName.ProjectUserMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) + .leftJoin( + TableName.ProjectUserAdditionalPrivilege, + `${TableName.ProjectUserAdditionalPrivilege}.projectMembershipId`, + `${TableName.ProjectMembership}.id` + ) .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) .where("userId", userId) @@ -69,9 +74,22 @@ export const permissionDALFactory = (db: TDbClient) => { db.ref("updatedAt").withSchema(TableName.ProjectMembership).as("membershipUpdatedAt"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("orgId").withSchema(TableName.Project), - db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug") - ) - .select("permissions"); + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("permissions").withSchema(TableName.ProjectRoles), + db.ref("id").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApId"), + db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApPermissions"), + db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userApTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userApTemporaryAccessEndTime") + ); const permission = sqlNestRelationships({ data: docs, @@ -102,15 +120,44 @@ export const permissionDALFactory = (db: TDbClient) => { permissions: z.unknown(), customRoleSlug: z.string().optional().nullable() }).parse(data) + }, + { + key: "userApId", + label: "additionalPrivileges" as const, + mapper: ({ + userApId, + userApPermissions, + userApIsTemporary, + userApTemporaryMode, + userApTemporaryRange, + userApTemporaryAccessEndTime, + userApTemporaryAccessStartTime + }) => ({ + id: userApId, + permissions: userApPermissions, + temporaryRange: userApTemporaryRange, + temporaryMode: userApTemporaryMode, + temporaryAccessEndTime: userApTemporaryAccessEndTime, + temporaryAccessStartTime: userApTemporaryAccessStartTime, + isTemporary: userApIsTemporary + }) } ] }); + + if (!permission?.[0]) return undefined; // when introducting cron mode change it here - const activeRoles = permission?.[0]?.roles.filter( + const activeRoles = permission?.[0]?.roles?.filter( ({ isTemporary, temporaryAccessEndTime }) => !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) ); - return permission?.[0] ? { ...permission[0], roles: activeRoles } : undefined; + + const activeAdditionalPrivileges = permission?.[0]?.additionalPrivileges?.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ); + + return { ...permission[0], roles: activeRoles, additionalPrivileges: activeAdditionalPrivileges }; } catch (error) { throw new DatabaseError({ error, name: "GetProjectPermission" }); } @@ -129,6 +176,11 @@ export const permissionDALFactory = (db: TDbClient) => { `${TableName.IdentityProjectMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) + .leftJoin( + TableName.IdentityProjectAdditionalPrivilege, + `${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId`, + `${TableName.IdentityProjectMembership}.id` + ) .join( // Join the Project table to later select orgId TableName.Project, @@ -144,9 +196,28 @@ export const permissionDALFactory = (db: TDbClient) => { 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"); + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("permissions").withSchema(TableName.ProjectRoles), + db.ref("id").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApId"), + db.ref("permissions").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApPermissions"), + db + .ref("temporaryMode") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApIsTemporary"), + db + .ref("temporaryRange") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryAccessEndTime") + ); const permission = sqlNestRelationships({ data: docs, @@ -171,16 +242,44 @@ export const permissionDALFactory = (db: TDbClient) => { permissions: z.unknown(), customRoleSlug: z.string().optional().nullable() }).parse(data) + }, + { + key: "identityApId", + label: "additionalPrivileges" as const, + mapper: ({ + identityApId, + identityApPermissions, + identityApIsTemporary, + identityApTemporaryMode, + identityApTemporaryRange, + identityApTemporaryAccessEndTime, + identityApTemporaryAccessStartTime + }) => ({ + id: identityApId, + permissions: identityApPermissions, + temporaryRange: identityApTemporaryRange, + temporaryMode: identityApTemporaryMode, + temporaryAccessEndTime: identityApTemporaryAccessEndTime, + temporaryAccessStartTime: identityApTemporaryAccessStartTime, + isTemporary: identityApIsTemporary + }) } ] }); + if (!permission?.[0]) return undefined; + // 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; + const activeAdditionalPrivileges = permission?.[0]?.additionalPrivileges?.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ); + + return { ...permission[0], roles: activeRoles, additionalPrivileges: activeAdditionalPrivileges }; } 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 d632be3df..f4e423797 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -180,10 +180,12 @@ export const permissionServiceFactory = ({ authMethod: ActorAuthMethod, userOrgId?: string ): Promise> => { - const membership = await permissionDAL.getProjectPermission(userId, projectId); - if (!membership) throw new UnauthorizedError({ name: "User not in project" }); + const userProjectPermission = await permissionDAL.getProjectPermission(userId, projectId); + if (!userProjectPermission) throw new UnauthorizedError({ name: "User not in project" }); - if (membership.roles.some(({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions)) { + if ( + userProjectPermission.roles.some(({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions) + ) { throw new BadRequestError({ name: "Custom permission not found" }); } @@ -192,17 +194,27 @@ export const permissionServiceFactory = ({ // Extra: This means that when users are using API keys to make requests, they can't use slug-based routes. // Slug-based routes depend on the organization ID being present on the request, since project slugs aren't globally unique, and we need a way to filter by organization. - if (userOrgId !== "API_KEY" && membership.orgId !== userOrgId) { + if (userOrgId !== "API_KEY" && userProjectPermission.orgId !== userOrgId) { throw new UnauthorizedError({ name: "You are not logged into this organization" }); } - validateOrgSAML(authMethod, membership.orgAuthEnforced); + validateOrgSAML(authMethod, userProjectPermission.orgAuthEnforced); + + // join two permissions and pass to build the final permission set + const rolePermissions = userProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || []; + const additionalPrivileges = + userProjectPermission.additionalPrivileges?.map(({ permissions }) => ({ + role: ProjectMembershipRole.Custom, + permissions + })) || []; return { - permission: buildProjectPermission(membership.roles), - membership, + permission: buildProjectPermission(rolePermissions.concat(additionalPrivileges)), + membership: userProjectPermission, hasRole: (role: string) => - membership.roles.findIndex(({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug) !== -1 + userProjectPermission.roles.findIndex( + ({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug + ) !== -1 }; }; @@ -226,8 +238,16 @@ export const permissionServiceFactory = ({ throw new UnauthorizedError({ name: "You are not a member of this organization" }); } + const rolePermissions = + identityProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || []; + const additionalPrivileges = + identityProjectPermission.additionalPrivileges?.map(({ permissions }) => ({ + role: ProjectMembershipRole.Custom, + permissions + })) || []; + return { - permission: buildProjectPermission(identityProjectPermission.roles), + permission: buildProjectPermission(rolePermissions.concat(additionalPrivileges)), membership: identityProjectPermission, hasRole: (role: string) => identityProjectPermission.roles.findIndex( diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal.ts new file mode 100644 index 000000000..6c15d2d5d --- /dev/null +++ b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-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 TProjectUserAdditionalPrivilegeDALFactory = ReturnType; + +export const projectUserAdditionalPrivilegeDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ProjectUserAdditionalPrivilege); + return orm; +}; diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts new file mode 100644 index 000000000..c9ff2c7e0 --- /dev/null +++ b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts @@ -0,0 +1,212 @@ +import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; + +import { BadRequestError } from "@app/lib/errors"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; + +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { TProjectUserAdditionalPrivilegeDALFactory } from "./project-user-additional-privilege-dal"; +import { + ProjectUserAdditionalPrivilegeTemporaryMode, + TCreateUserPrivilegeDTO, + TDeleteUserPrivilegeDTO, + TGetUserPrivilegeDetailsDTO, + TListUserPrivilegesDTO, + TUpdateUserPrivilegeDTO +} from "./project-user-additional-privilege-types"; + +type TProjectUserAdditionalPrivilegeServiceFactoryDep = { + projectUserAdditionalPrivilegeDAL: TProjectUserAdditionalPrivilegeDALFactory; + projectMembershipDAL: Pick; + permissionService: Pick; +}; + +export type TProjectUserAdditionalPrivilegeServiceFactory = ReturnType< + typeof projectUserAdditionalPrivilegeServiceFactory +>; + +export const projectUserAdditionalPrivilegeServiceFactory = ({ + projectUserAdditionalPrivilegeDAL, + projectMembershipDAL, + permissionService +}: TProjectUserAdditionalPrivilegeServiceFactoryDep) => { + const create = async ({ + slug, + actor, + actorId, + permissions: customPermission, + actorOrgId, + actorAuthMethod, + projectMembershipId, + ...dto + }: TCreateUserPrivilegeDTO) => { + const projectMembership = await projectMembershipDAL.findById(projectMembershipId); + if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + + const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ slug, projectMembershipId }); + if (existingSlug) throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); + + if (!dto.isTemporary) { + const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({ + projectMembershipId, + slug, + permissions: customPermission + }); + return additionalPrivilege; + } + + const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange); + const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({ + projectMembershipId, + slug, + permissions: customPermission, + isTemporary: true, + temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative, + temporaryRange: dto.temporaryRange, + temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs) + }); + return additionalPrivilege; + }; + + const updateById = async ({ + privilegeId, + actorOrgId, + actor, + actorId, + actorAuthMethod, + ...dto + }: TUpdateUserPrivilegeDTO) => { + const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); + if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" }); + + const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId); + if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + + if (dto?.slug) { + const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ + slug: dto.slug, + projectMembershipId: projectMembership.id + }); + if (existingSlug && existingSlug.id !== userPrivilege.id) + throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); + } + + const isTemporary = typeof dto?.isTemporary !== "undefined" ? dto.isTemporary : userPrivilege.isTemporary; + if (isTemporary) { + const temporaryAccessStartTime = dto?.temporaryAccessStartTime || userPrivilege?.temporaryAccessStartTime; + const temporaryRange = dto?.temporaryRange || userPrivilege?.temporaryRange; + const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.updateById(userPrivilege.id, { + ...dto, + temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""), + temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || "")) + }); + return additionalPrivilege; + } + + const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.updateById(userPrivilege.id, { + ...dto, + isTemporary: false, + temporaryAccessStartTime: null, + temporaryAccessEndTime: null, + temporaryRange: null, + temporaryMode: null + }); + return additionalPrivilege; + }; + + const deleteById = async ({ actorId, actor, actorOrgId, actorAuthMethod, privilegeId }: TDeleteUserPrivilegeDTO) => { + const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); + if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" }); + + const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId); + if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + + const deletedPrivilege = await projectUserAdditionalPrivilegeDAL.deleteById(userPrivilege.id); + return deletedPrivilege; + }; + + const getPrivilegeDetailsById = async ({ + privilegeId, + actorOrgId, + actor, + actorId, + actorAuthMethod + }: TGetUserPrivilegeDetailsDTO) => { + const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); + if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" }); + + const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId); + if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + + return userPrivilege; + }; + + const listPrivileges = async ({ + projectMembershipId, + actorOrgId, + actor, + actorId, + actorAuthMethod + }: TListUserPrivilegesDTO) => { + const projectMembership = await projectMembershipDAL.findById(projectMembershipId); + if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + + const userPrivileges = await projectUserAdditionalPrivilegeDAL.find({ projectMembershipId }); + return userPrivileges; + }; + + return { + create, + updateById, + deleteById, + getPrivilegeDetailsById, + listPrivileges + }; +}; diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-types.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-types.ts new file mode 100644 index 000000000..572474270 --- /dev/null +++ b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-types.ts @@ -0,0 +1,40 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum ProjectUserAdditionalPrivilegeTemporaryMode { + Relative = "relative" +} + +export type TCreateUserPrivilegeDTO = ( + | { + permissions: unknown; + projectMembershipId: string; + slug: string; + isTemporary: false; + } + | { + permissions: unknown; + projectMembershipId: string; + slug: string; + isTemporary: true; + temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } +) & + Omit; + +export type TUpdateUserPrivilegeDTO = { privilegeId: string } & Omit & + Partial<{ + permissions: unknown; + slug: string; + isTemporary: boolean; + temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + }>; + +export type TDeleteUserPrivilegeDTO = Omit & { privilegeId: string }; + +export type TGetUserPrivilegeDetailsDTO = Omit & { privilegeId: string }; + +export type TListUserPrivilegesDTO = Omit & { projectMembershipId: string }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 8736e80c6..70ba7e87b 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -397,3 +397,85 @@ export const SECRET_TAGS = { projectId: "The ID of the project to delete the tag from." } } as const; + +export const IDENTITY_ADDITIONAL_PRIVILEGE = { + CREATE: { + projectSlug: "The slug of the project of the identity in.", + identityId: "The ID of the identity to delete.", + slug: "The slug of the privilege to create.", + permissions: + "The permission object for the privilege. Refer https://casl.js.org/v6/en/guide/define-rules#the-shape-of-raw-rule to understand the shape", + isPackPermission: "Whether the server should pack(compact) the permission object.", + isTemporary: "Whether the privilege is temporary.", + temporaryMode: "Type of temporary access given. Types: relative", + temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", + temporaryAccessStartTime: "ISO time for which temporary access should begin." + }, + UPDATE: { + projectSlug: "The slug of the project of the identity in.", + identityId: "The ID of the identity to update.", + slug: "The slug of the privilege to update.", + newSlug: "The new slug of the privilege to update.", + permissions: `The permission object for the privilege. +Example unpacked permission shape +1. [["read", "secrets", {environment: "dev", secretPath: {$glob: "/"}}]] +2. [["read", "secrets", {environment: "dev"}], ["create", "secrets", {environment: "dev"}]] +2. [["read", "secrets", {environment: "dev"}]] +`, + isPackPermission: "Whether the server should pack(compact) the permission object.", + isTemporary: "Whether the privilege is temporary.", + temporaryMode: "Type of temporary access given. Types: relative", + temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", + temporaryAccessStartTime: "ISO time for which temporary access should begin." + }, + DELETE: { + projectSlug: "The slug of the project of the identity in.", + identityId: "The ID of the identity to delete.", + slug: "The slug of the privilege to delete." + }, + GET_BY_SLUG: { + projectSlug: "The slug of the project of the identity in.", + identityId: "The ID of the identity to list.", + slug: "The slug of the privilege." + }, + LIST: { + projectSlug: "The slug of the project of the identity in.", + identityId: "The ID of the identity to list.", + unpacked: "Whether the system should send the permissions as unpacked" + } +}; + +export const PROJECT_USER_ADDITIONAL_PRIVILEGE = { + CREATE: { + projectMembershipId: "Project membership id of user", + slug: "The slug of the privilege to create.", + permissions: + "The permission object for the privilege. Refer https://casl.js.org/v6/en/guide/define-rules#the-shape-of-raw-rule to understand the shape", + isPackPermission: "Whether the server should pack(compact) the permission object.", + isTemporary: "Whether the privilege is temporary.", + temporaryMode: "Type of temporary access given. Types: relative", + temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", + temporaryAccessStartTime: "ISO time for which temporary access should begin." + }, + UPDATE: { + privilegeId: "The id of privilege object", + slug: "The slug of the privilege to create.", + newSlug: "The new slug of the privilege to create.", + permissions: + "The permission object for the privilege. Refer https://casl.js.org/v6/en/guide/define-rules#the-shape-of-raw-rule to understand the shape", + isPackPermission: "Whether the server should pack(compact) the permission object.", + isTemporary: "Whether the privilege is temporary.", + temporaryMode: "Type of temporary access given. Types: relative", + temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", + temporaryAccessStartTime: "ISO time for which temporary access should begin." + }, + DELETE: { + privilegeId: "The id of privilege object" + }, + GET_BY_PRIVILEGEID: { + privilegeId: "The id of privilege object" + }, + LIST: { + projectMembershipId: "Project membership id of user" + } +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 940c414e8..7745a8a04 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -11,12 +11,16 @@ import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/pro import { dynamicSecretLeaseDALFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal"; import { dynamicSecretLeaseQueueServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue"; import { dynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; +import { identityProjectAdditionalPrivilegeDALFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal"; +import { identityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { ldapConfigDALFactory } from "@app/ee/services/ldap-config/ldap-config-dal"; import { ldapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; import { licenseDALFactory } from "@app/ee/services/license/license-dal"; import { licenseServiceFactory } from "@app/ee/services/license/license-service"; import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { projectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; +import { projectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal"; import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; import { scimDALFactory } from "@app/ee/services/scim/scim-dal"; @@ -149,6 +153,7 @@ export const registerRoutes = async ( const projectDAL = projectDALFactory(db); const projectMembershipDAL = projectMembershipDALFactory(db); + const projectUserAdditionalPrivilegeDAL = projectUserAdditionalPrivilegeDALFactory(db); const projectUserMembershipRoleDAL = projectUserMembershipRoleDALFactory(db); const projectRoleDAL = projectRoleDALFactory(db); const projectEnvDAL = projectEnvDALFactory(db); @@ -174,6 +179,7 @@ export const registerRoutes = async ( const identityOrgMembershipDAL = identityOrgDALFactory(db); const identityProjectDAL = identityProjectDALFactory(db); const identityProjectMembershipRoleDAL = identityProjectMembershipRoleDALFactory(db); + const identityProjectAdditionalPrivilegeDAL = identityProjectAdditionalPrivilegeDALFactory(db); const identityUaDAL = identityUaDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); @@ -345,6 +351,11 @@ export const registerRoutes = async ( projectRoleDAL, licenseService }); + const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({ + permissionService, + projectMembershipDAL, + projectUserAdditionalPrivilegeDAL + }); const projectKeyService = projectKeyServiceFactory({ permissionService, projectKeyDAL, @@ -548,6 +559,12 @@ export const registerRoutes = async ( identityProjectMembershipRoleDAL, projectRoleDAL }); + const identityProjectAdditionalPrivilegeService = identityProjectAdditionalPrivilegeServiceFactory({ + projectDAL, + identityProjectAdditionalPrivilegeDAL, + permissionService, + identityProjectDAL + }); const identityUaService = identityUaServiceFactory({ identityOrgMembershipDAL, permissionService, @@ -638,7 +655,9 @@ export const registerRoutes = async ( trustedIp: trustedIpService, scim: scimService, secretBlindIndex: secretBlindIndexService, - telemetry: telemetryService + telemetry: telemetryService, + projectUserAdditionalPrivilege: projectUserAdditionalPrivilegeService, + identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService }); server.decorate("store", { diff --git a/backend/src/server/routes/v1/project-membership-router.ts b/backend/src/server/routes/v1/project-membership-router.ts index 9e2f5bd22..f277e556c 100644 --- a/backend/src/server/routes/v1/project-membership-router.ts +++ b/backend/src/server/routes/v1/project-membership-router.ts @@ -158,7 +158,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider ]) ) .min(1) - .refine((data) => data.some(({ isTemporary }) => !isTemporary), "At least long lived role is required") + .refine((data) => data.some(({ isTemporary }) => !isTemporary), "At least one long lived role is required") .describe(PROJECTS.UPDATE_USER_MEMBERSHIP.roles) }), response: { diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index dd3ba04f4..e932d2068 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -25,6 +25,11 @@ export const identityProjectDALFactory = (db: TDbClient) => { `${TableName.IdentityProjectMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) + .leftJoin( + TableName.IdentityProjectAdditionalPrivilege, + `${TableName.IdentityProjectMembership}.id`, + `${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId` + ) .select( db.ref("id").withSchema(TableName.IdentityProjectMembership), db.ref("createdAt").withSchema(TableName.IdentityProjectMembership), diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index f8fed95bb..ed96edb41 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -655,7 +655,7 @@ export const secretServiceFactory = ({ actorOrgId ); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); @@ -741,7 +741,7 @@ export const secretServiceFactory = ({ actorOrgId ); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionActions.Delete, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); diff --git a/frontend/src/components/v2/Tag/Tag.tsx b/frontend/src/components/v2/Tag/Tag.tsx index 5f3771f62..7210e388b 100644 --- a/frontend/src/components/v2/Tag/Tag.tsx +++ b/frontend/src/components/v2/Tag/Tag.tsx @@ -20,6 +20,7 @@ const tagVariants = cva( green: "bg-primary-800 text-white" }, size: { + xs: "text-xs px-1 py-0.5", sm: "px-2 py-0.5" } } diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index fd3e88614..4ac19c351 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -29,18 +29,30 @@ export type IdentityMembershipOrg = { export type IdentityMembership = { id: string; identity: Identity; - 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; - }[]; + roles: Array< + { + id: string; + role: "owner" | "admin" | "member" | "no-access" | "custom"; + customRoleId: string; + customRoleName: string; + customRoleSlug: string; + } & ( + | { + isTemporary: false; + temporaryRange: null; + temporaryMode: null; + temporaryAccessEndTime: null; + temporaryAccessStartTime: null; + } + | { + isTemporary: true; + temporaryRange: string; + temporaryMode: string; + temporaryAccessEndTime: string; + temporaryAccessStartTime: string; + } + ) + >; createdAt: string; updatedAt: string; }; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/index.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/index.tsx new file mode 100644 index 000000000..401a06a72 --- /dev/null +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/index.tsx @@ -0,0 +1,7 @@ +export { + useCreateIdentityProjectAdditionalPrivilege, + useDeleteIdentityProjectAdditionalPrivilege, + useUpdateIdentityProjectAdditionalPrivilege +} from "./mutation"; +export { useGetIdentityProjectPrivilegeDetails } from "./queries"; +export type { TIdentityProjectPrivilege } from "./types"; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx new file mode 100644 index 000000000..bb3f6ca88 --- /dev/null +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx @@ -0,0 +1,73 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { identitiyProjectPrivilegeKeys } from "./queries"; +import { + TCreateIdentityProjectPrivilegeDTO, + TDeleteIdentityProjectPrivilegeDTO, + TIdentityProjectPrivilege, + TUpdateIdentityProjectPrivlegeDTO +} from "./types"; + +export const useCreateIdentityProjectAdditionalPrivilege = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post( + "/api/v1/additional-privilege/identity/permanent", + dto + ); + return data.privilege; + }, + onSuccess: (_, { projectSlug, identityId }) => { + queryClient.invalidateQueries( + identitiyProjectPrivilegeKeys.list({ projectSlug, identityId }) + ); + } + }); +}; + +export const useUpdateIdentityProjectAdditionalPrivilege = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ privilegeSlug, projectSlug, identityId, privilegeDetails }) => { + const { data: res } = await apiRequest.patch("/api/v1/additional-privilege/identity", { + privilegeSlug, + projectSlug, + identityId, + privilegeDetails + }); + return res.privilege; + }, + onSuccess: (_, { projectSlug, identityId }) => { + queryClient.invalidateQueries( + identitiyProjectPrivilegeKeys.list({ projectSlug, identityId }) + ); + } + }); +}; + +export const useDeleteIdentityProjectAdditionalPrivilege = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ identityId, projectSlug, privilegeSlug }) => { + const { data } = await apiRequest.delete("/api/v1/additional-privilege/identity", { + data: { + identityId, + projectSlug, + privilegeSlug + } + }); + return data.privilege; + }, + onSuccess: (_, { projectSlug, identityId }) => { + queryClient.invalidateQueries( + identitiyProjectPrivilegeKeys.list({ projectSlug, identityId }) + ); + } + }); +}; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx new file mode 100644 index 000000000..72534c158 --- /dev/null +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx @@ -0,0 +1,77 @@ +import { PackRule, unpackRules } from "@casl/ability/extra"; +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TProjectPermission } from "../roles/types"; +import { + TGetIdentityProejctPrivilegeDetails as TGetIdentityProjectPrivilegeDetails, + TIdentityProjectPrivilege, + TListIdentityUserPrivileges as TListIdentityProjectPrivileges +} from "./types"; + +export const identitiyProjectPrivilegeKeys = { + details: ({ identityId, privilegeSlug, projectSlug }: TGetIdentityProjectPrivilegeDetails) => + [ + "identity-user-privilege", + { + identityId, + projectSlug, + privilegeSlug + } + ] as const, + list: ({ projectSlug, identityId }: TListIdentityProjectPrivileges) => + ["identity-user-privileges", { identityId, projectSlug }] as const +}; + +export const useGetIdentityProjectPrivilegeDetails = ({ + projectSlug, + identityId, + privilegeSlug +}: TGetIdentityProjectPrivilegeDetails) => { + return useQuery({ + enabled: Boolean(projectSlug && identityId && privilegeSlug), + queryKey: identitiyProjectPrivilegeKeys.details({ projectSlug, privilegeSlug, identityId }), + queryFn: async () => { + const { + data: { privilege } + } = await apiRequest.get<{ + privilege: Omit & { permissions: unknown }; + }>(`/api/v1/additional-privilege/identity/${privilegeSlug}`, { + params: { + identityId, + projectSlug + } + }); + return { + ...privilege, + permissions: unpackRules(privilege.permissions as PackRule[]) + }; + } + }); +}; + +export const useListIdentityProjectPrivileges = ({ + projectSlug, + identityId +}: TListIdentityProjectPrivileges) => { + return useQuery({ + enabled: Boolean(projectSlug && identityId), + queryKey: identitiyProjectPrivilegeKeys.list({ projectSlug, identityId }), + queryFn: async () => { + const { + data: { privileges } + } = await apiRequest.get<{ + privileges: Array< + Omit & { permissions: unknown } + >; + }>("/api/v1/additional-privilege/identity", { + params: { identityId, projectSlug, unpacked: false } + }); + return privileges.map((el) => ({ + ...el, + permissions: unpackRules(el.permissions as PackRule[]) + })); + } + }); +}; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx new file mode 100644 index 000000000..fad549e38 --- /dev/null +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx @@ -0,0 +1,64 @@ +import { TProjectPermission } from "../roles/types"; + +export enum IdentityProjectAdditionalPrivilegeTemporaryMode { + Relative = "relative" +} + +export type TIdentityProjectPrivilege = { + projectMembershipId: string; + slug: string; + id: string; + createdAt: Date; + updatedAt: Date; + permissions?: TProjectPermission[]; +} & ( + | { + isTemporary: true; + temporaryMode: string; + temporaryRange: string; + temporaryAccessStartTime: string; + temporaryAccessEndTime?: string; + } + | { + isTemporary: false; + temporaryMode?: null; + temporaryRange?: null; + temporaryAccessStartTime?: null; + temporaryAccessEndTime?: null; + } + ); + +export type TCreateIdentityProjectPrivilegeDTO = { + identityId: string; + projectSlug: string; + slug?: string; + isTemporary?: boolean; + temporaryMode?: IdentityProjectAdditionalPrivilegeTemporaryMode; + temporaryRange?: string; + temporaryAccessStartTime?: string; + permissions: TProjectPermission[]; +}; + +export type TUpdateIdentityProjectPrivlegeDTO = { + projectSlug: string; + identityId: string; + privilegeSlug: string; + privilegeDetails: Partial>; +}; + +export type TDeleteIdentityProjectPrivilegeDTO = { + projectSlug: string; + identityId: string; + privilegeSlug: string; +}; + +export type TListIdentityUserPrivileges = { + projectSlug: string; + identityId: string; +}; + +export type TGetIdentityProejctPrivilegeDetails = { + projectSlug: string; + identityId: string; + privilegeSlug: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 48d85c285..ed89b51ba 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -6,12 +6,14 @@ export * from "./bots"; export * from "./dynamicSecret"; export * from "./dynamicSecretLease"; export * from "./identities"; +export * from "./identityProjectAdditionalPrivilege"; export * from "./incidentContacts"; export * from "./integrationAuth"; export * from "./integrations"; export * from "./keys"; export * from "./ldapConfig"; export * from "./organization"; +export * from "./projectUserAdditionalPrivilege"; export * from "./roles"; export * from "./scim"; export * from "./secretApproval"; diff --git a/frontend/src/hooks/api/projectUserAdditionalPrivilege/index.tsx b/frontend/src/hooks/api/projectUserAdditionalPrivilege/index.tsx new file mode 100644 index 000000000..14a2c8980 --- /dev/null +++ b/frontend/src/hooks/api/projectUserAdditionalPrivilege/index.tsx @@ -0,0 +1,7 @@ +export { + useCreateProjectUserAdditionalPrivilege, + useDeleteProjectUserAdditionalPrivilege, + useUpdateProjectUserAdditionalPrivilege +} from "./mutation"; +export { useGetProjectUserPrivilegeDetails, useListProjectUserPrivileges } from "./queries"; +export type { TProjectUserPrivilege } from "./types"; diff --git a/frontend/src/hooks/api/projectUserAdditionalPrivilege/mutation.tsx b/frontend/src/hooks/api/projectUserAdditionalPrivilege/mutation.tsx new file mode 100644 index 000000000..fb21a425e --- /dev/null +++ b/frontend/src/hooks/api/projectUserAdditionalPrivilege/mutation.tsx @@ -0,0 +1,62 @@ +import { packRules } from "@casl/ability/extra"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { projectUserPrivilegeKeys } from "./queries"; +import { + TCreateProjectUserPrivilegeDTO, + TDeleteProjectUserPrivilegeDTO, + TProjectUserPrivilege, + TUpdateProjectUserPrivlegeDTO +} from "./types"; + +export const useCreateProjectUserAdditionalPrivilege = () => { + const queryClient = useQueryClient(); + + return useMutation<{ privilege: TProjectUserPrivilege }, {}, TCreateProjectUserPrivilegeDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post("/api/v1/additional-privilege/users/permanent", { + ...dto, + permissions: packRules(dto.permissions) + }); + return data.privilege; + }, + onSuccess: (_, { projectMembershipId }) => { + queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId)); + } + }); +}; + +export const useUpdateProjectUserAdditionalPrivilege = () => { + const queryClient = useQueryClient(); + + return useMutation<{ privilege: TProjectUserPrivilege }, {}, TUpdateProjectUserPrivlegeDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.patch( + `/api/v1/additional-privilege/users/${dto.privilegeId}`, + { ...dto, permissions: dto.permissions ? packRules(dto.permissions) : undefined } + ); + return data.privilege; + }, + onSuccess: (_, { projectMembershipId }) => { + queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId)); + } + }); +}; + +export const useDeleteProjectUserAdditionalPrivilege = () => { + const queryClient = useQueryClient(); + + return useMutation<{ privilege: TProjectUserPrivilege }, {}, TDeleteProjectUserPrivilegeDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.delete( + `/api/v1/additional-privilege/users/${dto.privilegeId}` + ); + return data.privilege; + }, + onSuccess: (_, { projectMembershipId }) => { + queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId)); + } + }); +}; diff --git a/frontend/src/hooks/api/projectUserAdditionalPrivilege/queries.tsx b/frontend/src/hooks/api/projectUserAdditionalPrivilege/queries.tsx new file mode 100644 index 000000000..41c9a0dcc --- /dev/null +++ b/frontend/src/hooks/api/projectUserAdditionalPrivilege/queries.tsx @@ -0,0 +1,51 @@ +import { PackRule, unpackRules } from "@casl/ability/extra"; +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TProjectPermission } from "../roles/types"; +import { TProjectUserPrivilege } from "./types"; + +export const projectUserPrivilegeKeys = { + details: (privilegeId: string) => ["project-user-privilege", { privilegeId }] as const, + list: (projectMembershipId: string) => + ["project-user-privileges", { projectMembershipId }] as const +}; + +const fetchProjectUserPrivilegeDetails = async (privilegeId: string) => { + const { + data: { privilege } + } = await apiRequest.get<{ + privilege: Omit & { permissions: unknown }; + }>(`/api/v1/additional-privilege/users/${privilegeId}`); + return { + ...privilege, + permissions: unpackRules(privilege.permissions as PackRule[]) + }; +}; + +export const useGetProjectUserPrivilegeDetails = (privilegeId: string) => { + return useQuery({ + enabled: Boolean(privilegeId), + queryKey: projectUserPrivilegeKeys.details(privilegeId), + queryFn: () => fetchProjectUserPrivilegeDetails(privilegeId) + }); +}; + +export const useListProjectUserPrivileges = (projectMembershipId: string) => { + return useQuery({ + enabled: Boolean(projectMembershipId), + queryKey: projectUserPrivilegeKeys.list(projectMembershipId), + queryFn: async () => { + const { + data: { privileges } + } = await apiRequest.get<{ + privileges: Array & { permissions: unknown }>; + }>("/api/v1/additional-privilege/users", { params: { projectMembershipId } }); + return privileges.map((el) => ({ + ...el, + permissions: unpackRules(el.permissions as PackRule[]) + })); + } + }); +}; diff --git a/frontend/src/hooks/api/projectUserAdditionalPrivilege/types.tsx b/frontend/src/hooks/api/projectUserAdditionalPrivilege/types.tsx new file mode 100644 index 000000000..b757a07ab --- /dev/null +++ b/frontend/src/hooks/api/projectUserAdditionalPrivilege/types.tsx @@ -0,0 +1,57 @@ +import { TProjectPermission } from "../roles/types"; + +export enum ProjectUserAdditionalPrivilegeTemporaryMode { + Relative = "relative" +} + +export type TProjectUserPrivilege = { + projectMembershipId: string; + slug: string; + id: string; + createdAt: Date; + updatedAt: Date; + permissions?: TProjectPermission[]; +} & ( + | { + isTemporary: true; + temporaryMode: string; + temporaryRange: string; + temporaryAccessStartTime: string; + temporaryAccessEndTime?: string; + } + | { + isTemporary: false; + temporaryMode?: null; + temporaryRange?: null; + temporaryAccessStartTime?: null; + temporaryAccessEndTime?: null; + } + ); + +export type TCreateProjectUserPrivilegeDTO = { + projectMembershipId: string; + slug?: string; + isTemporary?: boolean; + temporaryMode?: ProjectUserAdditionalPrivilegeTemporaryMode; + temporaryRange?: string; + temporaryAccessStartTime?: string; + permissions: TProjectPermission[]; +}; + +export type TUpdateProjectUserPrivlegeDTO = { + privilegeId: string; + projectMembershipId: string; +} & Partial>; + +export type TDeleteProjectUserPrivilegeDTO = { + privilegeId: string; + projectMembershipId: string; +}; + +export type TGetProjectUserPrivilegeDetails = { + privilegeId: string; +}; + +export type TListProjectUserPrivileges = { + projectMembershipId: string; +}; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index edc3bdf9e..23f4dda4c 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -76,18 +76,32 @@ export type TWorkspaceUser = { }; 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; - }[]; + roles: ( + | { + id: string; + role: "owner" | "admin" | "member" | "no-access" | "custom"; + customRoleId: string; + customRoleName: string; + customRoleSlug: string; + isTemporary: false; + temporaryRange: null; + temporaryMode: null; + temporaryAccessEndTime: null; + temporaryAccessStartTime: null; + } + | { + id: string; + role: "owner" | "admin" | "member" | "no-access" | "custom"; + customRoleId: string; + customRoleName: string; + customRoleSlug: string; + isTemporary: true; + temporaryRange: string; + temporaryMode: string; + temporaryAccessEndTime: string; + temporaryAccessStartTime: string; + } + )[]; status: "invited" | "accepted" | "verified" | "completed"; deniedPermissions: any[]; }; diff --git a/frontend/src/views/Project/MembersPage/MembersPage.tsx b/frontend/src/views/Project/MembersPage/MembersPage.tsx index b565d426c..66c01f773 100644 --- a/frontend/src/views/Project/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Project/MembersPage/MembersPage.tsx @@ -32,15 +32,7 @@ export const MembersPage = withProjectPermission( Project Roles - - - + diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx index 58397d96a..2ab1ebeec 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx @@ -1,17 +1,351 @@ +import Link from "next/link"; +import { + faArrowUpRightFromSquare, + faClock, + faEdit, + faPlus, + faServer, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; -import { IdentitySection } from "./components"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + EmptyState, + HoverCard, + HoverCardContent, + HoverCardTrigger, + IconButton, + Modal, + ModalContent, + Table, + TableContainer, + TableSkeleton, + Tag, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api"; +import { IdentityMembership } from "@app/hooks/api/identities/types"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { usePopUp } from "@app/hooks/usePopUp"; -export const IdentityTab = () => { - return ( - - - - ); +import { IdentityModal } from "./components/IdentityModal"; +import { IdentityRoleForm } from "./components/IdentityRoleForm"; + +const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; +const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Member) return "Developer"; + if (role === ProjectMembershipRole.NoAccess) return "No access"; + return role; }; +export const IdentityTab = withProjectPermission( + () => { + const { currentWorkspace } = useWorkspace(); + + const workspaceId = currentWorkspace?.id ?? ""; + + const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || ""); + const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "identity", + "deleteIdentity", + "upgradePlan", + "updateRole" + ] as const); + + const onRemoveIdentitySubmit = async (identityId: string) => { + try { + await deleteMutateAsync({ + identityId, + workspaceId + }); + + createNotification({ + text: "Successfully removed identity from project", + type: "success" + }); + + handlePopUpClose("deleteIdentity"); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to remove identity from project"; + + createNotification({ + text, + type: "error" + }); + } + }; + + return ( + +
+
+

Identities

+
+ + + Documentation{" "} + + + +
+ + {(isAllowed) => ( + + )} + +
+ + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map((identityMember, index) => { + const { + identity: { id, name }, + roles, + createdAt + } = identityMember; + return ( + + + + + + + + ); + })} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
NameRoleAdded on +
{name} +
+ {roles + .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map( + ({ + role, + customRoleName, + id: roleId, + 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: roleId, + isTemporary, + temporaryAccessEndTime + }) => { + const isExpired = + new Date() > + new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + new Date( + temporaryAccessEndTime as string + ) && "text-red-600" + )} + /> + +
+ )} +
+
+ ); + } + )} +
+
+ )} + + + handlePopUpOpen("updateRole", { ...identityMember, index }) + } + > + + + +
+
{format(new Date(createdAt), "yyyy-MM-dd")} + + {(isAllowed) => ( + { + handlePopUpOpen("deleteIdentity", { + identityId: id, + name + }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} + > + + + )} + +
+ +
+
+ handlePopUpToggle("updateRole", state)} + > + + + handlePopUpOpen("upgradePlan", { description }) + } + identityProjectMember={ + data?.[ + (popUp.updateRole?.data as IdentityMembership & { index: number })?.index + ] as IdentityMembership + } + /> + + + + handlePopUpToggle("deleteIdentity", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveIdentitySubmit( + (popUp?.deleteIdentity?.data as { identityId: string })?.identityId + ) + } + /> +
+
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity } +); diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx similarity index 100% rename from frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityModal.tsx rename to frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx new file mode 100644 index 000000000..5855cc21d --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx @@ -0,0 +1,353 @@ +/* eslint-disable no-nested-ternary */ +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format, formatDistance } from "date-fns"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + FormControl, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectItem, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useSubscription, + useWorkspace +} from "@app/context"; +import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api"; +import { IdentityMembership } from "@app/hooks/api/identities/types"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types"; + +const roleFormSchema = z.object({ + roles: z + .object({ + slug: z.string(), + temporaryAccess: z.discriminatedUnion("isTemporary", [ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.object({ + isTemporary: z.literal(false) + }) + ]) + }) + .array() +}); +type TRoleForm = z.infer; + +type Props = { + identityProjectMember: IdentityMembership; + onOpenUpgradeModal: (title: string) => void; +}; +export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal }: Props) => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const { permission } = useProjectPermission(); + const isMemberEditDisabled = permission.cannot( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Identity + ); + + const roleForm = useForm({ + resolver: zodResolver(roleFormSchema), + values: { + roles: identityProjectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({ + slug: customRoleSlug || role, + temporaryAccess: dto.isTemporary + ? { + isTemporary: true, + temporaryRange: dto.temporaryRange, + temporaryAccessEndTime: dto.temporaryAccessEndTime, + temporaryAccessStartTime: dto.temporaryAccessStartTime + } + : { + isTemporary: dto.isTemporary + } + })) + } + }); + const selectedRoleList = useFieldArray({ + name: "roles", + control: roleForm.control + }); + + const formRoleField = roleForm.watch("roles"); + + const updateMembershipRole = useUpdateIdentityWorkspaceRole(); + + const handleRoleUpdate = async (data: TRoleForm) => { + if (updateMembershipRole.isLoading) return; + + const sanitizedRoles = data.roles.map((el) => { + const { isTemporary } = el.temporaryAccess; + if (!isTemporary) { + return { role: el.slug, isTemporary: false as const }; + } + return { + role: el.slug, + isTemporary: true as const, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: el.temporaryAccess.temporaryRange, + temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime + }; + }); + + const hasCustomRoleSelected = sanitizedRoles.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, + identityId: identityProjectMember.identity.id, + roles: sanitizedRoles + }); + createNotification({ text: "Successfully updated roles", type: "success" }); + roleForm.reset(undefined, { keepValues: true }); + } catch (err) { + createNotification({ text: "Failed to update role", type: "error" }); + } + }; + + if (isRolesLoading) + return ( +
+ +
+ ); + + return ( +
+
Roles
+

Select one of the pre-defined or custom roles.

+
+
+
+ {selectedRoleList.fields.map(({ id }, index) => { + const { temporaryAccess } = formRoleField[index]; + const isTemporary = temporaryAccess?.isTemporary; + const isExpired = + temporaryAccess.isTemporary && + new Date() > new Date(temporaryAccess.temporaryAccessEndTime || ""); + + return ( +
+ ( + + )} + /> + + +
+ + + +
+
+ +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {temporaryAccess.isTemporary && ( + + )} +
+
+
+
+ { + if (selectedRoleList.fields.length > 1) { + selectedRoleList.remove(index); + } + }} + > + + +
+ ); + })} +
+
+ + {(isAllowed) => ( + + )} + + +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx new file mode 100644 index 000000000..e354f1015 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx @@ -0,0 +1,20 @@ +import { IdentityMembership } from "@app/hooks/api/identities/types"; + +import { IdentityRbacSection } from "./IdentityRbacSection"; +import { SpecificPrivilegeSection } from "./SpecificPrivilegeSection"; + +type Props = { + identityProjectMember: IdentityMembership; + onOpenUpgradeModal: (title: string) => void; +}; +export const IdentityRoleForm = ({ identityProjectMember, onOpenUpgradeModal }: Props) => { + return ( +
+ + +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx new file mode 100644 index 000000000..83a6b6e45 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx @@ -0,0 +1,532 @@ +import { Controller, useForm } from "react-hook-form"; +import { + faArrowRotateLeft, + faCaretDown, + faCheck, + faClock, + faPlus, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format, formatDistance } from "date-fns"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + Checkbox, + DeleteActionModal, + FormControl, + FormLabel, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectItem, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { + TProjectUserPrivilege, + useCreateIdentityProjectAdditionalPrivilege, + useDeleteIdentityProjectAdditionalPrivilege, + useUpdateIdentityProjectAdditionalPrivilege +} from "@app/hooks/api"; +import { useListIdentityProjectPrivileges } from "@app/hooks/api/identityProjectAdditionalPrivilege/queries"; + +const secretPermissionSchema = z.object({ + secretPath: z.string().optional(), + environmentSlug: z.string(), + [ProjectPermissionActions.Edit]: z.boolean().optional(), + [ProjectPermissionActions.Read]: z.boolean().optional(), + [ProjectPermissionActions.Create]: z.boolean().optional(), + [ProjectPermissionActions.Delete]: z.boolean().optional(), + temporaryAccess: z.discriminatedUnion("isTemporary", [ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.object({ + isTemporary: z.literal(false) + }) + ]) +}); +type TSecretPermissionForm = z.infer; +const SpecificPrivilegeSecretForm = ({ + privilege, + identityId +}: { + privilege: TProjectUserPrivilege; + identityId: string; +}) => { + const { currentWorkspace } = useWorkspace(); + const projectSlug = currentWorkspace?.slug || ""; + + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "deletePrivilege" + ] as const); + const { permission } = useProjectPermission(); + const isMemberEditDisabled = permission.cannot( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Identity + ); + + const updateIdentityPrivilege = useUpdateIdentityProjectAdditionalPrivilege(); + const deleteIdentityPrivilege = useDeleteIdentityProjectAdditionalPrivilege(); + + const privilegeForm = useForm({ + resolver: zodResolver(secretPermissionSchema), + values: { + environmentSlug: privilege.permissions?.[0]?.conditions?.environment, + // secret path will be inside $glob operator + secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "", + read: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Read) + ), + edit: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Edit) + ), + create: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Create) + ), + delete: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Delete) + ), + // zod will pick it + temporaryAccess: privilege + } + }); + + const temporaryAccessField = privilegeForm.watch("temporaryAccess"); + const isTemporary = temporaryAccessField?.isTemporary; + const isExpired = + temporaryAccessField.isTemporary && + new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || ""); + + const handleUpdatePrivilege = async (data: TSecretPermissionForm) => { + if (updateIdentityPrivilege.isLoading) return; + try { + const actions = [ + { action: ProjectPermissionActions.Read, allowed: data.read }, + { action: ProjectPermissionActions.Create, allowed: data.create }, + { action: ProjectPermissionActions.Delete, allowed: data.delete }, + { action: ProjectPermissionActions.Edit, allowed: data.edit } + ]; + const conditions: Record = { environment: data.environmentSlug }; + if (data.secretPath) { + conditions.secretPath = { $glob: data.secretPath }; + } + await updateIdentityPrivilege.mutateAsync({ + privilegeDetails: { + ...data.temporaryAccess, + permissions: actions + .filter(({ allowed }) => allowed) + .map(({ action }) => ({ + action, + subject: [ProjectPermissionSub.Secrets], + conditions + })) + }, + privilegeSlug: privilege.slug, + identityId, + projectSlug + }); + createNotification({ + type: "success", + text: "Successfully updated privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to update privilege" + }); + } + }; + + const handleDeletePrivilege = async () => { + if (deleteIdentityPrivilege.isLoading) return; + try { + await deleteIdentityPrivilege.mutateAsync({ + identityId, + privilegeSlug: privilege.slug, + projectSlug + }); + createNotification({ + type: "success", + text: "Successfully deleted privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to delete privilege" + }); + } + }; + + const getAccessLabel = (exactTime = false) => { + if (isExpired) return "Access expired"; + if (!temporaryAccessField?.isTemporary) return "Permanent"; + if (exactTime) + return `Until ${format( + new Date(temporaryAccessField.temporaryAccessEndTime || ""), + "yyyy-MM-dd HH:mm:ss" + )}`; + return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); + }; + + return ( +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> +
+
+ + +
+ + + +
+
+ +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {temporaryAccessField.isTemporary && ( + + )} +
+
+
+
+ {privilegeForm.formState.isDirty ? ( + <> + + privilegeForm.reset()} + > + + + + + + {privilegeForm.formState.isSubmitting ? ( + + ) : ( + + )} + + + + ) : ( + + handlePopUpOpen("deletePrivilege")} + > + + + + )} +
+
+
+ handlePopUpToggle("deletePrivilege", isOpen)} + deleteKey="delete" + onClose={() => handlePopUpClose("deletePrivilege")} + onDeleteApproved={handleDeletePrivilege} + /> +
+ ); +}; + +type Props = { + identityId: string; +}; + +export const SpecificPrivilegeSection = ({ identityId }: Props) => { + const { currentWorkspace } = useWorkspace(); + const projectSlug = currentWorkspace?.slug || ""; + const { data: identityPrivileges, isLoading } = useListIdentityProjectPrivileges({ + identityId, + projectSlug + }); + + const createIdentityPrivilege = useCreateIdentityProjectAdditionalPrivilege(); + + const handleCreatePrivilege = async () => { + if (createIdentityPrivilege.isLoading) return; + try { + await createIdentityPrivilege.mutateAsync({ + permissions: [ + { + action: ProjectPermissionActions.Read, + subject: [ProjectPermissionSub.Secrets], + conditions: { + environment: currentWorkspace?.environments?.[0].slug + } + } + ], + identityId, + projectSlug + }); + createNotification({ + type: "success", + text: "Successfully created privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to create privilege" + }); + } + }; + + return ( +
+
+ Additional Privileges + {isLoading && } +
+

+ Select individual privileges to associate with the identity. +

+
+ {identityPrivileges + ?.filter(({ permissions }) => + permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets) + ) + ?.map((privilege) => ( + + ))} +
+ + {(isAllowed) => ( + + )} + +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx new file mode 100644 index 000000000..f59675cb3 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx @@ -0,0 +1 @@ +export { IdentityRoleForm } from "./IdentityRoleForm"; 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 deleted file mode 100644 index 6ff10cfa8..000000000 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityRoles.tsx +++ /dev/null @@ -1,459 +0,0 @@ -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 { createNotification } from "@app/components/notifications"; -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 { 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/IdentitySection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentitySection.tsx deleted file mode 100644 index 4410bc639..000000000 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentitySection.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import Link from "next/link"; -import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { createNotification } from "@app/components/notifications"; -import { ProjectPermissionCan } from "@app/components/permissions"; -import { Button, DeleteActionModal } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { withProjectPermission } from "@app/hoc"; -import { useDeleteIdentityFromWorkspace } from "@app/hooks/api"; -import { usePopUp } from "@app/hooks/usePopUp"; - -import { IdentityModal } from "./IdentityModal"; -import { IdentityTable } from "./IdentityTable"; - -export const IdentitySection = withProjectPermission( - () => { - - const { currentWorkspace } = useWorkspace(); - - const workspaceId = currentWorkspace?.id ?? ""; - - const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace(); - - const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "identity", - "deleteIdentity", - "upgradePlan" - ] as const); - - const onRemoveIdentitySubmit = async (identityId: string) => { - try { - await deleteMutateAsync({ - identityId, - workspaceId - }); - - createNotification({ - text: "Successfully removed identity from project", - type: "success" - }); - - handlePopUpClose("deleteIdentity"); - } catch (err) { - console.error(err); - const error = err as any; - const text = error?.response?.data?.message ?? "Failed to remove identity from project"; - - createNotification({ - text, - type: "error" - }); - } - }; - - return ( -
-
-

Identities

-
- - - Documentation{" "} - - - -
- - {(isAllowed) => ( - - )} - -
- - - handlePopUpToggle("deleteIdentity", isOpen)} - deleteKey="confirm" - onDeleteApproved={() => - onRemoveIdentitySubmit( - (popUp?.deleteIdentity?.data as { identityId: string })?.identityId - ) - } - /> -
- ); - }, - { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity } -); 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 deleted file mode 100644 index 24197e9fe..000000000 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { format } from "date-fns"; - -import { ProjectPermissionCan } from "@app/components/permissions"; -import { - EmptyState, - IconButton, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tr -} from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { useGetWorkspaceIdentityMemberships } from "@app/hooks/api"; -import { UsePopUpState } from "@app/hooks/usePopUp"; - -import { IdentityRoles } from "./IdentityRoles"; - -type Props = { - handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["deleteIdentity", "identity"]>, - data?: { - identityId?: string; - name?: string; - } - ) => void; -}; - -export const IdentityTable = ({ handlePopUpOpen }: Props) => { - const { currentWorkspace } = useWorkspace(); - const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || ""); - - return ( - - - - - - - - - - - {isLoading && } - {!isLoading && - data && - data.length > 0 && - data.map(({ identity: { id, name }, roles, createdAt }) => { - return ( - - - - - - - ); - })} - {!isLoading && data && data?.length === 0 && ( - - - - )} - -
NameRoleAdded on -
{name} - - {(isAllowed) => ( - - )} - - {format(new Date(createdAt), "yyyy-MM-dd")} - - {(isAllowed) => ( - { - handlePopUpOpen("deleteIdentity", { - identityId: id, - name - }); - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - className="ml-4" - isDisabled={!isAllowed} - > - - - )} - -
- -
-
- ); -}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/index.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/index.tsx deleted file mode 100644 index f0663a6a5..000000000 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { IdentitySection } from "./IdentitySection"; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/index.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/index.tsx deleted file mode 100644 index f0663a6a5..000000000 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { IdentitySection } from "./IdentitySection"; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index 342b3b920..b59286577 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -2,9 +2,18 @@ import { useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Link from "next/link"; -import { faMagnifyingGlass, faPlus, faUsers, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { + faClock, + faEdit, + faMagnifyingGlass, + faPlus, + faUsers, + faXmark +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -14,6 +23,9 @@ import { DeleteActionModal, EmptyState, FormControl, + HoverCard, + HoverCardContent, + HoverCardTrigger, IconButton, Input, Modal, @@ -23,10 +35,12 @@ import { Table, TableContainer, TableSkeleton, + Tag, TBody, Td, Th, THead, + Tooltip, Tr, UpgradePlanModal } from "@app/components/v2"; @@ -46,9 +60,11 @@ import { useGetUserWsKey, useGetWorkspaceUsers } from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { TWorkspaceUser } from "@app/hooks/api/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; -import { MemberRoles } from "./MemberRoles"; +import { MemberRoleForm } from "./MemberRoleForm"; const addMemberFormSchema = z.object({ orgMembershipId: z.string().trim() @@ -56,8 +72,15 @@ const addMemberFormSchema = z.object({ type TAddMemberForm = z.infer; +const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; +const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Member) return "Developer"; + if (role === ProjectMembershipRole.NoAccess) return "No access"; + return role; +}; + export const MemberListTab = () => { - const { t } = useTranslation(); const { currentOrg } = useOrganization(); @@ -77,7 +100,8 @@ export const MemberListTab = () => { const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ "addMember", "removeMember", - "upgradePlan" + "upgradePlan", + "updateRole" ] as const); const { @@ -186,7 +210,14 @@ export const MemberListTab = () => { }, [orgUsers, members]); return ( -
+

Members

@@ -223,7 +254,8 @@ export const MemberListTab = () => { {isMembersLoading && } {!isMembersLoading && - filterdUsers?.map(({ user: u, inviteEmail, id: membershipId, roles }) => { + filterdUsers?.map((projectMember, index) => { + const { user: u, inviteEmail, id: membershipId, roles } = projectMember; const name = u ? `${u.firstName} ${u.lastName}` : "-"; const email = u?.email || inviteEmail; @@ -232,44 +264,136 @@ export const MemberListTab = () => { {name} {email} - - {(isAllowed) => ( - - handlePopUpOpen("upgradePlan", { description }) +
+ {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 && ( +
+ + + +
+ )} +
+
+ ); } - membershipId={membershipId} - /> + )} + {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" + )} + /> + +
+ )} +
+
+ ); + } + )} +
+
)} - + {userId !== u?.id && ( + + + handlePopUpOpen("updateRole", { ...projectMember, index }) + } + > + + + + )} +
{userId !== u?.id && ( - - {(isAllowed) => ( - - handlePopUpOpen("removeMember", { username: u.username }) - } - > - - - )} - +
+ + {(isAllowed) => ( + + handlePopUpOpen("removeMember", { username: u.username }) + } + > + + + )} + +
)} @@ -343,6 +467,27 @@ export const MemberListTab = () => { )} + handlePopUpToggle("updateRole", state)} + > + + handlePopUpOpen("upgradePlan", { description })} + projectMember={ + filterdUsers?.[ + (popUp.updateRole?.data as TWorkspaceUser & { index: number })?.index + ] as TWorkspaceUser + } + /> + + { onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} text={(popUp.upgradePlan?.data as { description: string })?.description} /> -
+
); }; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx new file mode 100644 index 000000000..fa9d8ae0f --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx @@ -0,0 +1,350 @@ +/* eslint-disable no-nested-ternary */ +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format, formatDistance } from "date-fns"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + FormControl, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectItem, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useSubscription, + useWorkspace +} from "@app/context"; +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"; + +const roleFormSchema = z.object({ + roles: z + .object({ + slug: z.string(), + temporaryAccess: z.discriminatedUnion("isTemporary", [ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.object({ + isTemporary: z.literal(false) + }) + ]) + }) + .array() +}); +type TRoleForm = z.infer; + +type Props = { + projectMember: TWorkspaceUser; + onOpenUpgradeModal: (title: string) => void; +}; +export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const { permission } = useProjectPermission(); + const isMemberEditDisabled = permission.cannot( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Member + ); + + const roleForm = useForm({ + resolver: zodResolver(roleFormSchema), + values: { + roles: projectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({ + slug: customRoleSlug || role, + temporaryAccess: dto.isTemporary + ? { + isTemporary: true, + temporaryRange: dto.temporaryRange, + temporaryAccessEndTime: dto.temporaryAccessEndTime, + temporaryAccessStartTime: dto.temporaryAccessStartTime + } + : { + isTemporary: dto.isTemporary + } + })) + } + }); + const selectedRoleList = useFieldArray({ + name: "roles", + control: roleForm.control + }); + + const formRoleField = roleForm.watch("roles"); + + const updateMembershipRole = useUpdateUserWorkspaceRole(); + + const handleRoleUpdate = async (data: TRoleForm) => { + if (updateMembershipRole.isLoading) return; + + const sanitizedRoles = data.roles.map((el) => { + const { isTemporary } = el.temporaryAccess; + if (!isTemporary) { + return { role: el.slug, isTemporary: false as const }; + } + return { + role: el.slug, + isTemporary: true as const, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: el.temporaryAccess.temporaryRange, + temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime + }; + }); + + const hasCustomRoleSelected = sanitizedRoles.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: projectMember.id, + roles: sanitizedRoles + }); + createNotification({ text: "Successfully updated roles", type: "success" }); + roleForm.reset(undefined, { keepValues: true }); + } catch (err) { + createNotification({ text: "Failed to update role", type: "error" }); + } + }; + + if (isRolesLoading) + return ( +
+ +
+ ); + + return ( +
+
Roles
+

Select one of the pre-defined or custom roles.

+
+
+
+ {selectedRoleList.fields.map(({ id }, index) => { + const { temporaryAccess } = formRoleField[index]; + const isTemporary = temporaryAccess?.isTemporary; + const isExpired = + temporaryAccess.isTemporary && + new Date() > new Date(temporaryAccess.temporaryAccessEndTime || ""); + + return ( +
+ ( + + )} + /> + + +
+ + + +
+
+ +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {temporaryAccess.isTemporary && ( + + )} +
+
+
+
+ { + if (selectedRoleList.fields.length > 1) { + selectedRoleList.remove(index); + } + }} + > + + +
+ ); + })} +
+
+ + {(isAllowed) => ( + + )} + + +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRoleForm.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRoleForm.tsx new file mode 100644 index 000000000..da6243281 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRoleForm.tsx @@ -0,0 +1,20 @@ +import { TWorkspaceUser } from "@app/hooks/api/types"; + +import { MemberRbacSection } from "./MemberRbacSection"; +import { SpecificPrivilegeSection } from "./SpecificPrivilegeSection"; + +type Props = { + projectMember: TWorkspaceUser; + onOpenUpgradeModal: (title: string) => void; +}; +export const MemberRoleForm = ({ projectMember, onOpenUpgradeModal }: Props) => { + return ( +
+ + +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx new file mode 100644 index 000000000..2df959a94 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -0,0 +1,514 @@ +import { Controller, useForm } from "react-hook-form"; +import { + faArrowRotateLeft, + faCaretDown, + faCheck, + faClock, + faPlus, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format, formatDistance } from "date-fns"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + Checkbox, + DeleteActionModal, + FormControl, + FormLabel, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectItem, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { + TProjectUserPrivilege, + useCreateProjectUserAdditionalPrivilege, + useDeleteProjectUserAdditionalPrivilege, + useListProjectUserPrivileges, + useUpdateProjectUserAdditionalPrivilege +} from "@app/hooks/api"; + +const secretPermissionSchema = z.object({ + secretPath: z.string().optional(), + environmentSlug: z.string(), + [ProjectPermissionActions.Edit]: z.boolean().optional(), + [ProjectPermissionActions.Read]: z.boolean().optional(), + [ProjectPermissionActions.Create]: z.boolean().optional(), + [ProjectPermissionActions.Delete]: z.boolean().optional(), + temporaryAccess: z.discriminatedUnion("isTemporary", [ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.object({ + isTemporary: z.literal(false) + }) + ]) +}); +type TSecretPermissionForm = z.infer; +const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPrivilege }) => { + const { currentWorkspace } = useWorkspace(); + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "deletePrivilege" + ] as const); + const { permission } = useProjectPermission(); + const isMemberEditDisabled = permission.cannot( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Member + ); + + const updateUserPrivilege = useUpdateProjectUserAdditionalPrivilege(); + const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege(); + + const privilegeForm = useForm({ + resolver: zodResolver(secretPermissionSchema), + values: { + environmentSlug: privilege.permissions?.[0]?.conditions?.environment, + // secret path will be inside $glob operator + secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "", + read: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Read) + ), + edit: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Edit) + ), + create: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Create) + ), + delete: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Delete) + ), + // zod will pick it + temporaryAccess: privilege + } + }); + + const temporaryAccessField = privilegeForm.watch("temporaryAccess"); + const isTemporary = temporaryAccessField?.isTemporary; + const isExpired = + temporaryAccessField.isTemporary && + new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || ""); + + const handleUpdatePrivilege = async (data: TSecretPermissionForm) => { + if (updateUserPrivilege.isLoading) return; + try { + const actions = [ + { action: ProjectPermissionActions.Read, allowed: data.read }, + { action: ProjectPermissionActions.Create, allowed: data.create }, + { action: ProjectPermissionActions.Delete, allowed: data.delete }, + { action: ProjectPermissionActions.Edit, allowed: data.edit } + ]; + const conditions: Record = { environment: data.environmentSlug }; + if (data.secretPath) { + conditions.secretPath = { $glob: data.secretPath }; + } + await updateUserPrivilege.mutateAsync({ + privilegeId: privilege.id, + ...data.temporaryAccess, + permissions: actions + .filter(({ allowed }) => allowed) + .map(({ action }) => ({ + action, + subject: [ProjectPermissionSub.Secrets], + conditions + })), + projectMembershipId: privilege.projectMembershipId + }); + createNotification({ + type: "success", + text: "Successfully updated privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to update privilege" + }); + } + }; + + const handleDeletePrivilege = async () => { + if (deleteUserPrivilege.isLoading) return; + try { + await deleteUserPrivilege.mutateAsync({ + privilegeId: privilege.id, + projectMembershipId: privilege.projectMembershipId + }); + createNotification({ + type: "success", + text: "Successfully deleted privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to delete privilege" + }); + } + }; + + const getAccessLabel = (exactTime = false) => { + if (isExpired) return "Access expired"; + if (!temporaryAccessField?.isTemporary) return "Permanent"; + if (exactTime) + return `Until ${format( + new Date(temporaryAccessField.temporaryAccessEndTime || ""), + "yyyy-MM-dd HH:mm:ss" + )}`; + return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); + }; + + return ( +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> +
+
+ + +
+ + + +
+
+ +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {temporaryAccessField.isTemporary && ( + + )} +
+
+
+
+ {privilegeForm.formState.isDirty ? ( + <> + + privilegeForm.reset()} + > + + + + + + {privilegeForm.formState.isSubmitting ? ( + + ) : ( + + )} + + + + ) : ( + + handlePopUpOpen("deletePrivilege")} + > + + + + )} +
+
+
+ handlePopUpToggle("deletePrivilege", isOpen)} + deleteKey="delete" + onClose={() => handlePopUpClose("deletePrivilege")} + onDeleteApproved={handleDeletePrivilege} + /> +
+ ); +}; + +type Props = { + membershipId: string; +}; + +export const SpecificPrivilegeSection = ({ membershipId }: Props) => { + const { data: userPrivileges, isLoading } = useListProjectUserPrivileges(membershipId); + const { currentWorkspace } = useWorkspace(); + + const createUserPrivilege = useCreateProjectUserAdditionalPrivilege(); + + const handleCreatePrivilege = async () => { + if (createUserPrivilege.isLoading) return; + try { + await createUserPrivilege.mutateAsync({ + permissions: [ + { + action: ProjectPermissionActions.Read, + subject: [ProjectPermissionSub.Secrets], + conditions: { + environment: currentWorkspace?.environments?.[0].slug + } + } + ], + projectMembershipId: membershipId + }); + createNotification({ + type: "success", + text: "Successfully created privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to create privilege" + }); + } + }; + + return ( +
+
+ Additional Privileges + {isLoading && } +
+

+ Select individual privileges to associate with the user. +

+
+ {userPrivileges + ?.filter(({ permissions }) => + permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets) + ) + ?.map((privilege) => ( + + ))} +
+ + {(isAllowed) => ( + + )} + +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/index.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/index.tsx new file mode 100644 index 000000000..765b4061c --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/index.tsx @@ -0,0 +1 @@ +export { MemberRoleForm } from "./MemberRoleForm"; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx deleted file mode 100644 index 50d8cd0d9..000000000 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx +++ /dev/null @@ -1,471 +0,0 @@ -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 { createNotification } from "@app/components/notifications"; -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 { 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/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx index db18822f4..188d81c65 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { Control, Controller, UseFormGetValues, UseFormSetValue, useWatch } from "react-hook-form"; import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { motion } from "framer-motion"; @@ -28,6 +28,7 @@ type Props = { formName: "secrets"; isNonEditable?: boolean; setValue: UseFormSetValue; + getValue: UseFormGetValues; control: Control; title: string; subtitle: string; @@ -44,6 +45,7 @@ enum Permission { export const MultiEnvProjectPermission = ({ isNonEditable, setValue, + getValue, control, formName, title, @@ -69,9 +71,12 @@ export const MultiEnvProjectPermission = ({ const handlePermissionChange = (val: Permission) => { switch (val) { - case Permission.NoAccess: - setValue(`permissions.${formName}`, undefined, { shouldDirty: true }); + case Permission.NoAccess: { + const permissions = getValue("permissions"); + if (permissions) delete permissions[formName]; + setValue("permissions", permissions, { shouldDirty: true }); break; + } case Permission.FullAccess: setValue( `permissions.${formName}`, @@ -101,7 +106,7 @@ export const MultiEnvProjectPermission = ({ className={twMerge( "rounded-md bg-mineshaft-800 px-10 py-6", (selectedPermissionCategory !== Permission.NoAccess || isCustom) && - "border-l-2 border-primary-600" + "border-l-2 border-primary-600" )} >
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx index cf2e7e8b9..4349b0913 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -128,6 +128,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { register, formState: { isSubmitting, isDirty, errors }, setValue, + getValues, control } = useForm({ defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {}, @@ -226,6 +227,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {
val !== "custom", { message: "Cannot use custom as its a keyword" }), permissions: z .object({ diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx index 13869b3f2..f670ea496 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -416,7 +416,7 @@ export const ActionBar = ({ {Object.keys(selectedSecrets).length} Selected
{ className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4" onSubmit={handleSubmit(onFormSubmit)} > -
-
- Allow user sign up +
+
+ Allow user signups +
+
+ Select if you want users to be able to signup freely into your Infisical instance.
{ />
{signupMode === "anyone" && ( -
-
- Restrict sign up by email domain(s) +
+
+ Restrict signup by email domain(s)