diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 7cf86032f..624915276 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -101,6 +101,9 @@ import { TIdentityKubernetesAuths, TIdentityKubernetesAuthsInsert, TIdentityKubernetesAuthsUpdate, + TIdentityMetadata, + TIdentityMetadataInsert, + TIdentityMetadataUpdate, TIdentityOidcAuths, TIdentityOidcAuthsInsert, TIdentityOidcAuthsUpdate, @@ -546,6 +549,11 @@ declare module "knex/types/tables" { TIdentityUniversalAuthsInsert, TIdentityUniversalAuthsUpdate >; + [TableName.IdentityMetadata]: KnexOriginal.CompositeTableType< + TIdentityMetadata, + TIdentityMetadataInsert, + TIdentityMetadataUpdate + >; [TableName.IdentityKubernetesAuth]: KnexOriginal.CompositeTableType< TIdentityKubernetesAuths, TIdentityKubernetesAuthsInsert, diff --git a/backend/src/db/migrations/20240924100329_identity-metadata.ts b/backend/src/db/migrations/20240924100329_identity-metadata.ts new file mode 100644 index 000000000..962c1d350 --- /dev/null +++ b/backend/src/db/migrations/20240924100329_identity-metadata.ts @@ -0,0 +1,24 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityMetadata))) { + await knex.schema.createTable(TableName.IdentityMetadata, (tb) => { + tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + tb.string("key").notNullable(); + tb.string("value").notNullable(); + tb.uuid("orgId").notNullable(); + tb.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + tb.uuid("userId"); + tb.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + tb.uuid("identityId"); + tb.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + tb.timestamps(true, true, true); + }); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityMetadata); +} diff --git a/backend/src/db/schemas/identity-metadata.ts b/backend/src/db/schemas/identity-metadata.ts new file mode 100644 index 000000000..de1cec104 --- /dev/null +++ b/backend/src/db/schemas/identity-metadata.ts @@ -0,0 +1,23 @@ +// 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 IdentityMetadataSchema = z.object({ + id: z.string().uuid(), + key: z.string(), + value: z.string(), + orgId: z.string().uuid(), + userId: z.string().uuid().nullable().optional(), + identityId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityMetadata = z.infer; +export type TIdentityMetadataInsert = Omit, TImmutableDBKeys>; +export type TIdentityMetadataUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index d856cab49..4fcf26c1a 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -31,6 +31,7 @@ export * from "./identity-aws-auths"; export * from "./identity-azure-auths"; export * from "./identity-gcp-auths"; export * from "./identity-kubernetes-auths"; +export * from "./identity-metadata"; export * from "./identity-oidc-auths"; export * from "./identity-org-memberships"; export * from "./identity-project-additional-privilege"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 068ef74ad..08f3e79ce 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -70,6 +70,8 @@ export enum TableName { IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege", + // used by both identity and users + IdentityMetadata = "identity_metadata", ScimToken = "scim_tokens", AccessApprovalPolicy = "access_approval_policies", AccessApprovalPolicyApprover = "access_approval_policies_approvers", diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index bbb03f4ae..392046757 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -3,10 +3,11 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectMembershipRole, ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; +import { ProjectPermissionSchema } from "@app/ee/services/permission/project-permission"; import { PROJECT_ROLE } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { ProjectPermissionSchema, SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas"; +import { SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index 3d18a34cc..d96eb7c1f 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -100,6 +100,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { async (req, profile, cb) => { try { if (!profile) throw new BadRequestError({ message: "Missing profile" }); + const email = profile?.email ?? // entra sends data in this format @@ -123,6 +124,14 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { ); } + const userMetadata = Object.keys(profile.attributes || {}) + .map((key) => { + // for the ones like in format: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email + const formatedKey = key.startsWith("http") ? key.split("/").at(-1) || "" : key; + return { key: formatedKey, value: String((profile.attributes as Record)[key]) }; + }) + .filter((el) => el.key && !["email", "firstName", "lastName"].includes(el.key)); + const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ externalId: profile.nameID, email, @@ -130,7 +139,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { lastName: lastName as string, relayState: (req.body as { RelayState?: string }).RelayState, authProvider: (req as unknown as FastifyRequest).ssoConfig?.authProvider as string, - orgId: (req as unknown as FastifyRequest).ssoConfig?.orgId as string + orgId: (req as unknown as FastifyRequest).ssoConfig?.orgId as string, + metadata: userMetadata }); cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { 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 index 7f2798258..f5c1c857a 100644 --- 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 @@ -34,18 +34,12 @@ export type TIdentityProjectAdditionalPrivilegeServiceFactory = ReturnType< // TODO(akhilmhdh): move this to more centralized export const UnpackedPermissionSchema = z.object({ - subject: z.union([z.string().min(1), z.string().array()]).optional(), - action: z.union([z.string().min(1), z.string().array()]), - conditions: z - .object({ - environment: z.string().optional(), - secretPath: z - .object({ - $glob: z.string().min(1) - }) - .optional() - }) - .optional() + subject: z + .union([z.string().min(1), z.string().array()]) + .transform((el) => (typeof el !== "string" ? el[0] : el)) + .optional(), + action: z.union([z.string().min(1), z.string().array()]).transform((el) => (typeof el === "string" ? [el] : el)), + conditions: z.unknown().optional() }); const unpackPermissions = (permissions: unknown) => diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 735d8e8d6..8ad58f528 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -168,8 +168,14 @@ export const permissionDALFactory = (db: TDbClient) => { }) .join(TableName.Project, `${TableName.Project}.id`, db.raw("?", [projectId])) .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.Users}.id`, `${TableName.IdentityMetadata}.userId`) + .andOn(`${TableName.Organization}.id`, `${TableName.IdentityMetadata}.orgId`); + }) .select( db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("username").withSchema(TableName.Users).as("username"), // groups specific db.ref("id").withSchema(TableName.GroupProjectMembership).as("groupMembershipId"), db.ref("createdAt").withSchema(TableName.GroupProjectMembership).as("groupMembershipCreatedAt"), @@ -257,6 +263,9 @@ export const permissionDALFactory = (db: TDbClient) => { .withSchema(TableName.ProjectUserAdditionalPrivilege) .as("userAdditionalPrivilegesTemporaryAccessEndTime"), // general + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("orgId").withSchema(TableName.Project), db.ref("id").withSchema(TableName.Project).as("projectId") @@ -267,6 +276,7 @@ export const permissionDALFactory = (db: TDbClient) => { key: "projectId", parentMapper: ({ orgId, + username, orgAuthEnforced, membershipId, groupMembershipId, @@ -279,6 +289,7 @@ export const permissionDALFactory = (db: TDbClient) => { orgAuthEnforced, userId, projectId, + username, id: membershipId || groupMembershipId, createdAt: membershipCreatedAt || groupMembershipCreatedAt, updatedAt: membershipUpdatedAt || groupMembershipUpdatedAt @@ -354,6 +365,15 @@ export const permissionDALFactory = (db: TDbClient) => { temporaryAccessEndTime: userAdditionalPrivilegesTemporaryAccessEndTime, isTemporary: userAdditionalPrivilegesIsTemporary }) + }, + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) } ] }); @@ -399,6 +419,7 @@ export const permissionDALFactory = (db: TDbClient) => { `${TableName.IdentityProjectMembershipRole}.projectMembershipId`, `${TableName.IdentityProjectMembership}.id` ) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityProjectMembership}.identityId`) .leftJoin( TableName.ProjectRoles, `${TableName.IdentityProjectMembershipRole}.customRoleId`, @@ -415,11 +436,17 @@ export const permissionDALFactory = (db: TDbClient) => { `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id` ) - .where("identityId", identityId) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.Identity}.id`, `${TableName.IdentityMetadata}.identityId`) + .andOn(`${TableName.Project}.orgId`, `${TableName.IdentityMetadata}.orgId`); + }) + .where(`${TableName.IdentityProjectMembership}.identityId`, identityId) .where(`${TableName.IdentityProjectMembership}.projectId`, projectId) .select(selectAllTableCols(TableName.IdentityProjectMembershipRole)) .select( db.ref("id").withSchema(TableName.IdentityProjectMembership).as("membershipId"), + db.ref("name").withSchema(TableName.Identity).as("identityName"), db.ref("orgId").withSchema(TableName.Project).as("orgId"), // Now you can select orgId from Project db.ref("createdAt").withSchema(TableName.IdentityProjectMembership).as("membershipCreatedAt"), db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership).as("membershipUpdatedAt"), @@ -443,15 +470,19 @@ export const permissionDALFactory = (db: TDbClient) => { db .ref("temporaryAccessEndTime") .withSchema(TableName.IdentityProjectAdditionalPrivilege) - .as("identityApTemporaryAccessEndTime") + .as("identityApTemporaryAccessEndTime"), + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue") ); const permission = sqlNestRelationships({ data: docs, key: "membershipId", - parentMapper: ({ membershipId, membershipCreatedAt, membershipUpdatedAt, orgId }) => ({ + parentMapper: ({ membershipId, membershipCreatedAt, membershipUpdatedAt, orgId, identityName }) => ({ id: membershipId, identityId, + username: identityName, projectId, createdAt: membershipCreatedAt, updatedAt: membershipUpdatedAt, @@ -489,6 +520,15 @@ export const permissionDALFactory = (db: TDbClient) => { temporaryAccessStartTime: identityApTemporaryAccessStartTime, isTemporary: identityApIsTemporary }) + }, + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) } ] }); diff --git a/backend/src/ee/services/permission/permission-service-types.ts b/backend/src/ee/services/permission/permission-service-types.ts new file mode 100644 index 000000000..620e7a61c --- /dev/null +++ b/backend/src/ee/services/permission/permission-service-types.ts @@ -0,0 +1,9 @@ +export type TBuildProjectPermissionDTO = { + permissions?: unknown; + role: string; +}[]; + +export type TBuildOrgPermissionDTO = { + permissions?: unknown; + role: string; +}[]; diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 119ac6fca..af17f08b6 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -1,6 +1,7 @@ import { createMongoAbility, MongoAbility, RawRuleOf } from "@casl/ability"; import { PackRule, unpackRules } from "@casl/ability/extra"; import { MongoQuery } from "@ucast/mongo2js"; +import handlebars from "handlebars"; import { OrgMembershipRole, @@ -11,6 +12,7 @@ import { } from "@app/db/schemas"; import { conditionsMatcher } from "@app/lib/casl"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { objectify } from "@app/lib/fn"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -20,7 +22,7 @@ import { TServiceTokenDALFactory } from "@app/services/service-token/service-tok import { orgAdminPermissions, orgMemberPermissions, orgNoAccessPermissions, OrgPermissionSet } from "./org-permission"; import { TPermissionDALFactory } from "./permission-dal"; import { validateOrgSAML } from "./permission-fns"; -import { TBuildOrgPermissionDTO, TBuildProjectPermissionDTO } from "./permission-types"; +import { TBuildOrgPermissionDTO, TBuildProjectPermissionDTO } from "./permission-service-types"; import { buildServiceTokenProjectPermission, projectAdminPermissions, @@ -72,7 +74,7 @@ export const permissionServiceFactory = ({ }); }; - const buildProjectPermission = (projectUserRoles: TBuildProjectPermissionDTO) => { + const buildProjectPermissionRules = (projectUserRoles: TBuildProjectPermissionDTO) => { const rules = projectUserRoles .map(({ role, permissions }) => { switch (role) { @@ -98,9 +100,7 @@ export const permissionServiceFactory = ({ }) .reduce((curr, prev) => prev.concat(curr), []); - return createMongoAbility(rules, { - conditionsMatcher - }); + return rules; }; /* @@ -223,8 +223,32 @@ export const permissionServiceFactory = ({ permissions })) || []; + const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); + const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false, strict: true }); + const metadataKeyValuePair = objectify( + userProjectPermission.metadata, + (i) => i.key, + (i) => i.value + ); + const interpolateRules = templatedRules( + { + identity: { + id: userProjectPermission.userId, + username: userProjectPermission.username, + metadata: metadataKeyValuePair + } + }, + { data: false } + ); + const permission = createMongoAbility( + JSON.parse(interpolateRules) as RawRuleOf>[], + { + conditionsMatcher + } + ); + return { - permission: buildProjectPermission(rolePermissions.concat(additionalPrivileges)), + permission, membership: userProjectPermission, hasRole: (role: string) => userProjectPermission.roles.findIndex( @@ -262,8 +286,32 @@ export const permissionServiceFactory = ({ permissions })) || []; + const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); + const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false, strict: true }); + const metadataKeyValuePair = objectify( + identityProjectPermission.metadata, + (i) => i.key, + (i) => i.value + ); + const interpolateRules = templatedRules( + { + identity: { + id: identityProjectPermission.identityId, + username: identityProjectPermission.username, + metadata: metadataKeyValuePair + } + }, + { data: false } + ); + const permission = createMongoAbility( + JSON.parse(interpolateRules) as RawRuleOf>[], + { + conditionsMatcher + } + ); + return { - permission: buildProjectPermission(rolePermissions.concat(additionalPrivileges)), + permission, membership: identityProjectPermission, hasRole: (role: string) => identityProjectPermission.roles.findIndex( @@ -346,14 +394,22 @@ export const permissionServiceFactory = ({ if (isCustomRole) { const projectRole = await projectRoleDAL.findOne({ slug: role, projectId }); if (!projectRole) throw new NotFoundError({ message: `Specified role was not found: ${role}` }); + const rules = buildProjectPermissionRules([ + { role: ProjectMembershipRole.Custom, permissions: projectRole.permissions } + ]); return { - permission: buildProjectPermission([ - { role: ProjectMembershipRole.Custom, permissions: projectRole.permissions } - ]), + permission: createMongoAbility(rules, { + conditionsMatcher + }), role: projectRole }; } - return { permission: buildProjectPermission([{ role, permissions: [] }]) }; + + const rules = buildProjectPermissionRules([{ role, permissions: [] }]); + const permission = createMongoAbility(rules, { + conditionsMatcher + }); + return { permission }; }; return { @@ -364,6 +420,6 @@ export const permissionServiceFactory = ({ getOrgPermissionByRole, getProjectPermissionByRole, buildOrgPermission, - buildProjectPermission + buildProjectPermissionRules }; }; diff --git a/backend/src/ee/services/permission/permission-types.ts b/backend/src/ee/services/permission/permission-types.ts index 620e7a61c..60fcbec85 100644 --- a/backend/src/ee/services/permission/permission-types.ts +++ b/backend/src/ee/services/permission/permission-types.ts @@ -1,9 +1,47 @@ -export type TBuildProjectPermissionDTO = { - permissions?: unknown; - role: string; -}[]; +import picomatch from "picomatch"; +import { z } from "zod"; -export type TBuildOrgPermissionDTO = { - permissions?: unknown; - role: string; -}[]; +export enum PermissionConditionOperators { + $IN = "$in", + $ALL = "$all", + $REGEX = "$regex", + $EQ = "$eq", + $NEQ = "$ne", + $GLOB = "$glob" +} + +export const PermissionConditionSchema = { + [PermissionConditionOperators.$IN]: z.string().min(1).array(), + [PermissionConditionOperators.$ALL]: z.string().min(1).array(), + [PermissionConditionOperators.$REGEX]: z + .string() + .min(1) + .refine( + (el) => { + try { + // eslint-disable-next-line no-new + new RegExp(el); + return true; + } catch { + return false; + } + }, + { message: "Invalid regex pattern" } + ), + [PermissionConditionOperators.$EQ]: z.string().min(1), + [PermissionConditionOperators.$NEQ]: z.string().min(1), + [PermissionConditionOperators.$GLOB]: z + .string() + .min(1) + .refine( + (el) => { + try { + picomatch.parse([el]); + return true; + } catch { + return false; + } + }, + { message: "Invalid glob pattern" } + ) +}; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 60daa14c4..7c9e75ded 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -1,8 +1,12 @@ import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability"; +import { z } from "zod"; +import { TableName } from "@app/db/schemas"; import { conditionsMatcher } from "@app/lib/casl"; import { BadRequestError } from "@app/lib/errors"; +import { PermissionConditionOperators, PermissionConditionSchema } from "./permission-types"; + export enum ProjectPermissionActions { Read = "read", Create = "create", @@ -37,7 +41,25 @@ export enum ProjectPermissionSub { Kms = "kms" } -type SubjectFields = { +export type SecretSubjectFields = { + environment: string; + secretPath: string; + // secretName: string; + // secretTags: string[]; +}; + +export const CaslSecretsV2SubjectKnexMapper = (field: string) => { + switch (field) { + case "secretName": + return `${TableName.SecretV2}.key`; + case "secretTags": + return `${TableName.SecretTag}.slug`; + default: + break; + } +}; + +export type SecretFolderSubjectFields = { environment: string; secretPath: string; }; @@ -45,11 +67,14 @@ type SubjectFields = { export type ProjectPermissionSet = | [ ProjectPermissionActions, - ProjectPermissionSub.Secrets | (ForcedSubject & SubjectFields) + ProjectPermissionSub.Secrets | (ForcedSubject & SecretSubjectFields) ] | [ ProjectPermissionActions, - ProjectPermissionSub.SecretFolders | (ForcedSubject & SubjectFields) + ( + | ProjectPermissionSub.SecretFolders + | (ForcedSubject & SecretFolderSubjectFields) + ) ] | [ProjectPermissionActions, ProjectPermissionSub.Role] | [ProjectPermissionActions, ProjectPermissionSub.Tags] @@ -76,128 +101,230 @@ export type ProjectPermissionSet = | [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms]; -export const fullProjectPermissionSet: [ProjectPermissionActions, ProjectPermissionSub][] = [ - [ProjectPermissionActions.Read, ProjectPermissionSub.Secrets], - [ProjectPermissionActions.Create, ProjectPermissionSub.Secrets], - [ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets], - [ProjectPermissionActions.Delete, ProjectPermissionSub.Secrets], +const CASL_ACTION_SCHEMA_NATIVE_ENUM = (actions: ACTION) => + z + .union([z.nativeEnum(actions), z.nativeEnum(actions).array().min(1)]) + .transform((el) => (typeof el === "string" ? [el] : el)); - [ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval], - [ProjectPermissionActions.Create, ProjectPermissionSub.SecretApproval], - [ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval], - [ProjectPermissionActions.Delete, ProjectPermissionSub.SecretApproval], +const CASL_ACTION_SCHEMA_ENUM = (actions: ACTION) => + z.union([z.enum(actions), z.enum(actions).array().min(1)]).transform((el) => (typeof el === "string" ? [el] : el)); - [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation], - [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRotation], - [ProjectPermissionActions.Edit, ProjectPermissionSub.SecretRotation], - [ProjectPermissionActions.Delete, ProjectPermissionSub.SecretRotation], +const SecretConditionSchema = z + .object({ + environment: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + }) + .partial() + ]), + secretPath: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]) + }) + .partial(); - [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback], - [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback], - - [ProjectPermissionActions.Read, ProjectPermissionSub.Member], - [ProjectPermissionActions.Create, ProjectPermissionSub.Member], - [ProjectPermissionActions.Edit, ProjectPermissionSub.Member], - [ProjectPermissionActions.Delete, ProjectPermissionSub.Member], - - [ProjectPermissionActions.Read, ProjectPermissionSub.Groups], - [ProjectPermissionActions.Create, ProjectPermissionSub.Groups], - [ProjectPermissionActions.Edit, ProjectPermissionSub.Groups], - [ProjectPermissionActions.Delete, ProjectPermissionSub.Groups], - - [ProjectPermissionActions.Read, ProjectPermissionSub.Role], - [ProjectPermissionActions.Create, ProjectPermissionSub.Role], - [ProjectPermissionActions.Edit, ProjectPermissionSub.Role], - [ProjectPermissionActions.Delete, ProjectPermissionSub.Role], - - [ProjectPermissionActions.Read, ProjectPermissionSub.Integrations], - [ProjectPermissionActions.Create, ProjectPermissionSub.Integrations], - [ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations], - [ProjectPermissionActions.Delete, ProjectPermissionSub.Integrations], - - [ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks], - [ProjectPermissionActions.Create, ProjectPermissionSub.Webhooks], - [ProjectPermissionActions.Edit, ProjectPermissionSub.Webhooks], - [ProjectPermissionActions.Delete, ProjectPermissionSub.Webhooks], - - [ProjectPermissionActions.Read, ProjectPermissionSub.Identity], - [ProjectPermissionActions.Create, ProjectPermissionSub.Identity], - [ProjectPermissionActions.Edit, ProjectPermissionSub.Identity], - [ProjectPermissionActions.Delete, ProjectPermissionSub.Identity], - - [ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens], - [ProjectPermissionActions.Create, ProjectPermissionSub.ServiceTokens], - [ProjectPermissionActions.Edit, ProjectPermissionSub.ServiceTokens], - [ProjectPermissionActions.Delete, ProjectPermissionSub.ServiceTokens], - - [ProjectPermissionActions.Read, ProjectPermissionSub.Settings], - [ProjectPermissionActions.Create, ProjectPermissionSub.Settings], - [ProjectPermissionActions.Edit, ProjectPermissionSub.Settings], - [ProjectPermissionActions.Delete, ProjectPermissionSub.Settings], - - [ProjectPermissionActions.Read, ProjectPermissionSub.Environments], - [ProjectPermissionActions.Create, ProjectPermissionSub.Environments], - [ProjectPermissionActions.Edit, ProjectPermissionSub.Environments], - [ProjectPermissionActions.Delete, ProjectPermissionSub.Environments], - - [ProjectPermissionActions.Read, ProjectPermissionSub.Tags], - [ProjectPermissionActions.Create, ProjectPermissionSub.Tags], - [ProjectPermissionActions.Edit, ProjectPermissionSub.Tags], - [ProjectPermissionActions.Delete, ProjectPermissionSub.Tags], - - // TODO(Daniel): Remove the audit logs permissions from project-level permissions. - // TODO: We haven't done this yet because it might break existing roles, since those roles will become "invalid" since the audit log permission defined on those roles, no longer exist in the project-level defined permissions. - [ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs], - [ProjectPermissionActions.Create, ProjectPermissionSub.AuditLogs], - [ProjectPermissionActions.Edit, ProjectPermissionSub.AuditLogs], - [ProjectPermissionActions.Delete, ProjectPermissionSub.AuditLogs], - - [ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList], - [ProjectPermissionActions.Create, ProjectPermissionSub.IpAllowList], - [ProjectPermissionActions.Edit, ProjectPermissionSub.IpAllowList], - [ProjectPermissionActions.Delete, ProjectPermissionSub.IpAllowList], - - // double check if all CRUD are needed for CA and Certificates - [ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities], - [ProjectPermissionActions.Create, ProjectPermissionSub.CertificateAuthorities], - [ProjectPermissionActions.Edit, ProjectPermissionSub.CertificateAuthorities], - [ProjectPermissionActions.Delete, ProjectPermissionSub.CertificateAuthorities], - - [ProjectPermissionActions.Read, ProjectPermissionSub.Certificates], - [ProjectPermissionActions.Create, ProjectPermissionSub.Certificates], - [ProjectPermissionActions.Edit, ProjectPermissionSub.Certificates], - [ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates], - - [ProjectPermissionActions.Read, ProjectPermissionSub.CertificateTemplates], - [ProjectPermissionActions.Create, ProjectPermissionSub.CertificateTemplates], - [ProjectPermissionActions.Edit, ProjectPermissionSub.CertificateTemplates], - [ProjectPermissionActions.Delete, ProjectPermissionSub.CertificateTemplates], - - [ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts], - [ProjectPermissionActions.Create, ProjectPermissionSub.PkiAlerts], - [ProjectPermissionActions.Edit, ProjectPermissionSub.PkiAlerts], - [ProjectPermissionActions.Delete, ProjectPermissionSub.PkiAlerts], - - [ProjectPermissionActions.Read, ProjectPermissionSub.PkiCollections], - [ProjectPermissionActions.Create, ProjectPermissionSub.PkiCollections], - [ProjectPermissionActions.Edit, ProjectPermissionSub.PkiCollections], - [ProjectPermissionActions.Delete, ProjectPermissionSub.PkiCollections], - - [ProjectPermissionActions.Edit, ProjectPermissionSub.Project], - [ProjectPermissionActions.Delete, ProjectPermissionSub.Project], - - [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms] -]; +export const ProjectPermissionSchema = z.discriminatedUnion("subject", [ + z.object({ + subject: z.literal(ProjectPermissionSub.Secrets).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ), + conditions: SecretConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretRollback).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_ENUM([ProjectPermissionActions.Read, ProjectPermissionActions.Create]).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.Member).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.Groups).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.Role).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.Integrations).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.Webhooks).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.Identity).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.ServiceTokens).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.Settings).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.Environments).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.Tags).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.AuditLogs).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.IpAllowList).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.CertificateAuthorities).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.Certificates).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.CertificateTemplates).describe("The entity this permission pertains to. "), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.PkiAlerts).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.PkiCollections).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.Project).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_ENUM([ProjectPermissionActions.Edit, ProjectPermissionActions.Delete]).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.Kms).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_ENUM([ProjectPermissionActions.Edit]).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretFolders).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_ENUM([ProjectPermissionActions.Read]).describe( + "Describe what action an entity can take." + ) + }) +]); const buildAdminPermissionRules = () => { const { can, rules } = new AbilityBuilder>(createMongoAbility); // Admins get full access to everything - fullProjectPermissionSet.forEach((permission) => { - const [action, subject] = permission; - can(action, subject); + [ + ProjectPermissionSub.Secrets, + ProjectPermissionSub.SecretApproval, + ProjectPermissionSub.SecretRotation, + ProjectPermissionSub.Member, + ProjectPermissionSub.Groups, + ProjectPermissionSub.Role, + ProjectPermissionSub.Integrations, + ProjectPermissionSub.Webhooks, + ProjectPermissionSub.Identity, + ProjectPermissionSub.ServiceTokens, + ProjectPermissionSub.Settings, + ProjectPermissionSub.Environments, + ProjectPermissionSub.Tags, + ProjectPermissionSub.AuditLogs, + ProjectPermissionSub.IpAllowList, + ProjectPermissionSub.CertificateAuthorities, + ProjectPermissionSub.Certificates, + ProjectPermissionSub.CertificateTemplates, + ProjectPermissionSub.PkiAlerts, + ProjectPermissionSub.PkiCollections + ].forEach((el) => { + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + el as ProjectPermissionSub + ); }); + can([ProjectPermissionActions.Edit, ProjectPermissionActions.Delete], ProjectPermissionSub.Project); + can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); + can([ProjectPermissionActions.Edit], ProjectPermissionSub.Kms); return rules; }; @@ -206,73 +333,116 @@ export const projectAdminPermissions = buildAdminPermissionRules(); const buildMemberPermissionRules = () => { const { can, rules } = new AbilityBuilder>(createMongoAbility); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); - can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets); - can(ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets); - can(ProjectPermissionActions.Delete, ProjectPermissionSub.Secrets); + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.Secrets + ); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); + can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval); + can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretRotation); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); - can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback); + can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); - can(ProjectPermissionActions.Create, ProjectPermissionSub.Member); + can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.Member); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); + can([ProjectPermissionActions.Read], ProjectPermissionSub.Groups); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); - can(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); - can(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); - can(ProjectPermissionActions.Delete, ProjectPermissionSub.Integrations); + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.Integrations + ); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); - can(ProjectPermissionActions.Create, ProjectPermissionSub.Webhooks); - can(ProjectPermissionActions.Edit, ProjectPermissionSub.Webhooks); - can(ProjectPermissionActions.Delete, ProjectPermissionSub.Webhooks); + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.Webhooks + ); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); - can(ProjectPermissionActions.Create, ProjectPermissionSub.Identity); - can(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); - can(ProjectPermissionActions.Delete, ProjectPermissionSub.Identity); + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.Identity + ); - can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); - can(ProjectPermissionActions.Create, ProjectPermissionSub.ServiceTokens); - can(ProjectPermissionActions.Edit, ProjectPermissionSub.ServiceTokens); - can(ProjectPermissionActions.Delete, ProjectPermissionSub.ServiceTokens); + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.ServiceTokens + ); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); - can(ProjectPermissionActions.Create, ProjectPermissionSub.Settings); - can(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); - can(ProjectPermissionActions.Delete, ProjectPermissionSub.Settings); + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.Settings + ); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); - can(ProjectPermissionActions.Create, ProjectPermissionSub.Environments); - can(ProjectPermissionActions.Edit, ProjectPermissionSub.Environments); - can(ProjectPermissionActions.Delete, ProjectPermissionSub.Environments); + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.Environments + ); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); - can(ProjectPermissionActions.Create, ProjectPermissionSub.Tags); - can(ProjectPermissionActions.Edit, ProjectPermissionSub.Tags); - can(ProjectPermissionActions.Delete, ProjectPermissionSub.Tags); + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.Tags + ); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); - can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); - can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); + can([ProjectPermissionActions.Read], ProjectPermissionSub.Role); + can([ProjectPermissionActions.Read], ProjectPermissionSub.AuditLogs); + can([ProjectPermissionActions.Read], ProjectPermissionSub.IpAllowList); // double check if all CRUD are needed for CA and Certificates - can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); + can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateAuthorities); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); - can(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); - can(ProjectPermissionActions.Edit, ProjectPermissionSub.Certificates); - can(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete + ], + ProjectPermissionSub.Certificates + ); - can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateTemplates); + can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateTemplates); - can(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); - can(ProjectPermissionActions.Read, ProjectPermissionSub.PkiCollections); + can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); + can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); return rules; }; @@ -382,32 +552,19 @@ export const isAtLeastAsPrivilegedWorkspace = ( return set1.size >= set2.size; }; +/* eslint-enable */ -/* - * Case: The user requests to create a role with permissions that are not valid and not supposed to be used ever. - * If we don't check for this, we can run into issues where functions like the `isAtLeastAsPrivileged` will not work as expected, because we compare the size of each permission set. - * If the permission set contains invalid permissions, the size will be different, and result in incorrect results. - */ -export const validateProjectPermissions = (permissions: unknown) => { - const parsedPermissions = - typeof permissions === "string" ? (JSON.parse(permissions) as string[]) : (permissions as string[]); - - const flattenedPermissions = [...parsedPermissions]; - - for (const perm of flattenedPermissions) { - const [action, subject] = perm; - - if ( - !fullProjectPermissionSet.find( - (currentPermission) => currentPermission[0] === action && currentPermission[1] === subject - ) - ) { - throw new BadRequestError({ - message: `Permission action ${action} on subject ${subject} is not valid`, - name: "Create Role" - }); - } +export const SecretV2SubjectFieldMapper = (arg: string) => { + switch (arg) { + case "environment": + return null; + case "secretPath": + return null; + case "secretName": + return `${TableName.SecretV2}.key`; + case "secretTags": + return `${TableName.SecretTag}.slug`; + default: + throw new BadRequestError({ message: `Invalid dynamic knex operator field: ${arg}` }); } }; - -/* eslint-enable */ diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index dd184f01f..0c979d023 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -23,6 +23,7 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/ import { AuthTokenType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TokenType } from "@app/services/auth-token/auth-token-types"; +import { TIdentityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; @@ -51,6 +52,8 @@ type TSamlConfigServiceFactoryDep = { TOrgDALFactory, "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" >; + + identityMetadataDAL: Pick; orgMembershipDAL: Pick; orgBotDAL: Pick; permissionService: Pick; @@ -71,7 +74,8 @@ export const samlConfigServiceFactory = ({ permissionService, licenseService, tokenService, - smtpService + smtpService, + identityMetadataDAL }: TSamlConfigServiceFactoryDep) => { const createSamlCfg = async ({ cert, @@ -332,7 +336,8 @@ export const samlConfigServiceFactory = ({ lastName, authProvider, orgId, - relayState + relayState, + metadata }: TSamlLoginDTO) => { const appCfg = getConfig(); const serverCfg = await getServerCfg(); @@ -386,6 +391,21 @@ export const samlConfigServiceFactory = ({ ); } + if (metadata && foundUser.id) { + await identityMetadataDAL.delete({ userId: foundUser.id, orgId }, tx); + if (metadata.length) { + await identityMetadataDAL.insertMany( + metadata.map(({ key, value }) => ({ + userId: foundUser.id, + orgId, + key, + value + })), + tx + ); + } + } + return foundUser; }); } else { @@ -474,6 +494,20 @@ export const samlConfigServiceFactory = ({ ); } + if (metadata && newUser.id) { + await identityMetadataDAL.delete({ userId: newUser.id, orgId }, tx); + if (metadata.length) { + await identityMetadataDAL.insertMany( + metadata.map(({ key, value }) => ({ + userId: newUser?.id, + orgId, + key, + value + })), + tx + ); + } + } return newUser; }); } diff --git a/backend/src/ee/services/saml-config/saml-config-types.ts b/backend/src/ee/services/saml-config/saml-config-types.ts index 92ee32b5c..96cb91035 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -53,4 +53,5 @@ export type TSamlLoginDTO = { orgId: string; // saml thingy relayState?: string; + metadata?: { key: string; value: string }[]; }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index cc189cb1c..75c3dc32a 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -360,7 +360,11 @@ export const ORGANIZATIONS = { organizationId: "The ID of the organization to update the membership for.", membershipId: "The ID of the membership to update.", role: "The new role of the membership.", - isActive: "The active status of the membership" + isActive: "The active status of the membership", + metadata: { + key: "The key for user metadata tag.", + value: "The value for user metadata tag." + } }, DELETE_USER_MEMBERSHIP: { organizationId: "The ID of the organization to delete the membership from.", diff --git a/backend/src/lib/casl/knex.ts b/backend/src/lib/casl/knex.ts new file mode 100644 index 000000000..cc9718fe0 --- /dev/null +++ b/backend/src/lib/casl/knex.ts @@ -0,0 +1,111 @@ +import { AnyAbility, ExtractSubjectType } from "@casl/ability"; +import { AbilityQuery, rulesToQuery } from "@casl/ability/extra"; +import { Tables } from "knex/types/tables"; + +import { BadRequestError, UnauthorizedError } from "../errors"; +import { TKnexDynamicOperator } from "../knex/dynamic"; + +type TBuildKnexQueryFromCaslDTO = { + ability: K; + subject: ExtractSubjectType[1]>; + action: Parameters[0]; +}; + +export const buildKnexQueryFromCaslOperators = ({ + ability, + subject, + action +}: TBuildKnexQueryFromCaslDTO) => { + const query = rulesToQuery(ability, action, subject, (rule) => { + if (!rule.ast) throw new Error("Ast not defined"); + return rule.ast; + }); + + if (query === null) throw new UnauthorizedError({ message: `You don't have permission to do ${action} ${subject}` }); + return query; +}; + +type TFieldMapper = { + [K in T]: `${K}.${Exclude}`; +}[T]; + +type TFormatCaslFieldsWithTableNames = { + // handle if any missing operator else throw error let the app break because this is executing again the db + missingOperatorCallback?: (operator: string) => void; + fieldMapping: (arg: string) => TFieldMapper | null; + dynamicQuery: TKnexDynamicOperator; +}; + +export const formatCaslOperatorFieldsWithTableNames = ({ + missingOperatorCallback = (arg) => { + throw new BadRequestError({ message: `Unknown permission operator: ${arg}` }); + }, + dynamicQuery: dynamicQueryAst, + fieldMapping +}: TFormatCaslFieldsWithTableNames) => { + const stack: [TKnexDynamicOperator, TKnexDynamicOperator | null][] = [[dynamicQueryAst, null]]; + + while (stack.length) { + const [filterAst, parentAst] = stack.pop()!; + + if (filterAst.operator === "and" || filterAst.operator === "or" || filterAst.operator === "not") { + filterAst.value.forEach((el) => { + stack.push([el, filterAst]); + }); + + // eslint-disable-next-line no-continue + continue; + } + + if ( + filterAst.operator === "eq" || + filterAst.operator === "ne" || + filterAst.operator === "in" || + filterAst.operator === "endsWith" || + filterAst.operator === "startsWith" + ) { + const attrPath = fieldMapping(filterAst.field); + if (attrPath) { + filterAst.field = attrPath; + } else if (parentAst && Array.isArray(parentAst.value)) { + parentAst.value = parentAst.value.filter((childAst) => childAst !== filterAst) as string[]; + } else throw new Error("Unknown casl field"); + // eslint-disable-next-line no-continue + continue; + } + + if (parentAst && Array.isArray(parentAst.value)) { + parentAst.value = parentAst.value.filter((childAst) => childAst !== filterAst) as string[]; + } else { + missingOperatorCallback?.(filterAst.operator); + } + } + return dynamicQueryAst; +}; + +export const convertCaslOperatorToKnexOperator = ( + caslKnexOperators: AbilityQuery, + fieldMapping: (arg: string) => TFieldMapper | null +) => { + const value = []; + if (caslKnexOperators.$and) { + value.push({ + operator: "not" as const, + value: caslKnexOperators.$and as TKnexDynamicOperator[] + }); + } + if (caslKnexOperators.$or) { + value.push({ + operator: "or" as const, + value: caslKnexOperators.$or as TKnexDynamicOperator[] + }); + } + + return formatCaslOperatorFieldsWithTableNames({ + dynamicQuery: { + operator: "and", + value + }, + fieldMapping + }); +}; diff --git a/backend/src/lib/fn/array.ts b/backend/src/lib/fn/array.ts index 00819ceb8..959d01aef 100644 --- a/backend/src/lib/fn/array.ts +++ b/backend/src/lib/fn/array.ts @@ -52,3 +52,21 @@ export const unique = (array: readonly T[ ); return Object.values(valueMap); }; + +/** + * Convert an array to a dictionary by mapping each item + * into a dictionary key & value + */ +export const objectify = ( + array: readonly T[], + getKey: (item: T) => Key, + getValue: (item: T) => Value = (item) => item as unknown as Value +): Record => { + return array.reduce( + (acc, item) => { + acc[getKey(item)] = getValue(item); + return acc; + }, + {} as Record + ); +}; diff --git a/backend/src/lib/knex/dynamic.ts b/backend/src/lib/knex/dynamic.ts new file mode 100644 index 000000000..c336d7a9e --- /dev/null +++ b/backend/src/lib/knex/dynamic.ts @@ -0,0 +1,89 @@ +import { Knex } from "knex"; + +import { UnauthorizedError } from "../errors"; + +type TKnexDynamicPrimitiveOperator = { + operator: "eq" | "ne" | "startsWith" | "endsWith"; + value: string; + field: string; +}; + +type TKnexDynamicInOperator = { + operator: "in"; + value: string[] | number[]; + field: string; +}; + +type TKnexNonGroupOperator = TKnexDynamicInOperator | TKnexDynamicPrimitiveOperator; + +type TKnexGroupOperator = { + operator: "and" | "or" | "not"; + value: (TKnexNonGroupOperator | TKnexGroupOperator)[]; +}; + +// akhilmhdh: This is still in pending state and not yet ready. If you want to use it ping me. +// used when you need to write a complex query with the orm +// use it when you need complex or and and condition - most of the time not needed +// majorly used with casl permission to filter data based on permission +export type TKnexDynamicOperator = TKnexGroupOperator | TKnexNonGroupOperator; + +export const buildDynamicKnexQuery = (dynamicQuery: TKnexDynamicOperator, rootQueryBuild: Knex.QueryBuilder) => { + const stack = [{ filterAst: dynamicQuery, queryBuilder: rootQueryBuild }]; + + while (stack.length) { + const { filterAst, queryBuilder } = stack.pop()!; + switch (filterAst.operator) { + case "eq": { + void queryBuilder.where(filterAst.field, "=", filterAst.value); + break; + } + case "ne": { + void queryBuilder.whereNot(filterAst.field, filterAst.value); + break; + } + case "startsWith": { + void queryBuilder.whereILike(filterAst.field, `${filterAst.value}%`); + break; + } + case "endsWith": { + void queryBuilder.whereILike(filterAst.field, `%${filterAst.value}`); + break; + } + case "and": { + void queryBuilder.andWhere((subQueryBuilder) => { + filterAst.value.forEach((el) => { + stack.push({ + queryBuilder: subQueryBuilder, + filterAst: el + }); + }); + }); + break; + } + case "or": { + void queryBuilder.orWhere((subQueryBuilder) => { + filterAst.value.forEach((el) => { + stack.push({ + queryBuilder: subQueryBuilder, + filterAst: el + }); + }); + }); + break; + } + case "not": { + void queryBuilder.whereNot((subQueryBuilder) => { + filterAst.value.forEach((el) => { + stack.push({ + queryBuilder: subQueryBuilder, + filterAst: el + }); + }); + }); + break; + } + default: + throw new UnauthorizedError({ message: `Invalid knex dynamic operator: ${filterAst.operator}` }); + } + } +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index eb2b00120..64b242c40 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -101,6 +101,7 @@ import { groupProjectDALFactory } from "@app/services/group-project/group-projec import { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal"; import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { identityDALFactory } from "@app/services/identity/identity-dal"; +import { identityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal"; import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal"; import { identityServiceFactory } from "@app/services/identity/identity-service"; import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal"; @@ -265,6 +266,7 @@ export const registerRoutes = async ( const serviceTokenDAL = serviceTokenDALFactory(db); const identityDAL = identityDALFactory(db); + const identityMetadataDAL = identityMetadataDALFactory(db); const identityAccessTokenDAL = identityAccessTokenDALFactory(db); const identityOrgMembershipDAL = identityOrgDALFactory(db); const identityProjectDAL = identityProjectDALFactory(db); @@ -386,6 +388,7 @@ export const registerRoutes = async ( const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL, orgMembershipDAL }); const samlService = samlConfigServiceFactory({ + identityMetadataDAL, permissionService, orgBotDAL, orgDAL, @@ -489,6 +492,7 @@ export const registerRoutes = async ( }); const orgService = orgServiceFactory({ userAliasDAL, + identityMetadataDAL, licenseService, samlConfigDAL, orgRoleDAL, @@ -1027,7 +1031,8 @@ export const registerRoutes = async ( identityDAL, identityOrgMembershipDAL, identityProjectDAL, - licenseService + licenseService, + identityMetadataDAL }); const identityAccessTokenService = identityAccessTokenServiceFactory({ diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 303c58621..f8e02365a 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -200,7 +200,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { ) ); - if (includeDynamicSecrets) { + if (includeDynamicSecrets && permissiveEnvs.length) { // this is the unique count, ie duplicate secrets across envs only count as 1 totalDynamicSecretCount = await server.services.dynamicSecret.getCountMultiEnv({ actor: req.permission.type, @@ -241,7 +241,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { } } - if (includeSecrets) { + if (includeSecrets && permissiveEnvs.length) { // this is the unique count, ie duplicate secrets across envs only count as 1 totalSecretCount = await server.services.secret.getSecretsCountMultiEnv({ actorId: req.permission.id, diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index cf72ba2ff..163b560e2 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -29,7 +29,11 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { body: z.object({ name: z.string().trim().describe(IDENTITIES.CREATE.name), organizationId: z.string().trim().describe(IDENTITIES.CREATE.organizationId), - role: z.string().trim().min(1).default(OrgMembershipRole.NoAccess).describe(IDENTITIES.CREATE.role) + role: z.string().trim().min(1).default(OrgMembershipRole.NoAccess).describe(IDENTITIES.CREATE.role), + metadata: z + .object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }) + .array() + .optional() }), response: { 200: z.object({ @@ -93,7 +97,11 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { }), body: z.object({ name: z.string().trim().optional().describe(IDENTITIES.UPDATE.name), - role: z.string().trim().min(1).optional().describe(IDENTITIES.UPDATE.role) + role: z.string().trim().min(1).optional().describe(IDENTITIES.UPDATE.role), + metadata: z + .object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }) + .array() + .optional() }), response: { 200: z.object({ @@ -193,6 +201,14 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ identity: IdentityOrgMembershipsSchema.extend({ + metadata: z + .object({ + key: z.string().trim().min(1), + id: z.string().trim().min(1), + value: z.string().trim().min(1) + }) + .array() + .optional(), customRole: OrgRolesSchema.pick({ id: true, name: true, diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index d237bca22..5d34bc702 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -130,18 +130,24 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - membership: OrgMembershipsSchema.merge( - z.object({ - user: UsersSchema.pick({ - username: true, - email: true, - isEmailVerified: true, - firstName: true, - lastName: true, - id: true - }).merge(z.object({ publicKey: z.string().nullable() })) - }) - ).omit({ createdAt: true, updatedAt: true }) + membership: OrgMembershipsSchema.extend({ + metadata: z + .object({ + key: z.string().trim().min(1), + id: z.string().trim().min(1), + value: z.string().trim().min(1) + }) + .array() + .optional(), + user: UsersSchema.pick({ + username: true, + email: true, + isEmailVerified: true, + firstName: true, + lastName: true, + id: true + }).extend({ publicKey: z.string().nullable() }) + }).omit({ createdAt: true, updatedAt: true }) }) } }, @@ -178,7 +184,14 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }), body: z.object({ role: z.string().trim().optional().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.role), - isActive: z.boolean().optional().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.isActive) + isActive: z.boolean().optional().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.isActive), + metadata: z + .object({ + key: z.string().trim().min(1).describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.metadata.key), + value: z.string().trim().min(1).describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.metadata.value) + }) + .array() + .optional() }), response: { 200: z.object({ diff --git a/backend/src/services/identity/identity-metadata-dal.ts b/backend/src/services/identity/identity-metadata-dal.ts new file mode 100644 index 000000000..535420cf3 --- /dev/null +++ b/backend/src/services/identity/identity-metadata-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 TIdentityMetadataDALFactory = ReturnType; + +export const identityMetadataDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.IdentityMetadata); + return orm; +}; diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index f309d3e29..0422a4b5d 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -3,7 +3,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { TableName, TIdentityOrgMemberships } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; import { OrderByDirection } from "@app/lib/types"; import { TListOrgIdentitiesByOrgIdDTO } from "@app/services/identity/identity-types"; @@ -42,10 +42,25 @@ export const identityOrgDALFactory = (db: TDbClient) => { tx?: Knex ) => { try { + const paginatedFetchIdentity = (tx || db.replicaNode())(TableName.Identity) + .where((queryBuilder) => { + if (limit) { + void queryBuilder.offset(offset).limit(limit); + } + }) + .as(TableName.Identity); + const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership) .where(filter) - .join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`) + .join>(paginatedFetchIdentity, (queryBuilder) => { + queryBuilder.on(`${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`); + }) .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityMetadata}.identityId`) + .andOn(`${TableName.IdentityOrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`); + }) .select(selectAllTableCols(TableName.IdentityOrgMembership)) // cr stands for custom role .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) @@ -55,12 +70,15 @@ export const identityOrgDALFactory = (db: TDbClient) => { .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)) .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)) .select(db.ref("id").as("identityId").withSchema(TableName.Identity)) - .select(db.ref("name").as("identityName").withSchema(TableName.Identity)) - .select(db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity)); - - if (limit) { - void query.offset(offset).limit(limit); - } + .select( + db.ref("name").as("identityName").withSchema(TableName.Identity), + db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity) + ) + .select( + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue") + ); if (orderBy) { switch (orderBy) { @@ -80,9 +98,10 @@ export const identityOrgDALFactory = (db: TDbClient) => { } const docs = await query; - - return docs.map( - ({ + const formattedDocs = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: ({ crId, crDescription, crSlug, @@ -91,16 +110,21 @@ export const identityOrgDALFactory = (db: TDbClient) => { identityId, identityName, identityAuthMethod, - ...el + role, + roleId, + id, + orgId, + createdAt, + updatedAt }) => ({ - ...el, + role, + roleId, identityId, - identity: { - id: identityId, - name: identityName, - authMethod: identityAuthMethod - }, - customRole: el.roleId + id, + orgId, + createdAt, + updatedAt, + customRole: roleId ? { id: crId, name: crName, @@ -108,9 +132,27 @@ export const identityOrgDALFactory = (db: TDbClient) => { permissions: crPermission, description: crDescription } - : undefined - }) - ); + : undefined, + identity: { + id: identityId, + name: identityName, + authMethod: identityAuthMethod as string + } + }), + childrenMapper: [ + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return formattedDocs; } catch (error) { throw new DatabaseError({ error, name: "FindByOrgId" }); } diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index b9cda4840..cb244898f 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -10,6 +10,7 @@ import { TIdentityProjectDALFactory } from "@app/services/identity-project/ident import { ActorType } from "../auth/auth-type"; import { TIdentityDALFactory } from "./identity-dal"; +import { TIdentityMetadataDALFactory } from "./identity-metadata-dal"; import { TIdentityOrgDALFactory } from "./identity-org-dal"; import { TCreateIdentityDTO, @@ -22,6 +23,7 @@ import { type TIdentityServiceFactoryDep = { identityDAL: TIdentityDALFactory; + identityMetadataDAL: TIdentityMetadataDALFactory; identityOrgMembershipDAL: TIdentityOrgDALFactory; identityProjectDAL: Pick; permissionService: Pick; @@ -32,6 +34,7 @@ export type TIdentityServiceFactory = ReturnType; export const identityServiceFactory = ({ identityDAL, + identityMetadataDAL, identityOrgMembershipDAL, identityProjectDAL, permissionService, @@ -44,7 +47,8 @@ export const identityServiceFactory = ({ orgId, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + metadata }: TCreateIdentityDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); @@ -78,6 +82,17 @@ export const identityServiceFactory = ({ }, tx ); + if (metadata && metadata.length) { + await identityMetadataDAL.insertMany( + metadata.map(({ key, value }) => ({ + identityId: newIdentity.id, + orgId, + key, + value + })), + tx + ); + } return newIdentity; }); await licenseService.updateSubscriptionOrgMemberCount(orgId); @@ -92,7 +107,8 @@ export const identityServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + metadata }: TUpdateIdentityDTO) => { const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: id }); if (!identityOrgMembership) throw new NotFoundError({ message: `Failed to find identity with id ${id}` }); @@ -134,8 +150,8 @@ export const identityServiceFactory = ({ const identity = await identityDAL.transaction(async (tx) => { const newIdentity = name ? await identityDAL.updateById(id, { name }, tx) : await identityDAL.findById(id, tx); if (role) { - await identityOrgMembershipDAL.update( - { identityId: id }, + await identityOrgMembershipDAL.updateById( + identityOrgMembership.id, { role: customRole ? OrgMembershipRole.Custom : role, roleId: customRole?.id || null @@ -143,6 +159,20 @@ export const identityServiceFactory = ({ tx ); } + if (metadata) { + await identityMetadataDAL.delete({ orgId: identityOrgMembership.orgId, identityId: id }, tx); + if (metadata.length) { + await identityMetadataDAL.insertMany( + metadata.map(({ key, value }) => ({ + identityId: newIdentity.id, + orgId: identityOrgMembership.orgId, + key, + value + })), + tx + ); + } + } return newIdentity; }); diff --git a/backend/src/services/identity/identity-types.ts b/backend/src/services/identity/identity-types.ts index 8a5a81ba5..23110c3bd 100644 --- a/backend/src/services/identity/identity-types.ts +++ b/backend/src/services/identity/identity-types.ts @@ -4,12 +4,14 @@ import { OrderByDirection, TOrgPermission } from "@app/lib/types"; export type TCreateIdentityDTO = { role: string; name: string; + metadata?: { key: string; value: string }[]; } & TOrgPermission; export type TUpdateIdentityDTO = { id: string; role?: string; name?: string; + metadata?: { key: string; value: string }[]; } & Omit; export type TDeleteIdentityDTO = { diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts index ccd995296..68b117202 100644 --- a/backend/src/services/org-membership/org-membership-dal.ts +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -1,7 +1,7 @@ import { TDbClient } from "@app/db"; import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify } from "@app/lib/knex"; +import { ormify, sqlNestRelationships } from "@app/lib/knex"; export type TOrgMembershipDALFactory = ReturnType; @@ -19,6 +19,11 @@ export const orgMembershipDALFactory = (db: TDbClient) => { `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id` ) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.OrgMembership}.userId`, `${TableName.IdentityMetadata}.userId`) + .andOn(`${TableName.OrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`); + }) .select( db.ref("id").withSchema(TableName.OrgMembership), db.ref("inviteEmail").withSchema(TableName.OrgMembership), @@ -33,19 +38,66 @@ export const orgMembershipDALFactory = (db: TDbClient) => { db.ref("lastName").withSchema(TableName.Users), db.ref("isEmailVerified").withSchema(TableName.Users), db.ref("id").withSchema(TableName.Users).as("userId"), - db.ref("publicKey").withSchema(TableName.UserEncryptionKey) + db.ref("publicKey").withSchema(TableName.UserEncryptionKey), + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue") ) - .where({ isGhost: false }) // MAKE SURE USER IS NOT A GHOST USER - .first(); + .where({ isGhost: false }); // MAKE SURE USER IS NOT A GHOST USER if (!member) return undefined; - const { email, isEmailVerified, username, firstName, lastName, userId, publicKey, ...data } = member; + const doc = sqlNestRelationships({ + data: member, + key: "id", + parentMapper: ({ + email, + isEmailVerified, + username, + firstName, + lastName, + userId, + publicKey, + roleId, + orgId, + id, + role, + status, + isActive, + inviteEmail + }) => ({ + roleId, + orgId, + id, + role, + status, + isActive, + inviteEmail, + user: { + id: userId, + email, + isEmailVerified, + username, + firstName, + lastName, + userId, + publicKey + } + }), + childrenMapper: [ + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); - return { - ...data, - user: { email, isEmailVerified, username, firstName, lastName, id: userId, publicKey } - }; + return doc?.[0]; } catch (error) { throw new DatabaseError({ error, name: "Find org membership by id" }); } diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index faa3adba6..02c634e27 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -37,6 +37,7 @@ import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; +import { TIdentityMetadataDALFactory } from "../identity/identity-metadata-dal"; import { TProjectDALFactory } from "../project/project-dal"; import { assignWorkspaceKeysToMembers } from "../project/project-fns"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; @@ -72,12 +73,13 @@ type TOrgServiceFactoryDep = { userDAL: TUserDALFactory; groupDAL: TGroupDALFactory; projectDAL: TProjectDALFactory; + identityMetadataDAL: Pick; projectMembershipDAL: Pick< TProjectMembershipDALFactory, "findProjectMembershipsByUserId" | "delete" | "create" | "find" | "insertMany" | "transaction" >; projectKeyDAL: Pick; - orgMembershipDAL: Pick; + orgMembershipDAL: Pick; incidentContactDAL: TIncidentContactsDALFactory; samlConfigDAL: Pick; smtpService: TSmtpService; @@ -115,7 +117,8 @@ export const orgServiceFactory = ({ projectRoleDAL, samlConfigDAL, projectBotDAL, - projectUserMembershipRoleDAL + projectUserMembershipRoleDAL, + identityMetadataDAL }: TOrgServiceFactoryDep) => { /* * Get organization details by the organization id @@ -404,20 +407,22 @@ export const orgServiceFactory = ({ userId, membershipId, actorAuthMethod, - actorOrgId + actorOrgId, + metadata }: TUpdateOrgMembershipDTO) => { const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member); - const foundMembership = await orgMembershipDAL.findOne({ - id: membershipId, - orgId - }); + const foundMembership = await orgMembershipDAL.findById(membershipId); if (!foundMembership) throw new NotFoundError({ message: "Failed to find organization membership" }); + if (foundMembership.orgId !== orgId) + throw new UnauthorizedError({ message: "Updated org member doesn't belong to the organization" }); if (foundMembership.userId === userId) throw new UnauthorizedError({ message: "Cannot update own organization membership" }); const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole); + let userRole = role; + let userRoleId: string | null = null; if (role && isCustomRole) { const customRole = await orgRoleDAL.findOne({ slug: role, orgId }); if (!customRole) throw new BadRequestError({ name: "UpdateMembership", message: "Organization role not found" }); @@ -428,17 +433,31 @@ export const orgServiceFactory = ({ message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." }); - const [membership] = await orgDAL.updateMembership( - { id: membershipId, orgId }, - { - role: OrgMembershipRole.Custom, - roleId: customRole.id - } - ); - return membership; + userRole = OrgMembershipRole.Custom; + userRoleId = customRole.id; } + const membership = await orgDAL.transaction(async (tx) => { + const [updatedOrgMembership] = await orgDAL.updateMembership( + { id: membershipId, orgId }, + { role: userRole, roleId: userRoleId, isActive } + ); - const [membership] = await orgDAL.updateMembership({ id: membershipId, orgId }, { role, roleId: null, isActive }); + if (metadata) { + await identityMetadataDAL.delete({ userId: updatedOrgMembership.userId, orgId }, tx); + if (metadata.length) { + await identityMetadataDAL.insertMany( + metadata.map(({ key, value }) => ({ + userId: updatedOrgMembership.userId, + orgId, + key, + value + })), + tx + ); + } + } + return updatedOrgMembership; + }); return membership; }; /* diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index ef0a5606a..085226be0 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -9,6 +9,7 @@ export type TUpdateOrgMembershipDTO = { role?: string; isActive?: boolean; actorOrgId: string | undefined; + metadata?: { key: string; value: string }[]; actorAuthMethod: ActorAuthMethod; }; diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index a1e22504f..49c4f81c9 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -7,8 +7,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionSet, - ProjectPermissionSub, - validateProjectPermissions + ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -60,8 +59,6 @@ export const projectRoleServiceFactory = ({ throw new BadRequestError({ name: "Create Role", message: "Project role with same slug already exists" }); } - validateProjectPermissions(data.permissions); - const role = await projectRoleDAL.create({ ...data, projectId @@ -127,10 +124,6 @@ export const projectRoleServiceFactory = ({ throw new BadRequestError({ name: "Update Role", message: "Project role with the same slug already exists" }); } - if (data.permissions) { - validateProjectPermissions(data.permissions); - } - const [updatedRole] = await projectRoleDAL.update( { id: roleId, projectId }, { diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index b768f5f72..349d01f36 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -7,6 +7,30 @@ export enum ProjectPermissionActions { Delete = "delete" } +export enum PermissionConditionOperators { + $IN = "$in", + $ALL = "$all", + $REGEX = "$regex", + $EQ = "$eq", + $NEQ = "$neq", + $GLOB = "$glob" +} + +export type TPermissionConditionOperators = { + [PermissionConditionOperators.$IN]: string[]; + [PermissionConditionOperators.$ALL]: string[]; + [PermissionConditionOperators.$EQ]: string; + [PermissionConditionOperators.$NEQ]: string; + [PermissionConditionOperators.$REGEX]: string; + [PermissionConditionOperators.$GLOB]: string; +}; + +export type TPermissionCondition = Record< + string, + | string + | { $in: string[]; $all: string[]; $regex: string; $eq: string; $neq: string; $glob: string } +>; + export enum ProjectPermissionSub { Role = "role", Member = "member", diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index f4ee8bf9c..21c4c560e 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -67,12 +67,13 @@ export const useCreateIdentity = () => { export const useUpdateIdentity = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ identityId, name, role }) => { + mutationFn: async ({ identityId, name, role, metadata }) => { const { data: { identity } } = await apiRequest.patch(`/api/v1/identities/${identityId}`, { name, - role + role, + metadata }); return identity; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 697f0ed82..cdad71e9f 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -37,6 +37,7 @@ export type IdentityMembershipOrg = { id: string; identity: Identity; organization: string; + metadata: { key: string; value: string; id: string }[]; role: "admin" | "member" | "viewer" | "no-access" | "custom"; customRole?: TOrgRole; createdAt: string; @@ -79,6 +80,7 @@ export type CreateIdentityDTO = { name: string; organizationId: string; role?: string; + metadata?: { key: string; value: string }[]; }; export type UpdateIdentityDTO = { @@ -86,6 +88,7 @@ export type UpdateIdentityDTO = { name?: string; role?: string; organizationId: string; + metadata?: { key: string; value: string }[]; }; export type DeleteIdentityDTO = { diff --git a/frontend/src/hooks/api/roles/types.ts b/frontend/src/hooks/api/roles/types.ts index e2d1b533a..da28d9ca8 100644 --- a/frontend/src/hooks/api/roles/types.ts +++ b/frontend/src/hooks/api/roles/types.ts @@ -40,7 +40,7 @@ export type TPermission = { export type TProjectPermission = { conditions?: Record; - action: string; + action: string | string[]; subject: string | string[]; }; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index fc5243510..116b98d4f 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -235,12 +235,13 @@ export const useUpdateOrgMembership = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, UpdateOrgMembershipDTO>({ - mutationFn: ({ organizationId, membershipId, role, isActive }) => { + mutationFn: ({ organizationId, membershipId, role, isActive, metadata }) => { return apiRequest.patch( `/api/v2/organizations/${organizationId}/memberships/${membershipId}`, { role, - isActive + isActive, + metadata } ); }, diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index c96e3d735..86686a1a0 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -46,6 +46,7 @@ export type UserEnc = { export type OrgUser = { id: string; + metadata: { key: string; value: string; id: string }[]; user: { username: string; email?: string; @@ -142,6 +143,7 @@ export type UpdateOrgMembershipDTO = { membershipId: string; role?: string; isActive?: boolean; + metadata?: { key: string; value: string }[]; }; export type DeletOrgMembershipDTO = { diff --git a/frontend/src/views/Org/IdentityPage/components/IdentityDetailsSection.tsx b/frontend/src/views/Org/IdentityPage/components/IdentityDetailsSection.tsx index 223f4c9a0..fe56d3260 100644 --- a/frontend/src/views/Org/IdentityPage/components/IdentityDetailsSection.tsx +++ b/frontend/src/views/Org/IdentityPage/components/IdentityDetailsSection.tsx @@ -1,8 +1,8 @@ -import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faCopy, faKey, faPencil } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { OrgPermissionCan } from "@app/components/permissions"; -import { IconButton, Tooltip } from "@app/components/v2"; +import { IconButton, Tag, Tooltip } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { useTimedReset } from "@app/hooks"; import { useGetIdentityById } from "@app/hooks/api"; @@ -40,7 +40,8 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) = identityId, name: data.identity.name, role: data.role, - customRole: data.customRole + customRole: data.customRole, + metadata: data.metadata }); }} > @@ -77,10 +78,38 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =

Name

{data.identity.name}

-
+

Organization Role

{data.role}

+
+

Metadata

+ {data?.metadata?.length ? ( +
+ {data.metadata?.map((el) => ( +
+ + +
{el.key}
+
+ +
+ {el.value} +
+
+
+ ))} +
+ ) : ( +

-

+ )} +
) : ( diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx index e5a1f8a02..5663c26d5 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx @@ -1,13 +1,17 @@ import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; import { useRouter } from "next/router"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, + FormLabel, + IconButton, Input, Modal, ModalContent, @@ -22,14 +26,21 @@ import { } from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; -const schema = yup +const schema = z .object({ - name: yup.string().required("MI name is required"), - role: yup.string() + name: z.string(), + role: z.string(), + metadata: z + .object({ + key: z.string().trim().min(1), + value: z.string().trim().min(1) + }) + .array() + .optional() }) .required(); -export type FormData = yup.InferType; +export type FormData = z.infer; type Props = { popUp: UsePopUpState<["identity"]>; @@ -61,12 +72,17 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { reset, formState: { isSubmitting } } = useForm({ - resolver: yupResolver(schema), + resolver: zodResolver(schema), defaultValues: { name: "" } }); + const metadataFormFields = useFieldArray({ + control, + name: "metadata" + }); + useEffect(() => { const identity = popUp?.identity?.data as { identityId: string; @@ -93,7 +109,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { } }, [popUp?.identity?.data, roles]); - const onFormSubmit = async ({ name, role }: FormData) => { + const onFormSubmit = async ({ name, role, metadata }: FormData) => { try { const identity = popUp?.identity?.data as { identityId: string; @@ -108,7 +124,8 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { identityId: identity.identityId, name, role: role || undefined, - organizationId: orgId + organizationId: orgId, + metadata }); handlePopUpToggle("identity", false); @@ -118,7 +135,8 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { const { id: createdId } = await createMutateAsync({ name, role: role || undefined, - organizationId: orgId + organizationId: orgId, + metadata }); await addMutateAsync({ @@ -207,6 +225,67 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> +
+ +
+
+ {metadataFormFields.fields.map(({ id: metadataFieldId }, i) => ( +
+
+ {i === 0 && Key} + ( + + + + )} + /> +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+ metadataFormFields.remove(i)} + > + + +
+ ))} +
+ +
+
-
+

Status

{getStatus(membership)}

+
+

Metadata

+ {membership?.metadata?.length ? ( +
+ {membership.metadata?.map((el) => ( +
+ + +
{el.key}
+
+ +
+ {el.value} +
+
+
+ ))} +
+ ) : ( +

-

+ )} +
{membership.isActive && (membership.status === "invited" || membership.status === "verified") && membership.user.email && diff --git a/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx b/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx index ce3db38d7..57c8cebb2 100644 --- a/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx +++ b/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx @@ -1,16 +1,35 @@ import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; +import { + Button, + FormControl, + FormLabel, + IconButton, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; import { useOrganization, useSubscription } from "@app/context"; import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = z.object({ - role: z.string() + role: z.string(), + metadata: z + .object({ + key: z.string().trim().min(1), + value: z.string().trim().min(1) + }) + .array() + .optional() }); export type FormData = z.infer; @@ -39,9 +58,15 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg resolver: zodResolver(schema) }); + const metadataFormFields = useFieldArray({ + control, + name: "metadata" + }); + const popUpData = popUp?.orgMembership?.data as { membershipId: string; role: string; + metadata: { key: string; value: string }[]; }; useEffect(() => { @@ -49,7 +74,8 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg if (popUpData) { reset({ - role: popUpData.role + role: popUpData.role, + metadata: popUpData.metadata }); } else { reset({ @@ -58,14 +84,15 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg } }, [popUp?.orgMembership?.data, roles]); - const onFormSubmit = async ({ role }: FormData) => { + const onFormSubmit = async ({ role, metadata }: FormData) => { try { if (!orgId) return; await updateOrgMembership({ organizationId: orgId, membershipId: popUpData.membershipId, - role + role, + metadata }); handlePopUpToggle("orgMembership", false); @@ -135,6 +162,67 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg )} /> +
+ +
+
+ {metadataFormFields.fields.map(({ id: metadataFieldId }, i) => ( +
+
+ {i === 0 && Key} + ( + + + + )} + /> +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+ metadataFormFields.remove(i)} + > + + +
+ ))} +
+ +
+
)} diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState.tsx new file mode 100644 index 000000000..9a1bdab49 --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState.tsx @@ -0,0 +1,18 @@ +import { useFormContext } from "react-hook-form"; + +import { EmptyState } from "@app/components/v2"; + +import { TFormSchema } from "./ProjectRoleModifySection.utils"; + +// This is made into seperate component because watch subscribes to all permissions +// thus keeping in top level casues render on all ones +export const PermissionEmptyState = () => { + const { watch } = useFormContext(); + const isNotEmptyPermissions = Object.entries(watch("permissions") || {}).some( + ([key, value]) => key && value?.length > 0 + ); + + if (isNotEmptyPermissions) return
; + + return ; +}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts index 8b4958ee0..9a4a6cff8 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts @@ -1,29 +1,39 @@ -/* eslint-disable no-param-reassign */ import { z } from "zod"; -import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { + PermissionConditionOperators, + TPermissionCondition, + TPermissionConditionOperators +} from "@app/context/ProjectPermissionContext/types"; import { TProjectPermission } from "@app/hooks/api/roles/types"; -const generalPermissionSchema = z - .object({ - read: z.boolean().optional(), - edit: z.boolean().optional(), - delete: z.boolean().optional(), - create: z.boolean().optional() - }) - .optional(); +const GeneralPolicyActionSchema = z.object({ + read: z.boolean().optional(), + edit: z.boolean().optional(), + delete: z.boolean().optional(), + create: z.boolean().optional() +}); -const multiEnvPermissionSchema = z - .object({ - secretPath: z.string().trim().optional(), - read: z.boolean().optional(), - edit: z.boolean().optional(), - delete: z.boolean().optional(), - create: z.boolean().optional() - }) - .optional(); +const SecretFolderPolicyActionSchema = z.object({ + read: z.boolean().optional() +}); -const PERMISSION_ACTIONS = ["read", "create", "edit", "delete"] as const; +const SecretRollbackPolicyActionSchema = z.object({ + read: z.boolean().optional(), + create: z.boolean().optional() +}); + +const WorkspacePolicyActionSchema = z.object({ + edit: z.boolean().optional(), + delete: z.boolean().optional() +}); + +const ConditionSchema = z.object({ + operator: z.string(), + lhs: z.string(), + rhs: z.string().min(1) +}); export const formSchema = z.object({ name: z.string().trim(), @@ -35,139 +45,423 @@ export const formSchema = z.object({ .refine((val) => val !== "custom", { message: "Cannot use custom as its a keyword" }), permissions: z .object({ - secrets: z.record(multiEnvPermissionSchema).optional(), - "secret-folders": generalPermissionSchema.optional(), - member: generalPermissionSchema, - groups: generalPermissionSchema, - identity: generalPermissionSchema, - role: generalPermissionSchema, - integrations: generalPermissionSchema, - webhooks: generalPermissionSchema, - "service-tokens": generalPermissionSchema, - settings: generalPermissionSchema, - environments: generalPermissionSchema, - tags: generalPermissionSchema, - "ip-allowlist": generalPermissionSchema, - "certificate-authorities": generalPermissionSchema, - certificates: generalPermissionSchema, - "pki-alerts": generalPermissionSchema, - "pki-collections": generalPermissionSchema, - "certificate-templates": generalPermissionSchema, - // akhilmhdh: refactor all keys like below - [ProjectPermissionSub.SecretApproval]: generalPermissionSchema, - workspace: z - .object({ - edit: z.boolean().optional(), - delete: z.boolean().optional() - }) - .optional(), - "secret-rollback": z - .object({ - read: z.boolean().optional(), - create: z.boolean().optional() - }) - .optional() + [ProjectPermissionSub.Secrets]: GeneralPolicyActionSchema.extend({ + conditions: ConditionSchema.array() + .optional() + .default([]) + .refine( + (el) => { + const lhsOperatorSet = new Set(); + for (let i = 0; i < el.length; i += 1) { + const { lhs, operator } = el[i]; + if (lhsOperatorSet.has(`${lhs}-${operator}`)) { + return false; + } + lhsOperatorSet.add(`${lhs}-${operator}`); + } + return true; + }, + { message: "Duplicate operator found for a condition" } + ) + }) + .array() + .default([]), + [ProjectPermissionSub.SecretFolders]: SecretFolderPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Member]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Groups]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Identity]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Role]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Integrations]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Webhooks]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.ServiceTokens]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Settings]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Environments]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.AuditLogs]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.IpAllowList]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.CertificateAuthorities]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Certificates]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.PkiAlerts]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.PkiCollections]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.CertificateTemplates]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Workspace]: WorkspacePolicyActionSchema.array().default([]), + [ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SecretRotation]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]) }) + .partial() .optional() }); export type TFormSchema = z.infer; -const multiEnvApi2Form = ( - formVal: Record, - permission: TProjectPermission -) => { - const isCustomRule = Boolean(permission?.conditions?.environment); - // full access - if (isCustomRule && formVal && !formVal?.custom) { - formVal.custom = { read: true, edit: true, delete: true, create: true }; - } - - const secretEnv = permission?.conditions?.environment || "all"; - const secretPath = permission?.conditions?.secretPath?.$glob; - // initialize - if (formVal && !formVal?.[secretEnv]) { - formVal[secretEnv] = { read: false, edit: false, create: false, delete: false, secretPath }; - } - - formVal[secretEnv][permission.action] = true; +const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => { + const formConditions: z.infer[] = []; + Object.entries(caslConditions).forEach(([type, condition]) => { + if (typeof condition === "string") { + formConditions.push({ + operator: PermissionConditionOperators.$EQ, + lhs: type, + rhs: condition + }); + } else { + Object.keys(condition).forEach((conditionOperator) => { + const rhs = condition[conditionOperator as PermissionConditionOperators]; + formConditions.push({ + operator: conditionOperator, + lhs: type, + rhs: typeof rhs === "string" ? rhs : rhs.join(",") + }); + }); + } + }); + return formConditions; }; // convert role permission to form compatiable data structure export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { - // any because if it set it as form type due to the discriminated union type of ts - // i would have to write a if loop with both conditions same - const formVal: Record = {}; + const formVal: Partial = {}; permissions.forEach((permission) => { - const { subject: caslSub, action } = permission; - const subject = typeof caslSub === "string" ? caslSub : caslSub[0]; - if (!formVal?.[subject]) formVal[subject] = {}; + const { subject: caslSub, action, conditions } = permission; + const subject = (typeof caslSub === "string" ? caslSub : caslSub[0]) as ProjectPermissionSub; - if (subject === "secrets") { - multiEnvApi2Form(formVal[subject], permission); - } else { - // everything else follows same pattern - // formVal[settings][read | write] = true - formVal[subject][action] = true; + if ( + [ + ProjectPermissionSub.Secrets, + ProjectPermissionSub.Member, + ProjectPermissionSub.Groups, + ProjectPermissionSub.Identity, + ProjectPermissionSub.Role, + ProjectPermissionSub.Integrations, + ProjectPermissionSub.Webhooks, + ProjectPermissionSub.ServiceTokens, + ProjectPermissionSub.Settings, + ProjectPermissionSub.Environments, + ProjectPermissionSub.AuditLogs, + ProjectPermissionSub.IpAllowList, + ProjectPermissionSub.CertificateAuthorities, + ProjectPermissionSub.Certificates, + ProjectPermissionSub.PkiAlerts, + ProjectPermissionSub.PkiCollections, + ProjectPermissionSub.CertificateTemplates, + ProjectPermissionSub.SecretApproval, + ProjectPermissionSub.Tags, + ProjectPermissionSub.SecretRotation, + ProjectPermissionSub.Kms + ].includes(subject) + ) { + const canRead = action.includes(ProjectPermissionActions.Read); + const canEdit = action.includes(ProjectPermissionActions.Edit); + const canDelete = action.includes(ProjectPermissionActions.Delete); + const canCreate = action.includes(ProjectPermissionActions.Create); + + // from above statement we are sure it won't be undefined + if (subject === ProjectPermissionSub.Secrets) { + if (!formVal[subject]) formVal[subject] = []; + formVal[subject]!.push({ + read: canRead, + create: canCreate, + edit: canEdit, + delete: canDelete, + conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [] + }); + } else { + // deduplicate multiple rules for other policies + // because they don't have condition it doesn't make sense for multiple rules + if (!formVal[subject]) formVal[subject] = [{}]; + if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true; + if (canEdit) formVal[subject as ProjectPermissionSub.Member]![0].edit = true; + if (canCreate) formVal[subject as ProjectPermissionSub.Member]![0].create = true; + if (canDelete) formVal[subject as ProjectPermissionSub.Member]![0].delete = true; + } + } else if (subject === ProjectPermissionSub.Workspace) { + const canEdit = action.includes(ProjectPermissionActions.Edit); + const canDelete = action.includes(ProjectPermissionActions.Delete); + if (!formVal[subject]) formVal[subject] = [{}]; + + // from above statement we are sure it won't be undefined + if (canEdit) formVal[subject as ProjectPermissionSub.Workspace]![0].edit = true; + if (canDelete) formVal[subject as ProjectPermissionSub.Member]![0].delete = true; + } else if (subject === ProjectPermissionSub.SecretRollback) { + const canRead = action.includes(ProjectPermissionActions.Read); + const canCreate = action.includes(ProjectPermissionActions.Create); + if (!formVal[subject]) formVal[subject] = [{}]; + + // from above statement we are sure it won't be undefined + if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true; + if (canCreate) formVal[subject as ProjectPermissionSub.Member]![0].create = true; + } else if (subject === ProjectPermissionSub.SecretFolders) { + const canRead = action.includes(ProjectPermissionActions.Read); + if (!formVal[subject]) formVal[subject] = [{}]; + + // from above statement we are sure it won't be undefined + if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true; } }); - return formVal; }; -const multiEnvForm2Api = ( - permissions: TProjectPermission[], - formVal: Record, - subject: "secrets" +const convertFormOperatorToCaslCondition = ( + conditions: { lhs: string; rhs: string; operator: string }[] ) => { - if (!formVal) return; - - const isFullAccess = PERMISSION_ACTIONS.every((action) => formVal?.all?.[action]); - // if any of them is set in all push it without any condition - PERMISSION_ACTIONS.forEach((action) => { - if (formVal?.all?.[action]) permissions.push({ action, subject }); + const caslCondition: Record> = {}; + conditions.forEach((el) => { + if (!caslCondition[el.lhs]) caslCondition[el.lhs] = {}; + if ( + el.operator === PermissionConditionOperators.$IN || + el.operator === PermissionConditionOperators.$ALL + ) { + caslCondition[el.lhs][el.operator] = el.rhs.split(","); + } else { + caslCondition[el.lhs][ + el.operator as Exclude< + PermissionConditionOperators, + PermissionConditionOperators.$ALL | PermissionConditionOperators.$IN + > + ] = el.rhs; + } }); - - if (!isFullAccess) { - Object.keys(formVal || {}) - .filter((id) => id !== "all" && id !== "custom") // remove all and custom for iter - .forEach((slug) => { - const actions = Object.keys(formVal?.[slug] || {}) as [ - "read", - "edit", - "create", - "delete", - "secretPath" - ]; - actions.forEach((action) => { - // if not full access for an action - if (!formVal?.all?.[action] && action !== "secretPath" && formVal?.[slug]?.[action]) { - const conditions: Record = { environment: slug }; - if (formVal[slug]?.secretPath) - conditions.secretPath = { $glob: formVal?.[slug]?.secretPath }; - - permissions.push({ action, subject, conditions }); - } - }); - }); - } + return caslCondition; }; export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => { const permissions: TProjectPermission[] = []; // other than workspace everything else follows same // if in future there is a different follow the above on how workspace is done - Object.entries(formVal || {}).forEach(([rule, actions]) => { - if (rule === "secrets") { - multiEnvForm2Api(permissions, JSON.parse(JSON.stringify(actions || {})), rule); - } else if (actions) { - Object.entries(actions).forEach(([action, isAllowed]) => { - if (isAllowed) { - permissions.push({ subject: rule, action }); - } + Object.entries(formVal || {}).forEach(([subject, rules]) => { + rules.forEach((actions) => { + const caslActions = Object.keys(actions).filter( + (el) => actions?.[el as keyof typeof actions] && el !== "conditions" + ); + const caslConditions = + "conditions" in actions + ? convertFormOperatorToCaslCondition(actions.conditions) + : undefined; + + permissions.push({ + action: caslActions, + subject, + conditions: caslConditions }); - } + }); }); return permissions; }; + +export type TProjectPermissionObject = { + [K in ProjectPermissionSub]: { + title: string; + actions: { + label: string; + value: keyof Omit< + NonNullable[K]>[number], + "conditions" + >; + }[]; + }; +}; + +export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { + [ProjectPermissionSub.Secrets]: { + title: "Secrets", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.SecretFolders]: { + title: "Secret Folders", + actions: [{ label: "Read Only", value: "read" }] + }, + [ProjectPermissionSub.Kms]: { + title: "KMS", + actions: [{ label: "Modify", value: "edit" }] + }, + [ProjectPermissionSub.Integrations]: { + title: "Integrations", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.Workspace]: { + title: "Project", + actions: [ + { label: "Update project details", value: "edit" }, + { label: "Delete project", value: "delete" } + ] + }, + [ProjectPermissionSub.Role]: { + title: "Roles", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.Member]: { + title: "User Management", + actions: [ + { label: "View all members", value: "read" }, + { label: "Invite members", value: "create" }, + { label: "Edit members", value: "edit" }, + { label: "Remove members", value: "delete" } + ] + }, + [ProjectPermissionSub.Groups]: { + title: "Group Management", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.Identity]: { + title: "Machine Identity Management", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.Webhooks]: { + title: "Webhooks", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.ServiceTokens]: { + title: "Service Tokens", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.Settings]: { + title: "Settings", + actions: [ + { label: "Read", value: "read" }, + { label: "Modify", value: "edit" } + ] + }, + [ProjectPermissionSub.Environments]: { + title: "Environments", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.Tags]: { + title: "Tags", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.AuditLogs]: { + title: "Audit Logs", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.IpAllowList]: { + title: "IP Allowlist", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.CertificateAuthorities]: { + title: "Certificate Authorities", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.Certificates]: { + title: "Certificates", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.CertificateTemplates]: { + title: "Certificate Templates", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.PkiCollections]: { + title: "PKI Collections", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.PkiAlerts]: { + title: "PKI Alerts", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.SecretApproval]: { + title: "Secret Protect policy", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.SecretRotation]: { + title: "Secret Rotation", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.SecretRollback]: { + title: "Secret Rollback", + actions: [ + { label: "Perform rollback", value: "create" }, + { label: "View", value: "read" } + ] + } +}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx deleted file mode 100644 index 725a2310e..000000000 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { useEffect, useMemo } from "react"; -import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; -import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { createNotification } from "@app/components/notifications"; -import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2"; -import { useToggle } from "@app/hooks"; -import { TFormSchema } from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; - -const GENERAL_PERMISSIONS = [ - { action: "read", label: "View" }, - { action: "create", label: "Create" }, - { action: "edit", label: "Modify" }, - { action: "delete", label: "Remove" } -] as const; - -const WORKSPACE_PERMISSIONS = [ - { action: "edit", label: "Update project details" }, - { action: "delete", label: "Delete projects" } -] as const; - -const MEMBERS_PERMISSIONS = [ - { action: "read", label: "View all members" }, - { action: "create", label: "Invite members" }, - { action: "edit", label: "Edit members" }, - { action: "delete", label: "Remove members" } -] as const; - -const SECRET_ROLLBACK_PERMISSIONS = [ - { action: "create", label: "Perform Rollback" }, - { action: "read", label: "View" } -] as const; - -const getPermissionList = (option: Props["formName"]) => { - switch (option) { - case "workspace": - return WORKSPACE_PERMISSIONS; - case "member": - return MEMBERS_PERMISSIONS; - case "secret-rollback": - return SECRET_ROLLBACK_PERMISSIONS; - default: - return GENERAL_PERMISSIONS; - } -}; - -type PermissionName = - | `permissions.workspace.${"edit" | "delete"}` - | `permissions.secret-rollback.${"create" | "read"}` - | `permissions.${Exclude< - keyof NonNullable, - "workspace" | "secret-rollback" | "secrets" - >}.${"read" | "create" | "edit" | "delete"}`; - -type Props = { - isEditable: boolean; - title: string; - formName: keyof Omit, "secrets">; - setValue: UseFormSetValue; - control: Control; -}; - -enum Permission { - NoAccess = "no-access", - ReadOnly = "read-only", - FullAccess = "full-acess", - Custom = "custom" -} - -export const RolePermissionRow = ({ isEditable, title, formName, control, setValue }: Props) => { - const [isRowExpanded, setIsRowExpanded] = useToggle(); - const [isCustom, setIsCustom] = useToggle(); - - const rule = useWatch({ - control, - name: `permissions.${formName}` - }); - - const selectedPermissionCategory = useMemo(() => { - const actions = Object.keys(rule || {}) as Array; - - switch (formName) { - default: { - const totalActions = GENERAL_PERMISSIONS.length; - const score = actions - .map((key) => (rule?.[key] ? 1 : 0)) - .reduce((a, b) => a + b, 0 as number); - if (isCustom) return Permission.Custom; - if (score === 0) return Permission.NoAccess; - if (score === totalActions) return Permission.FullAccess; - if (rule && "read" in rule) { - if (score === 1 && rule?.read) return Permission.ReadOnly; - } - - return Permission.Custom; - } - } - }, [rule, isCustom]); - - useEffect(() => { - if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); - else setIsCustom.off(); - }, [selectedPermissionCategory]); - - useEffect(() => { - const isRowCustom = selectedPermissionCategory === Permission.Custom; - if (isRowCustom) { - setIsRowExpanded.on(); - } - }, []); - - const handlePermissionChange = (val: Permission) => { - if (val === Permission.Custom) { - setIsRowExpanded.on(); - setIsCustom.on(); - return; - } - setIsCustom.off(); - - switch (val) { - case Permission.NoAccess: - setValue( - `permissions.${formName}`, - { read: false, edit: false, create: false, delete: false }, - { shouldDirty: true } - ); - break; - case Permission.FullAccess: - setValue( - `permissions.${formName}`, - { read: true, edit: true, create: true, delete: true }, - { shouldDirty: true } - ); - break; - case Permission.ReadOnly: - setValue( - `permissions.${formName}`, - { read: true, edit: false, create: false, delete: false }, - { shouldDirty: true } - ); - break; - default: - setValue( - `permissions.${formName}`, - { read: false, edit: false, create: false, delete: false }, - { shouldDirty: true } - ); - break; - } - }; - - return ( - <> - setIsRowExpanded.toggle()} - > - - - - {title} - - - - - {isRowExpanded && ( - - -
- {getPermissionList(formName).map(({ action, label }) => { - const permissionName = `permissions.${formName}.${action}` as PermissionName; - return ( - ( - { - if (!isEditable) { - createNotification({ - type: "error", - text: "Failed to update default role" - }); - return; - } - field.onChange(e); - }} - id={permissionName} - > - {label} - - )} - /> - ); - })} -
- - - )} - - ); -}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretFoldersRow.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretFoldersRow.tsx deleted file mode 100644 index eb0353b73..000000000 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretFoldersRow.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Control, UseFormSetValue, useWatch } from "react-hook-form"; - -import { Select, SelectItem, Td, Tr } from "@app/components/v2"; -import { ProjectPermissionSub } from "@app/context"; -import { TFormSchema } from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; - -type Props = { - isEditable: boolean; - setValue: UseFormSetValue; - control: Control; -}; - -enum Permission { - SameAsSecrets = "same-as-secrets", - ReadOnly = "read-only" -} - -export const RowPermissionSecretFoldersRow = ({ isEditable, setValue, control }: Props) => { - const formName = ProjectPermissionSub.SecretFolders; - const rule = useWatch({ - control, - name: `permissions.${formName}` - }); - - const selectedPermissionCategory = - rule !== undefined ? Permission.ReadOnly : Permission.SameAsSecrets; - - const handlePermissionChange = (val: Permission) => { - if (!val) return; - switch (val) { - case Permission.SameAsSecrets: { - setValue(`permissions.${formName}`, undefined, { shouldDirty: true }); - break; - } - // Read-only - default: - setValue( - `permissions.${formName}`, - { - read: true, - edit: false, - create: false, - delete: false - }, - { - shouldDirty: true - } - ); - break; - } - }; - - return ( - - - Secret Folders - - - - - ); -}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretsRow.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretsRow.tsx deleted file mode 100644 index 75285db85..000000000 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretsRow.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import { useMemo } from "react"; -import { Control, Controller, UseFormGetValues, UseFormSetValue, useWatch } from "react-hook-form"; -import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import GlobPatternExamples from "@app/components/basic/popups/GlobPatternExamples"; -import { - Checkbox, - FormControl, - Input, - Select, - SelectItem, - Table, - TableContainer, - TBody, - Td, - Th, - THead, - Tr -} from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { TFormSchema } from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; - -type Props = { - title: string; - formName: "secrets"; - isEditable: boolean; - setValue: UseFormSetValue; - getValue: UseFormGetValues; - control: Control; -}; - -enum Permission { - NoAccess = "no-access", - ReadOnly = "read-only", - FullAccess = "full-acess", - Custom = "custom" -} - -export const RowPermissionSecretsRow = ({ - title, - formName, - isEditable, - setValue, - getValue, - control -}: Props) => { - const { currentWorkspace } = useWorkspace(); - const environments = currentWorkspace?.environments || []; - - const customRule = useWatch({ - control, - name: `permissions.${formName}.custom` - }); - const isCustom = Boolean(customRule); - - const allRule = useWatch({ control, name: `permissions.${formName}.all` }); - - const selectedPermissionCategory = useMemo(() => { - const { read, delete: del, edit, create } = allRule || {}; - if (read && del && edit && create) return Permission.FullAccess; - if (read) return Permission.ReadOnly; - return Permission.NoAccess; - }, [allRule]); - - const handlePermissionChange = (val: Permission) => { - if (!val) return; - switch (val) { - case Permission.NoAccess: { - const permissions = getValue("permissions"); - if (permissions) delete permissions[formName]; - setValue("permissions", permissions, { shouldDirty: true }); - break; - } - case Permission.FullAccess: - setValue( - `permissions.${formName}`, - { all: { read: true, edit: true, create: true, delete: true } }, - { shouldDirty: true } - ); - break; - case Permission.ReadOnly: - setValue( - `permissions.${formName}`, - { all: { read: true, edit: false, create: false, delete: false } }, - { shouldDirty: true } - ); - break; - default: - setValue( - `permissions.${formName}`, - { custom: { read: false, edit: false, create: false, delete: false } }, - { shouldDirty: true } - ); - break; - } - }; - - return ( - <> - - {isCustom && } - {title} - - - - - {isCustom && ( - - -
- - - - - - - - - - - - - {isCustom && - environments.map(({ name, slug }) => ( - - - - - - - - - ))} - -
- -
- Secret Path - - - -
-
ViewCreateModifyDelete
{name} - ( - /* eslint-disable-next-line no-template-curly-in-string */ - - - - )} - /> - - ( -
- -
- )} - /> -
- ( -
- -
- )} - /> -
- ( -
- -
- )} - /> -
- ( -
- -
- )} - /> -
-
-
- - - )} - - ); -}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index 814994f90..fb3567897 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -1,127 +1,57 @@ -import { useForm } from "react-hook-form"; +import { FormProvider, useForm } from "react-hook-form"; +import { faPlus, faSave } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; -import { Button, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2"; +import { Button, Modal, ModalContent, ModalTrigger } from "@app/components/v2"; import { ProjectPermissionSub, useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api"; + +import { GeneralPermissionOptions } from "./components/GeneralPermissionOptions"; +import { NewPermissionRule } from "./components/NewPermissionRule"; +import { SecretPermissionConditions } from "./components/SecretPermissionConditions"; +import { PermissionEmptyState } from "./PermissionEmptyState"; import { formRolePermission2API, formSchema, + PROJECT_PERMISSION_OBJECT, rolePermission2Form, TFormSchema -} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; - -import { RolePermissionRow } from "./RolePermissionRow"; -import { RowPermissionSecretFoldersRow } from "./RolePermissionSecretFoldersRow"; -import { RowPermissionSecretsRow } from "./RolePermissionSecretsRow"; - -const SINGLE_PERMISSION_LIST = [ - { - title: "Project", - formName: "workspace" - }, - { - title: "Integrations", - formName: "integrations" - }, - { - title: "Secret Protect policy", - formName: ProjectPermissionSub.SecretApproval - }, - { - title: "Roles", - formName: "role" - }, - { - title: "User Management", - formName: "member" - }, - { - title: "Group Management", - formName: "groups" - }, - { - title: "Machine Identity Management", - formName: "identity" - }, - { - title: "Webhooks", - formName: "webhooks" - }, - { - title: "Service Tokens", - formName: "service-tokens" - }, - { - title: "Settings", - formName: "settings" - }, - { - title: "Environments", - formName: "environments" - }, - { - title: "Tags", - formName: "tags" - }, - { - title: "IP Allowlist", - formName: "ip-allowlist" - }, - { - title: "Certificate Authorities", - formName: "certificate-authorities" - }, - { - title: "Certificates", - formName: "certificates" - }, - { - title: "Certificate Templates", - formName: "certificate-templates" - }, - { - title: "PKI Collections", - formName: "pki-collections" - }, - { - title: "PKI Alerts", - formName: "pki-alerts" - }, - { - title: "Secret Rollback", - formName: "secret-rollback" - } -] as const; +} from "./ProjectRoleModifySection.utils"; type Props = { roleSlug: string; + isDisabled?: boolean; }; -export const RolePermissionsSection = ({ roleSlug }: Props) => { +export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => { const { currentWorkspace } = useWorkspace(); + const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const); const projectSlug = currentWorkspace?.slug || ""; - const { data: role } = useGetProjectRoleBySlug(currentWorkspace?.slug ?? "", roleSlug as string); + const { data: role, isLoading } = useGetProjectRoleBySlug( + currentWorkspace?.slug ?? "", + roleSlug as string + ); + + const form = useForm({ + values: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : undefined, + resolver: zodResolver(formSchema) + }); const { - setValue, - getValues, - control, handleSubmit, formState: { isDirty, isSubmitting }, reset - } = useForm({ - defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {}, - resolver: zodResolver(formSchema) - }); + } = form; const { mutateAsync: updateRole } = useUpdateProjectRole(); const onSubmit = async (el: TFormSchema) => { try { if (!projectSlug || !role?.id) return; - await updateRole({ id: role?.id as string, projectSlug, @@ -143,70 +73,77 @@ export const RolePermissionsSection = ({ roleSlug }: Props) => { onSubmit={handleSubmit(onSubmit)} className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4" > -
-

Permissions

- {isCustomRole && ( -
- - + +
+

Policies

+
+ {isCustomRole && ( + <> + {isDirty && ( + + )} +
+ + handlePopUpToggle("createPolicy", isOpen)} + > + + + + + handlePopUpToggle("createPolicy")} /> + + +
+ + )}
- )} -
-
- - - - - - - - - - - - {SINGLE_PERMISSION_LIST.map((permission) => { - return ( - - ); - })} - -
- ResourcePermission
-
-
+
+
+ {!isLoading && } + {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( + + {subject === ProjectPermissionSub.Secrets ? ( + + ) : undefined} + + ))} +
+ ); }; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionOptions.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionOptions.tsx new file mode 100644 index 000000000..cdf0f5151 --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionOptions.tsx @@ -0,0 +1,139 @@ +import { cloneElement } from "react"; +import { Controller, useFieldArray, useFormContext } from "react-hook-form"; +import { faChevronDown, faChevronRight, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { Button, Checkbox, Tag } from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema, TProjectPermissionObject } from "../ProjectRoleModifySection.utils"; + +type Props = { + title: string; + subject: T; + actions: TProjectPermissionObject[T]["actions"]; + children?: JSX.Element; + isDisabled?: boolean; +}; + +export const GeneralPermissionOptions = >({ + subject, + actions, + children, + title, + isDisabled +}: Props) => { + const { control } = useFormContext(); + const items = useFieldArray({ + control, + name: `permissions.${subject}` + }); + const [isOpen, setIsOpen] = useToggle(); + + if (!items.fields.length) return
; + + return ( +
+
setIsOpen.toggle()} + onKeyDown={(e) => { + if (e.key === "Enter") { + setIsOpen.toggle(); + } + }} + > +
+ +
+
{title}
+ {items.fields.length > 1 && ( +
+ + {items.fields.length} rules + +
+ )} +
+ {isOpen && ( +
+ {items.fields.map((el, rootIndex) => ( +
+
+
Actions
+
+ {actions.map(({ label, value }) => { + if (typeof value !== "string") return undefined; + return ( + ( +
+ + {label} + +
+ )} + /> + ); + })} +
+
+ {children && + cloneElement(children, { + position: rootIndex + })} +
+ {!isDisabled && subject === ProjectPermissionSub.Secrets && ( + + )} + {!isDisabled && ( + + )}{" "} +
+
+ ))} +
+ )} +
+ ); +}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule.tsx new file mode 100644 index 000000000..5950bee36 --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule.tsx @@ -0,0 +1,121 @@ +import { Controller, useForm, useFormContext } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + Checkbox, + FormControl, + FormLabel, + ModalClose, + Select, + SelectItem +} from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; + +import { + formSchema, + PROJECT_PERMISSION_OBJECT, + TFormSchema +} from "../ProjectRoleModifySection.utils"; + +type Props = { + onClose: () => void; +}; + +export const NewPermissionRule = ({ onClose }: Props) => { + const rootForm = useFormContext(); + + const form = useForm<{ + type: ProjectPermissionSub; + permissions: NonNullable; + }>({ + resolver: zodResolver( + formSchema.pick({ permissions: true }).extend({ type: z.nativeEnum(ProjectPermissionSub) }) + ), + defaultValues: { + type: ProjectPermissionSub.Secrets + } + }); + + const selectedSubject = form.watch("type"); + + return ( +
+ ( + + + + )} + /> + +
+ {PROJECT_PERMISSION_OBJECT?.[selectedSubject]?.actions?.map(({ label, value }) => ( + ( +
+ + {label} + +
+ )} + /> + ))} +
+
+ + + + +
+
+ ); +}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/SecretPermissionConditions.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/SecretPermissionConditions.tsx new file mode 100644 index 000000000..38f60cc71 --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/SecretPermissionConditions.tsx @@ -0,0 +1,148 @@ +import { Controller, useFieldArray, useFormContext } from "react-hook-form"; +import { faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2"; +import { PermissionConditionOperators } from "@app/context/ProjectPermissionContext/types"; + +import { TFormSchema } from "../ProjectRoleModifySection.utils"; + +type Props = { + position?: number; + isDisabled?: boolean; +}; + +const getValueLabel = (type: string) => { + if (type === "environment") return "Environment slug"; + if (type === "secretPath") return "Folder path"; + return ""; +}; + +export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props) => { + const { + control, + watch, + formState: { errors } + } = useFormContext(); + const items = useFieldArray({ + control, + name: `permissions.secrets.${position}.conditions` + }); + + return ( +
+
+ {items.fields.map((el, index) => { + const lhs = watch(`permissions.secrets.${position}.conditions.${index}.lhs`); + return ( +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+ items.remove(index)} + > + + +
+
+ ); + })} +
+ {errors?.permissions?.secrets?.[position]?.conditions?.message && ( +
+ + {errors?.permissions?.secrets?.[position]?.conditions?.message} +
+ )} +
{}
+
+ +
+
+ ); +};