diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index 102e21fc7..392046757 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -3,12 +3,12 @@ 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 { AuthMode } from "@app/services/auth/auth-type"; -import { ProjectPermissionSchema } from "@app/ee/services/permission/project-permission"; import { SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas"; +import { AuthMode } from "@app/services/auth/auth-type"; export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 735d8e8d6..a8f63be88 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -170,6 +170,7 @@ export const permissionDALFactory = (db: TDbClient) => { .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) .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"), @@ -267,6 +268,7 @@ export const permissionDALFactory = (db: TDbClient) => { key: "projectId", parentMapper: ({ orgId, + username, orgAuthEnforced, membershipId, groupMembershipId, @@ -279,6 +281,7 @@ export const permissionDALFactory = (db: TDbClient) => { orgAuthEnforced, userId, projectId, + username, id: membershipId || groupMembershipId, createdAt: membershipCreatedAt || groupMembershipCreatedAt, updatedAt: membershipUpdatedAt || groupMembershipUpdatedAt @@ -399,6 +402,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`, @@ -420,6 +424,7 @@ export const permissionDALFactory = (db: TDbClient) => { .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"), @@ -449,9 +454,10 @@ export const permissionDALFactory = (db: TDbClient) => { 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, 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..b4bbaa99c 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, @@ -20,7 +21,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 +73,7 @@ export const permissionServiceFactory = ({ }); }; - const buildProjectPermission = (projectUserRoles: TBuildProjectPermissionDTO) => { + const buildProjectPermissionRules = (projectUserRoles: TBuildProjectPermissionDTO) => { const rules = projectUserRoles .map(({ role, permissions }) => { switch (role) { @@ -98,9 +99,7 @@ export const permissionServiceFactory = ({ }) .reduce((curr, prev) => prev.concat(curr), []); - return createMongoAbility(rules, { - conditionsMatcher - }); + return rules; }; /* @@ -223,8 +222,21 @@ export const permissionServiceFactory = ({ permissions })) || []; + const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); + const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false }); + const interpolateRules = templatedRules( + { identity: { id: userProjectPermission.userId, username: userProjectPermission.username } }, + { 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 +274,21 @@ export const permissionServiceFactory = ({ permissions })) || []; + const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); + const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false }); + const interpolateRules = templatedRules( + { identity: { id: identityProjectPermission.identityId, username: identityProjectPermission.username } }, + { 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 +371,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 +397,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 231529ccd..7b1f1a5df 100644 --- a/backend/src/ee/services/permission/permission-types.ts +++ b/backend/src/ee/services/permission/permission-types.ts @@ -1,22 +1,12 @@ import picomatch from "picomatch"; import { z } from "zod"; -export type TBuildProjectPermissionDTO = { - permissions?: unknown; - role: string; -}[]; - -export type TBuildOrgPermissionDTO = { - permissions?: unknown; - role: string; -}[]; - export enum PermissionConditionOperators { $IN = "$in", $ALL = "$all", $REGEX = "$regex", $EQ = "$eq", - $NEQ = "$neq", + $NEQ = "$ne", $GLOB = "$glob" } diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index da362576d..9b206bd9d 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -1,7 +1,10 @@ import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability"; - -import { conditionsMatcher } from "@app/lib/casl"; 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 { @@ -38,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; }; @@ -46,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] @@ -595,3 +619,18 @@ export const isAtLeastAsPrivilegedWorkspace = ( return set1.size >= set2.size; }; /* eslint-enable */ + +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}` }); + } +}; 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/knex/dynamic.ts b/backend/src/lib/knex/dynamic.ts new file mode 100644 index 000000000..90a9d2abe --- /dev/null +++ b/backend/src/lib/knex/dynamic.ts @@ -0,0 +1,88 @@ +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)[]; +}; + +// 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.where(filterAst.field, filterAst.value); + break; + } + case "startsWith": { + void queryBuilder.where(filterAst.field, filterAst.value); + break; + } + case "endsWith": { + void queryBuilder.where(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}` }); + } + } +};