diff --git a/backend/package.json b/backend/package.json index 43409e619..c0dc33bf0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -40,6 +40,7 @@ "type:check": "tsc --noEmit", "lint:fix": "eslint --fix --ext js,ts ./src", "lint": "eslint 'src/**/*.ts'", + "test:unit": "vitest run -c vitest.unit.config.ts", "test:e2e": "vitest run -c vitest.e2e.config.ts --bail=1", "test:e2e-watch": "vitest -c vitest.e2e.config.ts --bail=1", "test:e2e-coverage": "vitest run --coverage -c vitest.e2e.config.ts", diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 7de3f8f92..7f1bca5d8 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -3,7 +3,7 @@ import slugify from "@sindresorhus/slugify"; import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas"; import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; @@ -87,9 +87,14 @@ export const groupServiceFactory = ({ actorOrgId ); const isCustomRole = Boolean(customRole); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to create a more privileged group" }); + + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to create a more privileged group", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const group = await groupDAL.transaction(async (tx) => { const existingGroup = await groupDAL.findOne({ orgId: actorOrgId, name }, tx); @@ -156,9 +161,13 @@ export const groupServiceFactory = ({ ); const isCustomRole = Boolean(customOrgRole); - const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasRequiredNewRolePermission) - throw new ForbiddenRequestError({ message: "Failed to create a more privileged group" }); + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to create a more privileged group", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); if (isCustomRole) customRole = customOrgRole; } @@ -329,9 +338,13 @@ export const groupServiceFactory = ({ const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); // check if user has broader or equal to privileges than group - const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission); - if (!hasRequiredPrivileges) - throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" }); + const permissionBoundary = validatePermissionBoundary(permission, groupRolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to add user to more privileged group", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const user = await userDAL.findOne({ username }); if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` }); @@ -396,9 +409,13 @@ export const groupServiceFactory = ({ const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); // check if user has broader or equal to privileges than group - const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission); - if (!hasRequiredPrivileges) - throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" }); + const permissionBoundary = validatePermissionBoundary(permission, groupRolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to delete user from more privileged group", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const user = await userDAL.findOne({ username }); if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` }); diff --git a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts b/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts index 3a38c0d65..08e3a7727 100644 --- a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts +++ b/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts @@ -3,7 +3,7 @@ import { packRules } from "@casl/ability/extra"; import ms from "ms"; import { ActionProjectType, TableName } from "@app/db/schemas"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { unpackPermissions } from "@app/server/routes/santizedSchemas/permission"; import { ActorType } from "@app/services/auth/auth-type"; @@ -79,9 +79,13 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetIdentityPermission.update(targetIdentityPermission.rules.concat(customPermission)); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetIdentityPermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to update more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ slug, @@ -161,9 +165,13 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetIdentityPermission.update(targetIdentityPermission.rules.concat(data.permissions || [])); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetIdentityPermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to update more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); if (data?.slug) { const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ @@ -239,9 +247,13 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to update more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id); return { 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 16c0cc212..4ac60a659 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 @@ -3,7 +3,7 @@ import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; import ms from "ms"; import { ActionProjectType } from "@app/db/schemas"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission"; import { ActorType } from "@app/services/auth/auth-type"; @@ -88,9 +88,13 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetIdentityPermission.update(targetIdentityPermission.rules.concat(customPermission)); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetIdentityPermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to update more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ slug, @@ -172,9 +176,13 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetIdentityPermission.update(targetIdentityPermission.rules.concat(data.permissions || [])); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetIdentityPermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to update more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ slug, @@ -268,9 +276,13 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to edit more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to edit more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ slug, diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts index 14586d5e2..7cf255bcc 100644 --- a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts +++ b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts @@ -3,7 +3,7 @@ import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; import ms from "ms"; import { ActionProjectType, TableName } from "@app/db/schemas"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission"; import { ActorType } from "@app/services/auth/auth-type"; @@ -76,9 +76,13 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetUserPermission.update(targetUserPermission.rules.concat(customPermission)); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetUserPermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, targetUserPermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to update more privileged user", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ slug, @@ -163,9 +167,13 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetUserPermission.update(targetUserPermission.rules.concat(dto.permissions || [])); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetUserPermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, targetUserPermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to update more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); if (dto?.slug) { const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ diff --git a/backend/src/lib/casl/boundary.test.ts b/backend/src/lib/casl/boundary.test.ts new file mode 100644 index 000000000..c5e284a2e --- /dev/null +++ b/backend/src/lib/casl/boundary.test.ts @@ -0,0 +1,665 @@ +import { createMongoAbility } from "@casl/ability"; + +import { PermissionConditionOperators } from "."; +import { validatePermissionBoundary } from "./boundary"; + +describe("Validate Permission Boundary Function", () => { + test.each([ + { + title: "child with equal privilege", + parentPermission: createMongoAbility([ + { + action: ["create", "edit", "delete", "read"], + subject: "secrets" + } + ]), + childPermission: createMongoAbility([ + { + action: ["create", "edit", "delete", "read"], + subject: "secrets" + } + ]), + expectValid: true, + missingPermissions: [] + }, + { + title: "child with less privilege", + parentPermission: createMongoAbility([ + { + action: ["create", "edit", "delete", "read"], + subject: "secrets" + } + ]), + childPermission: createMongoAbility([ + { + action: ["create", "edit"], + subject: "secrets" + } + ]), + expectValid: true, + missingPermissions: [] + }, + { + title: "child with more privilege", + parentPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets" + } + ]), + childPermission: createMongoAbility([ + { + action: ["create", "edit"], + subject: "secrets" + } + ]), + expectValid: false, + missingPermissions: [{ action: "edit", subject: "secrets" }] + }, + { + title: "parent with multiple and child with multiple", + parentPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets" + }, + { + action: ["create", "edit"], + subject: "members" + } + ]), + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "members" + } + ]), + expectValid: true, + missingPermissions: [] + }, + { + title: "Child with no access", + parentPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets" + }, + { + action: ["create", "edit"], + subject: "members" + } + ]), + childPermission: createMongoAbility([]), + expectValid: true, + missingPermissions: [] + }, + { + title: "Parent and child disjoint set", + parentPermission: createMongoAbility([ + { + action: ["create", "edit", "delete", "read"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" } + } + } + ]), + childPermission: createMongoAbility([ + { + action: ["create", "edit", "delete", "read"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$EQ]: "dev" } + } + } + ]), + expectValid: false, + missingPermissions: ["create", "edit", "delete", "read"].map((el) => ({ + action: el, + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$EQ]: "dev" } + } + })) + }, + { + title: "Parent with inverted rules", + parentPermission: createMongoAbility([ + { + action: ["create", "edit", "delete", "read"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" } + } + }, + { + action: "read", + subject: "secrets", + inverted: true, + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" }, + secretPath: { [PermissionConditionOperators.$GLOB]: "/hello/**" } + } + } + ]), + childPermission: createMongoAbility([ + { + action: "read", + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" }, + secretPath: { [PermissionConditionOperators.$EQ]: "/" } + } + } + ]), + expectValid: true, + missingPermissions: [] + }, + { + title: "Parent with inverted rules - child accessing invalid one", + parentPermission: createMongoAbility([ + { + action: ["create", "edit", "delete", "read"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" } + } + }, + { + action: "read", + subject: "secrets", + inverted: true, + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" }, + secretPath: { [PermissionConditionOperators.$GLOB]: "/hello/**" } + } + } + ]), + childPermission: createMongoAbility([ + { + action: "read", + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" }, + secretPath: { [PermissionConditionOperators.$EQ]: "/hello/world" } + } + } + ]), + expectValid: false, + missingPermissions: [ + { + action: "read", + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" }, + secretPath: { [PermissionConditionOperators.$EQ]: "/hello/world" } + } + } + ] + } + ])("Check permission: $title", ({ parentPermission, childPermission, expectValid, missingPermissions }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + if (expectValid) { + expect(permissionBoundary.isValid).toBeTruthy(); + } else { + expect(permissionBoundary.isValid).toBeFalsy(); + expect(permissionBoundary.missingPermissions).toEqual(expect.arrayContaining(missingPermissions)); + } + }); +}); + +describe("Validate Permission Boundary: Checking Parent $eq operator", () => { + const parentPermission = createMongoAbility([ + { + action: ["create", "read"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" } + } + } + ]); + + test.each([ + { + operator: PermissionConditionOperators.$EQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$IN, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$IN]: ["dev"] } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$GLOB, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$GLOB]: "dev" } + } + } + ]) + } + ])("Child $operator truthy cases", ({ childPermission }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + expect(permissionBoundary.isValid).toBeTruthy(); + }); + + test.each([ + { + operator: PermissionConditionOperators.$EQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "prod" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$IN, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$IN]: ["dev", "prod"] } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$GLOB, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$GLOB]: "dev**" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$NEQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$GLOB]: "staging" } + } + } + ]) + } + ])("Child $operator falsy cases", ({ childPermission }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + expect(permissionBoundary.isValid).toBeFalsy(); + }); +}); + +describe("Validate Permission Boundary: Checking Parent $neq operator", () => { + const parentPermission = createMongoAbility([ + { + action: ["create", "read"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$NEQ]: "/hello" } + } + } + ]); + + test.each([ + { + operator: PermissionConditionOperators.$EQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$EQ]: "/" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$NEQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$NEQ]: "/hello" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$IN, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$IN]: ["/", "/staging"] } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$GLOB, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$GLOB]: "/dev**" } + } + } + ]) + } + ])("Child $operator truthy cases", ({ childPermission }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + expect(permissionBoundary.isValid).toBeTruthy(); + }); + + test.each([ + { + operator: PermissionConditionOperators.$EQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$EQ]: "/hello" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$NEQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$NEQ]: "/" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$IN, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$IN]: ["/", "/hello"] } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$GLOB, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$GLOB]: "/hello**" } + } + } + ]) + } + ])("Child $operator falsy cases", ({ childPermission }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + expect(permissionBoundary.isValid).toBeFalsy(); + }); +}); + +describe("Validate Permission Boundary: Checking Parent $IN operator", () => { + const parentPermission = createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$IN]: ["dev", "staging"] } + } + } + ]); + + test.each([ + { + operator: PermissionConditionOperators.$EQ, + childPermission: createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$IN, + childPermission: createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$IN]: ["dev"] } + } + } + ]) + }, + { + operator: `${PermissionConditionOperators.$IN} - 2`, + childPermission: createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$IN]: ["dev", "staging"] } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$GLOB, + childPermission: createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$GLOB]: "dev" } + } + } + ]) + } + ])("Child $operator truthy cases", ({ childPermission }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + expect(permissionBoundary.isValid).toBeTruthy(); + }); + + test.each([ + { + operator: PermissionConditionOperators.$EQ, + childPermission: createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "prod" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$NEQ, + childPermission: createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$NEQ]: "dev" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$IN, + childPermission: createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$IN]: ["dev", "prod"] } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$GLOB, + childPermission: createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$GLOB]: "dev**" } + } + } + ]) + } + ])("Child $operator falsy cases", ({ childPermission }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + expect(permissionBoundary.isValid).toBeFalsy(); + }); +}); + +describe("Validate Permission Boundary: Checking Parent $GLOB operator", () => { + const parentPermission = createMongoAbility([ + { + action: ["create", "read"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$GLOB]: "/hello/**" } + } + } + ]); + + test.each([ + { + operator: PermissionConditionOperators.$EQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$EQ]: "/hello/world" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$IN, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$IN]: ["/hello/world", "/hello/world2"] } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$GLOB, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$GLOB]: "/hello/**/world" } + } + } + ]) + } + ])("Child $operator truthy cases", ({ childPermission }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + expect(permissionBoundary.isValid).toBeTruthy(); + }); + + test.each([ + { + operator: PermissionConditionOperators.$EQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$EQ]: "/print" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$NEQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$NEQ]: "/hello/world" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$IN, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$IN]: ["/", "/hello"] } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$GLOB, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$GLOB]: "/hello**" } + } + } + ]) + } + ])("Child $operator falsy cases", ({ childPermission }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + expect(permissionBoundary.isValid).toBeFalsy(); + }); +}); diff --git a/backend/src/lib/casl/boundary.ts b/backend/src/lib/casl/boundary.ts new file mode 100644 index 000000000..dee006f25 --- /dev/null +++ b/backend/src/lib/casl/boundary.ts @@ -0,0 +1,249 @@ +import { MongoAbility } from "@casl/ability"; +import { MongoQuery } from "@ucast/mongo2js"; +import picomatch from "picomatch"; + +import { PermissionConditionOperators } from "./index"; + +type TMissingPermission = { + action: string; + subject: string; + conditions?: MongoQuery; +}; + +type TPermissionConditionShape = { + [PermissionConditionOperators.$EQ]: string; + [PermissionConditionOperators.$NEQ]: string; + [PermissionConditionOperators.$GLOB]: string; + [PermissionConditionOperators.$IN]: string[]; +}; + +const getPermissionSetID = (action: string, subject: string) => `${action}:${subject}`; +const invertTheOperation = (shouldInvert: boolean, operation: boolean) => (shouldInvert ? !operation : operation); +const formatConditionOperator = (condition: TPermissionConditionShape | string) => { + return ( + typeof condition === "string" ? { [PermissionConditionOperators.$EQ]: condition } : condition + ) as TPermissionConditionShape; +}; + +const isOperatorsASubset = (parentSet: TPermissionConditionShape, subset: TPermissionConditionShape) => { + // we compute each operator against each other in left hand side and right hand side + if (subset[PermissionConditionOperators.$EQ] || subset[PermissionConditionOperators.$NEQ]) { + const subsetOperatorValue = subset[PermissionConditionOperators.$EQ] || subset[PermissionConditionOperators.$NEQ]; + const isInverted = Boolean(subset[PermissionConditionOperators.$NEQ]); + if ( + parentSet[PermissionConditionOperators.$EQ] && + invertTheOperation(isInverted, parentSet[PermissionConditionOperators.$EQ] !== subsetOperatorValue) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$NEQ] && + invertTheOperation(isInverted, parentSet[PermissionConditionOperators.$NEQ] === subsetOperatorValue) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$IN] && + invertTheOperation(isInverted, !parentSet[PermissionConditionOperators.$IN].includes(subsetOperatorValue)) + ) { + return false; + } + // ne and glob cannot match each other + if (parentSet[PermissionConditionOperators.$GLOB] && isInverted) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$GLOB] && + !picomatch.isMatch(subsetOperatorValue, parentSet[PermissionConditionOperators.$GLOB], { strictSlashes: false }) + ) { + return false; + } + } + if (subset[PermissionConditionOperators.$IN]) { + const subsetOperatorValue = subset[PermissionConditionOperators.$IN]; + if ( + parentSet[PermissionConditionOperators.$EQ] && + (subsetOperatorValue.length !== 1 || subsetOperatorValue[0] !== parentSet[PermissionConditionOperators.$EQ]) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$NEQ] && + subsetOperatorValue.includes(parentSet[PermissionConditionOperators.$NEQ]) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$IN] && + !subsetOperatorValue.every((el) => parentSet[PermissionConditionOperators.$IN].includes(el)) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$GLOB] && + !subsetOperatorValue.every((el) => + picomatch.isMatch(el, parentSet[PermissionConditionOperators.$GLOB], { + strictSlashes: false + }) + ) + ) { + return false; + } + } + if (subset[PermissionConditionOperators.$GLOB]) { + const subsetOperatorValue = subset[PermissionConditionOperators.$GLOB]; + const { isGlob } = picomatch.scan(subsetOperatorValue); + // if it's glob, all other fixed operators would make this superset because glob is powerful. like eq + // example: $in [dev, prod] => glob: dev** could mean anything starting with dev: thus is bigger + if ( + isGlob && + Object.keys(parentSet).some( + (el) => el !== PermissionConditionOperators.$GLOB && el !== PermissionConditionOperators.$NEQ + ) + ) { + return false; + } + + if ( + parentSet[PermissionConditionOperators.$EQ] && + parentSet[PermissionConditionOperators.$EQ] !== subsetOperatorValue + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$NEQ] && + picomatch.isMatch(parentSet[PermissionConditionOperators.$NEQ], subsetOperatorValue, { + strictSlashes: false + }) + ) { + return false; + } + // if parent set is IN, glob cannot be used for children - It's a bigger scope + if ( + parentSet[PermissionConditionOperators.$IN] && + !parentSet[PermissionConditionOperators.$IN].includes(subsetOperatorValue) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$GLOB] && + !picomatch.isMatch(subsetOperatorValue, parentSet[PermissionConditionOperators.$GLOB], { + strictSlashes: false + }) + ) { + return false; + } + } + return true; +}; + +const isSubsetForSamePermissionSubjectAction = ( + parentSetRules: ReturnType, + subsetRules: ReturnType, + appendToMissingPermission: (condition?: MongoQuery) => void +) => { + const isMissingConditionInParent = parentSetRules.every((el) => !el.conditions); + if (isMissingConditionInParent) return true; + + // all subset rules must pass in comparison to parent rul + return subsetRules.every((subsetRule) => { + const subsetRuleConditions = subsetRule.conditions as Record; + // compare subset rule with all parent rules + const isSubsetOfNonInvertedParentSet = parentSetRules + .filter((el) => !el.inverted) + .some((parentSetRule) => { + // get conditions and iterate + const parentSetRuleConditions = parentSetRule?.conditions as Record; + if (!parentSetRuleConditions) return true; + return Object.keys(parentSetRuleConditions).every((parentConditionField) => { + // if parent condition is missing then it's never a subset + if (!subsetRuleConditions?.[parentConditionField]) return false; + + // standardize the conditions plain string operator => $eq function + const parentRuleConditionOperators = formatConditionOperator(parentSetRuleConditions[parentConditionField]); + const selectedSubsetRuleCondition = subsetRuleConditions?.[parentConditionField]; + const subsetRuleConditionOperators = formatConditionOperator(selectedSubsetRuleCondition); + return isOperatorsASubset(parentRuleConditionOperators, subsetRuleConditionOperators); + }); + }); + + const invertedParentSetRules = parentSetRules.filter((el) => el.inverted); + const isNotSubsetOfInvertedParentSet = invertedParentSetRules.length + ? !invertedParentSetRules.some((parentSetRule) => { + // get conditions and iterate + const parentSetRuleConditions = parentSetRule?.conditions as Record< + string, + TPermissionConditionShape | string + >; + if (!parentSetRuleConditions) return true; + return Object.keys(parentSetRuleConditions).every((parentConditionField) => { + // if parent condition is missing then it's never a subset + if (!subsetRuleConditions?.[parentConditionField]) return false; + + // standardize the conditions plain string operator => $eq function + const parentRuleConditionOperators = formatConditionOperator(parentSetRuleConditions[parentConditionField]); + const selectedSubsetRuleCondition = subsetRuleConditions?.[parentConditionField]; + const subsetRuleConditionOperators = formatConditionOperator(selectedSubsetRuleCondition); + return isOperatorsASubset(parentRuleConditionOperators, subsetRuleConditionOperators); + }); + }) + : true; + const isSubset = isSubsetOfNonInvertedParentSet && isNotSubsetOfInvertedParentSet; + if (!isSubset) { + appendToMissingPermission(subsetRule.conditions); + } + return isSubset; + }); +}; + +export const validatePermissionBoundary = (parentSetPermissions: MongoAbility, subsetPermissions: MongoAbility) => { + const checkedPermissionRules = new Set(); + const missingPermissions: TMissingPermission[] = []; + + subsetPermissions.rules.forEach((subsetPermissionRules) => { + const subsetPermissionSubject = subsetPermissionRules.subject.toString(); + let subsetPermissionActions: string[] = []; + + // actions can be string or string[] + if (typeof subsetPermissionRules.action === "string") { + subsetPermissionActions.push(subsetPermissionRules.action); + } else { + subsetPermissionRules.action.forEach((subsetPermissionAction) => { + subsetPermissionActions.push(subsetPermissionAction); + }); + } + + // if action is already processed ignore + subsetPermissionActions = subsetPermissionActions.filter( + (el) => !checkedPermissionRules.has(getPermissionSetID(el, subsetPermissionSubject)) + ); + + if (!subsetPermissionActions.length) return; + subsetPermissionActions.forEach((subsetPermissionAction) => { + const parentSetRulesOfSubset = parentSetPermissions.possibleRulesFor( + subsetPermissionAction, + subsetPermissionSubject + ); + const nonInveretedOnes = parentSetRulesOfSubset.filter((el) => !el.inverted); + if (!nonInveretedOnes.length) { + missingPermissions.push({ action: subsetPermissionAction, subject: subsetPermissionSubject }); + return; + } + + const subsetRules = subsetPermissions.possibleRulesFor(subsetPermissionAction, subsetPermissionSubject); + isSubsetForSamePermissionSubjectAction(parentSetRulesOfSubset, subsetRules, (conditions) => { + missingPermissions.push({ action: subsetPermissionAction, subject: subsetPermissionSubject, conditions }); + }); + }); + + subsetPermissionActions.forEach((el) => + checkedPermissionRules.add(getPermissionSetID(el, subsetPermissionSubject)) + ); + }); + + if (missingPermissions.length) { + return { isValid: false as const, missingPermissions }; + } + + return { isValid: true }; +}; diff --git a/backend/src/lib/casl/index.ts b/backend/src/lib/casl/index.ts index 1bc3f4f59..147d12ef7 100644 --- a/backend/src/lib/casl/index.ts +++ b/backend/src/lib/casl/index.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { buildMongoQueryMatcher, createMongoAbility, MongoAbility } from "@casl/ability"; +import { buildMongoQueryMatcher } from "@casl/ability"; import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js"; import picomatch from "picomatch"; @@ -26,263 +26,3 @@ export enum PermissionConditionOperators { $NEQ = "$ne", $GLOB = "$glob" } - -type TPermissionConditionShape = { - [PermissionConditionOperators.$EQ]: string; - [PermissionConditionOperators.$NEQ]: string; - [PermissionConditionOperators.$GLOB]: string; - [PermissionConditionOperators.$IN]: string[]; -}; - -const getPermissionSetContainerID = (action: string, subject: string) => `${action}:${subject}`; -const invertTheOperation = (shouldInvert: boolean, operation: boolean) => (shouldInvert ? !operation : operation); -const formatConditionOperator = (condition: TPermissionConditionShape | string) => { - return ( - typeof condition === "string" ? { [PermissionConditionOperators.$EQ]: condition } : condition - ) as TPermissionConditionShape; -}; - -const isOperatorsASubset = (parentSet: TPermissionConditionShape, subset: TPermissionConditionShape) => { - if (subset[PermissionConditionOperators.$EQ] || subset[PermissionConditionOperators.$NEQ]) { - const subsetOperatorValue = subset[PermissionConditionOperators.$EQ] || subset[PermissionConditionOperators.$NEQ]; - const isInverted = Boolean(subset[PermissionConditionOperators.$NEQ]); - if ( - parentSet[PermissionConditionOperators.$EQ] && - invertTheOperation(isInverted, parentSet[PermissionConditionOperators.$EQ] !== subsetOperatorValue) - ) { - return false; - } - if ( - parentSet[PermissionConditionOperators.$NEQ] && - invertTheOperation(isInverted, parentSet[PermissionConditionOperators.$NEQ] === subsetOperatorValue) - ) { - return false; - } - if ( - parentSet[PermissionConditionOperators.$IN] && - invertTheOperation(isInverted, !parentSet[PermissionConditionOperators.$IN].includes(subsetOperatorValue)) - ) { - return false; - } - if ( - parentSet[PermissionConditionOperators.$GLOB] && - invertTheOperation( - isInverted, - !picomatch.isMatch(subsetOperatorValue, parentSet[PermissionConditionOperators.$GLOB], { strictSlashes: false }) - ) - ) { - return false; - } - } - if (subset[PermissionConditionOperators.$IN]) { - const subsetOperatorValue = subset[PermissionConditionOperators.$IN]; - if ( - parentSet[PermissionConditionOperators.$EQ] && - (subsetOperatorValue.length !== 1 || subsetOperatorValue[0] !== parentSet[PermissionConditionOperators.$EQ]) - ) { - return false; - } - if ( - parentSet[PermissionConditionOperators.$NEQ] && - !subsetOperatorValue.includes(parentSet[PermissionConditionOperators.$NEQ]) - ) { - return false; - } - if ( - parentSet[PermissionConditionOperators.$IN] && - !subsetOperatorValue.every((el) => parentSet[PermissionConditionOperators.$IN].includes(el)) - ) { - return false; - } - if ( - parentSet[PermissionConditionOperators.$GLOB] && - !subsetOperatorValue.every((el) => - picomatch.isMatch(el, parentSet[PermissionConditionOperators.$GLOB], { - strictSlashes: false - }) - ) - ) { - return false; - } - } - if (subset[PermissionConditionOperators.$GLOB]) { - const subsetOperatorValue = subset[PermissionConditionOperators.$GLOB]; - const { isGlob } = picomatch.scan(subsetOperatorValue); - // if it's glob, all other fixed operators would make this superset because glob is powerful. like eq - // example: $in [dev, prod] => glob: dev** could mean anything starting with dev: thus is bigger - if ( - isGlob && - Object.keys(parentSet).some( - (el) => el !== PermissionConditionOperators.$GLOB && el !== PermissionConditionOperators.$NEQ - ) - ) { - return false; - } - - if ( - parentSet[PermissionConditionOperators.$EQ] && - parentSet[PermissionConditionOperators.$EQ] !== subsetOperatorValue - ) { - return false; - } - if ( - parentSet[PermissionConditionOperators.$NEQ] && - picomatch.isMatch(parentSet[PermissionConditionOperators.$NEQ], subsetOperatorValue, { - strictSlashes: false - }) - ) { - return false; - } - // if parent set is IN, glob cannot be used for children - It's a bigger scope - if ( - parentSet[PermissionConditionOperators.$IN] && - !parentSet[PermissionConditionOperators.$IN].includes(subsetOperatorValue) - ) { - return false; - } - if ( - parentSet[PermissionConditionOperators.$GLOB] && - !picomatch.isMatch(subsetOperatorValue, parentSet[PermissionConditionOperators.$GLOB], { - strictSlashes: false - }) - ) { - return false; - } - } - return true; -}; - -const isSubsetForSamePermissionSubjectAction = ( - // [{action, subject,conditions{env: dev}}] - parentSetRules: ReturnType, - // [{action, subject,conditions{env: prod}}, {action, subject,conditions{env: dev,secretPath: "/"}] - subsetRules: ReturnType -) => { - const isMissingConditionInParent = parentSetRules.every((el) => !el.conditions); - if (isMissingConditionInParent) return true; - - // all subset rules must pass in comparison to parent rul - return subsetRules.every((subsetRule) => { - const subsetRuleConditions = subsetRule.conditions as Record; - - // compare subset rule with all parent rules - const isSubsetOfNonInvertedParentSet = parentSetRules - .filter((el) => !el.inverted) - .some((parentSetRule) => { - // get conditions and iterate - const parentSetRuleConditions = parentSetRule?.conditions as Record; - if (!parentSetRuleConditions) return true; - return Object.keys(parentSetRuleConditions).every((parentConditionField) => { - // if parent condition is missing then it's never a subset - if (!subsetRuleConditions?.[parentConditionField]) return false; - - // standardize the conditions plain string operator => $eq function - const parentRuleConditionOperators = formatConditionOperator(parentSetRuleConditions[parentConditionField]); - const selectedSubsetRuleCondition = subsetRuleConditions?.[parentConditionField]; - const subsetRuleConditionOperators = formatConditionOperator(selectedSubsetRuleCondition); - return isOperatorsASubset(parentRuleConditionOperators, subsetRuleConditionOperators); - }); - }); - - const invertedParentSetRules = parentSetRules.filter((el) => el.inverted); - const isNotSubsetOfInvertedParentSet = invertedParentSetRules.length - ? !invertedParentSetRules.some((parentSetRule) => { - // get conditions and iterate - const parentSetRuleConditions = parentSetRule?.conditions as Record< - string, - TPermissionConditionShape | string - >; - if (!parentSetRuleConditions) return true; - return Object.keys(parentSetRuleConditions).every((parentConditionField) => { - // if parent condition is missing then it's never a subset - if (!subsetRuleConditions?.[parentConditionField]) return false; - - // standardize the conditions plain string operator => $eq function - const parentRuleConditionOperators = formatConditionOperator(parentSetRuleConditions[parentConditionField]); - const selectedSubsetRuleCondition = subsetRuleConditions?.[parentConditionField]; - const subsetRuleConditionOperators = formatConditionOperator(selectedSubsetRuleCondition); - return isOperatorsASubset(parentRuleConditionOperators, subsetRuleConditionOperators); - }); - }) - : true; - return isSubsetOfNonInvertedParentSet && isNotSubsetOfInvertedParentSet; - }); -}; - -/** - * Compares two sets of permissions to determine if the first set is at least as privileged as the second set. - * The function checks if all permissions in the second set are contained within the first set and if the first set has equal or more permissions. - * - */ -export const isAtLeastAsPrivileged = (parentSetPermissions: MongoAbility, subsetPermissions: MongoAbility) => { - const checkedPermissionRules = new Set(); - for (const subsetPermissionRules of subsetPermissions.rules) { - const subsetPermissionSubject = subsetPermissionRules.subject.toString(); - let subsetPermissionActions: string[] = []; - - if (typeof subsetPermissionRules.action === "string") { - subsetPermissionActions.push(subsetPermissionRules.action); - } else { - subsetPermissionRules.action.forEach((subsetPermissionAction) => { - subsetPermissionActions.push(subsetPermissionAction); - }); - } - subsetPermissionActions = subsetPermissionActions.filter( - (el) => !checkedPermissionRules.has(getPermissionSetContainerID(el, subsetPermissionSubject)) - ); - - // eslint-disable-next-line no-continue - if (!subsetPermissionActions.length) continue; - // eslint-disable-next-line no-unreachable-loop - for (const subsetPermissionAction of subsetPermissionActions) { - const parentSetRulesOfSubset = parentSetPermissions.possibleRulesFor( - subsetPermissionAction, - subsetPermissionSubject - ); - const nonInveretedOnes = parentSetRulesOfSubset.filter((el) => !el.inverted); - if (!nonInveretedOnes.length) return false; - - const subsetRules = subsetPermissions.possibleRulesFor(subsetPermissionAction, subsetPermissionSubject); - const isSubset = isSubsetForSamePermissionSubjectAction(parentSetRulesOfSubset, subsetRules); - if (!isSubset) return false; - } - - subsetPermissionActions.forEach((el) => - checkedPermissionRules.add(getPermissionSetContainerID(el, subsetPermissionSubject)) - ); - } - - return true; -}; - -const superset = createMongoAbility([ - { - action: ["create", "edit", "delete", "read"], - subject: "secrets", - conditions: { - environment: { [PermissionConditionOperators.$EQ]: "dev" } - } - }, - { - action: "read", - subject: "secrets", - inverted: true, - conditions: { - environment: { [PermissionConditionOperators.$EQ]: "dev" }, - secretPath: { [PermissionConditionOperators.$GLOB]: "/hello" } - } - } -]); - -const subset = createMongoAbility([ - { - action: "edit", - subject: "secrets", - conditions: { - environment: { [PermissionConditionOperators.$EQ]: "dev" }, - secretPath: { [PermissionConditionOperators.$EQ]: "/hello" } - } - } -]); - -console.log(isAtLeastAsPrivileged(superset, subset)); diff --git a/backend/src/lib/errors/index.ts b/backend/src/lib/errors/index.ts index cc1c1d66b..f3dc83f26 100644 --- a/backend/src/lib/errors/index.ts +++ b/backend/src/lib/errors/index.ts @@ -52,10 +52,18 @@ export class ForbiddenRequestError extends Error { error: unknown; - constructor({ name, error, message }: { message?: string; name?: string; error?: unknown } = {}) { + details?: unknown; + + constructor({ + name, + error, + message, + details + }: { message?: string; name?: string; error?: unknown; details?: unknown } = {}) { super(message ?? "You are not allowed to access this resource"); this.name = name || "ForbiddenError"; this.error = error; + this.details = details; } } diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 7f9e16197..0fd18bc29 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -122,7 +122,8 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider reqId: req.id, statusCode: HttpStatusCodes.Forbidden, message: error.message, - error: error.name + error: error.name, + details: error?.details }); } else if (error instanceof RateLimitError) { void res.status(HttpStatusCodes.TooManyRequests).send({ diff --git a/backend/src/services/group-project/group-project-service.ts b/backend/src/services/group-project/group-project-service.ts index 067ff17b0..b408e2e95 100644 --- a/backend/src/services/group-project/group-project-service.ts +++ b/backend/src/services/group-project/group-project-service.ts @@ -4,7 +4,7 @@ import ms from "ms"; import { ActionProjectType, ProjectMembershipRole, SecretKeyEncoding, TGroups } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; @@ -102,11 +102,13 @@ export const groupProjectServiceFactory = ({ project.id ); - const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, rolePermission); - - if (!hasRequiredPrivileges) { - throw new ForbiddenRequestError({ message: "Failed to assign group to a more privileged role" }); - } + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to assign group to a more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); } // validate custom roles input @@ -267,12 +269,13 @@ export const groupProjectServiceFactory = ({ requestedRoleChange, project.id ); - - const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, rolePermission); - - if (!hasRequiredPrivileges) { - throw new ForbiddenRequestError({ message: "Failed to assign group to a more privileged role" }); - } + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to assign group to a more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); } // validate custom roles input diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts index ff202f225..ddd9e0278 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts @@ -7,7 +7,7 @@ import { IdentityAuthMethod } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; @@ -339,9 +339,12 @@ export const identityAwsAuthServiceFactory = ({ actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke aws auth of identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke aws auth of identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const revokedIdentityAwsAuth = await identityAwsAuthDAL.transaction(async (tx) => { diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts index 01d013734..01878dbb3 100644 --- a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts @@ -5,7 +5,7 @@ import { IdentityAuthMethod } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; @@ -312,9 +312,12 @@ export const identityAzureAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke azure auth of identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke azure auth of identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const revokedIdentityAzureAuth = await identityAzureAuthDAL.transaction(async (tx) => { diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts index 5e404ca20..7b0dd4390 100644 --- a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts @@ -5,7 +5,7 @@ import { IdentityAuthMethod } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; @@ -358,9 +358,12 @@ export const identityGcpAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke gcp auth of identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke gcp auth of identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const revokedIdentityGcpAuth = await identityGcpAuthDAL.transaction(async (tx) => { diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts index 6757b0b84..2c9b6306e 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -7,7 +7,7 @@ import { IdentityAuthMethod, TIdentityJwtAuthsUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; @@ -508,11 +508,13 @@ export const identityJwtAuthServiceFactory = ({ actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) { + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke JWT auth of identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke jwt auth of identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); - } const revokedIdentityJwtAuth = await identityJwtAuthDAL.transaction(async (tx) => { const deletedJwtAuth = await identityJwtAuthDAL.delete({ identityId }, tx); diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 4508a255d..bd83885dc 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -7,7 +7,7 @@ import { IdentityAuthMethod, SecretKeyEncoding, TIdentityKubernetesAuthsUpdate } import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { decryptSymmetric, @@ -616,9 +616,12 @@ export const identityKubernetesAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke kubernetes auth of identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke kubernetes auth of identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const revokedIdentityKubernetesAuth = await identityKubernetesAuthDAL.transaction(async (tx) => { diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts index a1dbed46b..08c4e116f 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -8,7 +8,7 @@ import { IdentityAuthMethod, SecretKeyEncoding, TIdentityOidcAuthsUpdate } from import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { generateAsymmetricKeyPair } from "@app/lib/crypto"; import { @@ -531,11 +531,13 @@ export const identityOidcAuthServiceFactory = ({ actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) { + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke OIDC auth of identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke oidc auth of identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); - } const revokedIdentityOidcAuth = await identityOidcAuthDAL.transaction(async (tx) => { const deletedOidcAuth = await identityOidcAuthDAL.delete({ identityId }, tx); diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index 36b9b0562..0b71165e2 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -4,7 +4,7 @@ import ms from "ms"; import { ActionProjectType, ProjectMembershipRole } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; @@ -91,11 +91,13 @@ export const identityProjectServiceFactory = ({ projectId ); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); - - if (!hasRequiredPriviledges) { - throw new ForbiddenRequestError({ message: "Failed to change to a more privileged role" }); - } + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to change to a more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); } // validate custom roles input @@ -185,9 +187,13 @@ export const identityProjectServiceFactory = ({ projectId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) { - throw new ForbiddenRequestError({ message: "Failed to change to a more privileged role" }); - } + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to change to a more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); } // validate custom roles input @@ -277,8 +283,13 @@ export const identityProjectServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - if (!isAtLeastAsPrivileged(permission, identityRolePermission)) - throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to delete more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const [deletedIdentity] = await identityProjectDAL.delete({ identityId, projectId }); return deletedIdentity; diff --git a/backend/src/services/identity-token-auth/identity-token-auth-service.ts b/backend/src/services/identity-token-auth/identity-token-auth-service.ts index bf38c5fa1..d9e2d66fa 100644 --- a/backend/src/services/identity-token-auth/identity-token-auth-service.ts +++ b/backend/src/services/identity-token-auth/identity-token-auth-service.ts @@ -5,7 +5,7 @@ import { IdentityAuthMethod, TableName } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; @@ -245,11 +245,13 @@ export const identityTokenAuthServiceFactory = ({ actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) { + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke Token Auth of identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke token auth of identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); - } const revokedIdentityTokenAuth = await identityTokenAuthDAL.transaction(async (tx) => { const deletedTokenAuth = await identityTokenAuthDAL.delete({ identityId }, tx); @@ -295,10 +297,12 @@ export const identityTokenAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasPriviledge) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to create token for identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to create token for identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId }); @@ -415,10 +419,12 @@ export const identityTokenAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasPriviledge) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to update token for identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to update token for identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const [token] = await identityAccessTokenDAL.update( diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index b9837265a..650f0511b 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -8,7 +8,7 @@ import { IdentityAuthMethod } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip"; @@ -367,9 +367,12 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke universal auth of identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke universal auth of identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const revokedIdentityUniversalAuth = await identityUaDAL.transaction(async (tx) => { @@ -414,10 +417,12 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId ); - const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasPriviledge) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to add identity to project with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to add identity to project with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const appCfg = getConfig(); @@ -475,9 +480,12 @@ export const identityUaServiceFactory = ({ actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to add identity to project with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to get identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const identityUniversalAuth = await identityUaDAL.findOne({ @@ -524,9 +532,12 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to read identity client secret of project with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to read identity client secret of project with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const clientSecret = await identityUaClientSecretDAL.findById(clientSecretId); @@ -566,10 +577,12 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId ); - - if (!isAtLeastAsPrivileged(permission, rolePermission)) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke identity client secret with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke identity client secret with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const clientSecret = await identityUaClientSecretDAL.updateById(clientSecretId, { diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index fffcbacc2..68ec75287 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -4,7 +4,7 @@ import { OrgMembershipRole, TableName, TOrgRoles } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; @@ -58,9 +58,13 @@ export const identityServiceFactory = ({ orgId ); const isCustomRole = Boolean(customRole); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to create a more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to create a more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const plan = await licenseService.getPlan(orgId); @@ -129,9 +133,13 @@ export const identityServiceFactory = ({ actorAuthMethod, actorOrgId ); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to delete a more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); let customRole: TOrgRoles | undefined; if (role) { @@ -141,9 +149,13 @@ export const identityServiceFactory = ({ ); const isCustomRole = Boolean(customOrgRole); - const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasRequiredNewRolePermission) - throw new ForbiddenRequestError({ message: "Failed to create a more privileged identity" }); + const appliedRolePermissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!appliedRolePermissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to create a more privileged identity", + details: { missingPermissions: appliedRolePermissionBoundary.missingPermissions } + }); if (isCustomRole) customRole = customOrgRole; } @@ -216,9 +228,13 @@ export const identityServiceFactory = ({ actorAuthMethod, actorOrgId ); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to delete more privileged user", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const deletedIdentity = await identityDAL.deleteById(id); diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index fd1382dcf..f47a37222 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -7,7 +7,7 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; @@ -274,13 +274,13 @@ export const projectMembershipServiceFactory = ({ projectId ); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); - - if (!hasRequiredPriviledges) { + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: `Failed to change to a more privileged role ${requestedRoleChange}` + name: "PermissionBoundaryError", + message: `Failed to change to a more privileged role ${requestedRoleChange}`, + details: { missingPermissions: permissionBoundary.missingPermissions } }); - } } // validate custom roles input diff --git a/backend/vitest.unit.config.ts b/backend/vitest.unit.config.ts new file mode 100644 index 000000000..97862d288 --- /dev/null +++ b/backend/vitest.unit.config.ts @@ -0,0 +1,17 @@ +import path from "path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + env: { + NODE_ENV: "test" + }, + include: ["./src/**/*.test.ts"] + }, + resolve: { + alias: { + "@app": path.resolve(__dirname, "./src") + } + } +});