From 76e5d61da5aa6f7aed6a8ccf2c25186460f9637d Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Thu, 14 Dec 2023 15:59:54 +0530 Subject: [PATCH] feat(infisical-pg: added services for project, project key and environment --- backend-pg/scripts/create-backend-file.ts | 28 ++ backend-pg/src/@types/fastify.d.ts | 6 + backend-pg/src/@types/knex.d.ts | 11 +- .../db/migrations/20231212110939_project.ts | 20 ++ .../20231212110946_project-membership.ts | 1 + backend-pg/src/db/schemas/index.ts | 1 + backend-pg/src/db/schemas/models.ts | 3 +- backend-pg/src/db/schemas/project-keys.ts | 23 ++ .../ee/services/permission/permission-dal.ts | 23 +- .../services/permission/permission-service.ts | 66 +++- .../services/permission/project-permission.ts | 286 ++++++++++++++++++ backend-pg/src/lib/knex/index.ts | 32 +- backend-pg/src/lib/knex/join.ts | 28 ++ backend-pg/src/lib/types/index.ts | 8 + .../server/plugins/auth/inject-identity.ts | 33 +- .../server/plugins/auth/inject-permission.ts | 13 + backend-pg/src/services/auth/auth-type.ts | 6 + backend-pg/src/services/org/org-service.ts | 16 +- .../services/project-env/project-env-dal.ts | 10 + .../project-env/project-env-service.ts | 87 ++++++ .../services/project-env/project-env-types.ts | 16 + .../services/project-key/project-key-dal.ts | 42 +++ .../project-key/project-key-service.ts | 64 ++++ .../services/project-key/project-key-types.ts | 9 + .../project-membership-dal.ts | 45 +++ .../project-membership-service.ts | 190 ++++++++++++ .../project-membership-types.ts | 16 + .../services/project-role/project-role-dal.ts | 7 + .../project-role/project-role-service.ts | 158 ++++++++++ .../project-role/project-role-types.ts | 0 .../src/services/project/project-dal.ts | 116 +++++++ .../src/services/project/project-service.ts | 113 +++++++ .../src/services/project/project-types.ts | 20 ++ 33 files changed, 1469 insertions(+), 28 deletions(-) create mode 100644 backend-pg/src/db/schemas/project-keys.ts create mode 100644 backend-pg/src/ee/services/permission/project-permission.ts create mode 100644 backend-pg/src/lib/knex/join.ts create mode 100644 backend-pg/src/server/plugins/auth/inject-permission.ts create mode 100644 backend-pg/src/services/project-env/project-env-dal.ts create mode 100644 backend-pg/src/services/project-env/project-env-service.ts create mode 100644 backend-pg/src/services/project-env/project-env-types.ts create mode 100644 backend-pg/src/services/project-key/project-key-dal.ts create mode 100644 backend-pg/src/services/project-key/project-key-service.ts create mode 100644 backend-pg/src/services/project-key/project-key-types.ts create mode 100644 backend-pg/src/services/project-membership/project-membership-dal.ts create mode 100644 backend-pg/src/services/project-membership/project-membership-service.ts create mode 100644 backend-pg/src/services/project-membership/project-membership-types.ts create mode 100644 backend-pg/src/services/project-role/project-role-dal.ts create mode 100644 backend-pg/src/services/project-role/project-role-service.ts create mode 100644 backend-pg/src/services/project-role/project-role-types.ts create mode 100644 backend-pg/src/services/project/project-dal.ts create mode 100644 backend-pg/src/services/project/project-service.ts create mode 100644 backend-pg/src/services/project/project-types.ts diff --git a/backend-pg/scripts/create-backend-file.ts b/backend-pg/scripts/create-backend-file.ts index ae3ad2441..d6035a324 100644 --- a/backend-pg/scripts/create-backend-file.ts +++ b/backend-pg/scripts/create-backend-file.ts @@ -11,6 +11,7 @@ console.log(` Component List -------------- 1. Service component +2. DAL component `); const componentType = parseInt(prompt("Select a component: "), 10); @@ -62,4 +63,31 @@ export const ${serviceName} = ({ ${camelCase}Dal }: ${serviceTypeName}Dep) => { ` ); writeFileSync(path.join(dir, `${componentName}-types.ts`), ""); +} else if (componentType === 2) { + const componentName = prompt("Enter service name: "); + const componentPath = prompt("Path wrt service folder: "); + const pascalCase = componentName + .split("-") + .map((el) => `${el[0].toUpperCase()}${el.slice(1)}`) + .join(""); + const camelCase = componentName + .split("-") + .map((el, index) => (index === 0 ? el : `${el[0].toUpperCase()}${el.slice(1)}`)) + .join(""); + const dalTypeName = `T${pascalCase}DalFactory`; + const dalName = `${camelCase}DalFactory`; + + writeFileSync( + path.join(__dirname, "../src/services", componentPath, `${componentName}-dal.ts`), + `import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; + +export type ${dalTypeName} = ReturnType; + +export const ${dalName} = (db: TDbClient) => { + + return { }; +}; +` + ); } diff --git a/backend-pg/src/@types/fastify.d.ts b/backend-pg/src/@types/fastify.d.ts index b5d1af01c..f0330fecf 100644 --- a/backend-pg/src/@types/fastify.d.ts +++ b/backend-pg/src/@types/fastify.d.ts @@ -5,6 +5,7 @@ import { TAuthLoginFactory } from "@app/services/auth/auth-login-service"; import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service"; import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service"; import { AuthMode } from "@app/services/auth/auth-signup-type"; +import { ActorType } from "@app/services/auth/auth-type"; import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; @@ -25,10 +26,15 @@ declare module "fastify" { // identity injection. depending on which kinda of token the information is filled in auth auth: { authMode: AuthMode.JWT | AuthMode.API_KEY_V2 | AuthMode.API_KEY; + actor: ActorType.USER; userId: string; tokenVersionId: string; // the session id of token used user: TUsers; }; + permission: { + type: ActorType; + id: string; + }; // passport data passportUser: { isCompleted: string; diff --git a/backend-pg/src/@types/knex.d.ts b/backend-pg/src/@types/knex.d.ts index e2f7b13cf..686441dde 100644 --- a/backend-pg/src/@types/knex.d.ts +++ b/backend-pg/src/@types/knex.d.ts @@ -27,6 +27,10 @@ import { TOrgRolesUpdate, TProjectEnvironments, TProjectEnvironmentsInsert, + TProjectEnvironmentsUpdate, + TProjectKeys, + TProjectKeysInsert, + TProjectKeysUpdate, TProjectMemberships, TProjectMembershipsInsert, TProjectMembershipsUpdate, @@ -109,12 +113,17 @@ declare module "knex/types/tables" { [TableName.Environment]: Knex.CompositeTableType< TProjectEnvironments, TProjectEnvironmentsInsert, - TPRo + TProjectEnvironmentsUpdate >; [TableName.ProjectRoles]: Knex.CompositeTableType< TProjectRoles, TProjectRolesInsert, TProjectRolesUpdate >; + [TableName.ProjectKeys]: Knex.CompositeTableType< + TProjectKeys, + TProjectKeysInsert, + TProjectKeysUpdate + >; } } diff --git a/backend-pg/src/db/migrations/20231212110939_project.ts b/backend-pg/src/db/migrations/20231212110939_project.ts index fdc7dff4b..73f86802f 100644 --- a/backend-pg/src/db/migrations/20231212110939_project.ts +++ b/backend-pg/src/db/migrations/20231212110939_project.ts @@ -1,4 +1,5 @@ import { Knex } from "knex"; + import { TableName } from "../schemas"; import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; @@ -13,6 +14,7 @@ export async function up(knex: Knex): Promise { t.timestamps(true, true, true); }); } + // environments await createOnUpdateTrigger(knex, TableName.Project); if (!(await knex.schema.hasTable(TableName.Environment))) { await knex.schema.createTable(TableName.Environment, (t) => { @@ -24,10 +26,28 @@ export async function up(knex: Knex): Promise { t.timestamps(true, true, true); }); } + // project key + if (!(await knex.schema.hasTable(TableName.ProjectKeys))) { + await knex.schema.createTable(TableName.ProjectKeys, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.text("encryptedKey").notNullable(); + t.text("nonce").notNullable(); + t.uuid("receiverId").notNullable(); + t.foreign("receiverId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.uuid("senderId").notNullable(); + // if sender is deleted just don't do anything to this record + t.foreign("senderId").references("id").inTable(TableName.Users).onDelete("NO ACTION"); + t.uuid("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.ProjectKeys); } export async function down(knex: Knex): Promise { await knex.schema.dropTableIfExists(TableName.Environment); await knex.schema.dropTableIfExists(TableName.Project); await dropOnUpdateTrigger(knex, TableName.Project); + await dropOnUpdateTrigger(knex, TableName.ProjectKeys); } diff --git a/backend-pg/src/db/migrations/20231212110946_project-membership.ts b/backend-pg/src/db/migrations/20231212110946_project-membership.ts index 8218eb2a4..64b2efd6e 100644 --- a/backend-pg/src/db/migrations/20231212110946_project-membership.ts +++ b/backend-pg/src/db/migrations/20231212110946_project-membership.ts @@ -1,4 +1,5 @@ import { Knex } from "knex"; + import { TableName } from "../schemas"; import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; diff --git a/backend-pg/src/db/schemas/index.ts b/backend-pg/src/db/schemas/index.ts index ec8e272eb..4ae378c76 100644 --- a/backend-pg/src/db/schemas/index.ts +++ b/backend-pg/src/db/schemas/index.ts @@ -8,6 +8,7 @@ export * from "./org-memberships"; export * from "./org-roles"; export * from "./organizations"; export * from "./project-environments"; +export * from "./project-keys"; export * from "./project-memberships"; export * from "./project-roles"; export * from "./projects"; diff --git a/backend-pg/src/db/schemas/models.ts b/backend-pg/src/db/schemas/models.ts index 438fca26a..596f7ccb7 100644 --- a/backend-pg/src/db/schemas/models.ts +++ b/backend-pg/src/db/schemas/models.ts @@ -16,7 +16,8 @@ export enum TableName { Project = "projects", Environment = "project_environments", ProjectMembership = "project_memberships", - ProjectRoles = "project_roles" + ProjectRoles = "project_roles", + ProjectKeys = "project_keys" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; diff --git a/backend-pg/src/db/schemas/project-keys.ts b/backend-pg/src/db/schemas/project-keys.ts new file mode 100644 index 000000000..8738ba605 --- /dev/null +++ b/backend-pg/src/db/schemas/project-keys.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ProjectKeysSchema = z.object({ + id: z.string().uuid(), + encryptedKey: z.string(), + nonce: z.string(), + receiverId: z.string().uuid(), + senderId: z.string().uuid(), + projectId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TProjectKeys = z.infer; +export type TProjectKeysInsert = Omit; +export type TProjectKeysUpdate = Partial>; diff --git a/backend-pg/src/ee/services/permission/permission-dal.ts b/backend-pg/src/ee/services/permission/permission-dal.ts index 1ffe54211..c6e4233ef 100644 --- a/backend-pg/src/ee/services/permission/permission-dal.ts +++ b/backend-pg/src/ee/services/permission/permission-dal.ts @@ -1,5 +1,5 @@ import { TDbClient } from "@app/db"; -import { TableName, TOrgMemberships } from "@app/db/schemas"; +import { TableName, TOrgMemberships, TProjectMemberships } from "@app/db/schemas"; export type TPermissionDalFactory = ReturnType; @@ -18,7 +18,26 @@ export const permissionDalFactory = (db: TDbClient) => { return membership; }; + const getProjectPermission = async ( + userId: string, + projectId: string + ): Promise<(TProjectMemberships & { permissions: string }) | undefined> => { + const membership = await db(TableName.ProjectMembership) + .leftJoin( + TableName.ProjectRoles, + `${TableName.ProjectMembership}.roleId`, + `${TableName.ProjectRoles}.id` + ) + .where("userId", userId) + .where(`${TableName.ProjectMembership}.projectId`, projectId) + .select(`${TableName.ProjectMembership}.*`, "permissions") + .first(); + + return membership; + }; + return { - getOrgPermission + getOrgPermission, + getProjectPermission }; }; diff --git a/backend-pg/src/ee/services/permission/permission-service.ts b/backend-pg/src/ee/services/permission/permission-service.ts index f88b91152..fbe770a03 100644 --- a/backend-pg/src/ee/services/permission/permission-service.ts +++ b/backend-pg/src/ee/services/permission/permission-service.ts @@ -1,4 +1,6 @@ +import { ProjectMembershipRole } from "@app/db/schemas"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; import { conditionsMatcher, @@ -7,6 +9,11 @@ import { OrgPermissionSet } from "./org-permission"; import { TPermissionDalFactory } from "./permission-dal"; +import { + projectAdminPermissions, + projectMemberPermissions, + projectNoAccessPermissions, + projectViewerPermission} from "./project-permission"; import { createMongoAbility, MongoAbility, RawRuleOf } from "@casl/ability"; import { unpackRules } from "@casl/ability/extra"; @@ -44,7 +51,64 @@ export const permissionServiceFactory = ({ permissionDal }: TPermissionServiceFa throw new BadRequestError({ name: "Role missing", message: "User role not found" }); }; + // user permission for a project in an organization + const getUserProjectPermission = async (userId: string, projectId: string) => { + const membership = await permissionDal.getProjectPermission(userId, projectId); + if (!membership) throw new UnauthorizedError({ name: "User not in org" }); + if (membership.role === "custom" && !membership.permissions) { + throw new BadRequestError({ name: "Custom permission not found" }); + } + + if (membership.role === ProjectMembershipRole.Admin) + return { permission: projectAdminPermissions, membership }; + if (membership.role === ProjectMembershipRole.Member) + return { permission: projectMemberPermissions, membership }; + if (membership.role === ProjectMembershipRole.Viewer) + return { permission: projectViewerPermission, membership }; + if (membership.role === ProjectMembershipRole.NoAccess) + return { permission: projectNoAccessPermissions, membership }; + if (membership.role === ProjectMembershipRole.Custom) { + const permission = createMongoAbility( + // akhilmhdh: putting any due to ts incompatiable matching with string and the other + unpackRules>>(membership.permissions as any), + { + conditionsMatcher + } + ); + return { permission, membership }; + } + + throw new BadRequestError({ name: "Role missing", message: "User role not found" }); + }; + + const getProjectPermission = async (type: ActorType, id: string, projectId: string) => { + switch (type) { + case ActorType.USER: + return getUserProjectPermission(id, projectId); + default: + throw new UnauthorizedError({ + message: "Permission not defined", + name: "Get org permission" + }); + } + }; + + const getOrgPermission = async (type: ActorType, id: string, orgId: string) => { + switch (type) { + case ActorType.USER: + return getUserOrgPermission(id, orgId); + default: + throw new UnauthorizedError({ + message: "Permission not defined", + name: "Get org permission" + }); + } + }; + return { - getUserOrgPermission + getUserOrgPermission, + getOrgPermission, + getUserProjectPermission, + getProjectPermission }; }; diff --git a/backend-pg/src/ee/services/permission/project-permission.ts b/backend-pg/src/ee/services/permission/project-permission.ts new file mode 100644 index 000000000..962ee7807 --- /dev/null +++ b/backend-pg/src/ee/services/permission/project-permission.ts @@ -0,0 +1,286 @@ +import picomatch from "picomatch"; + +import { + AbilityBuilder, + buildMongoQueryMatcher, + createMongoAbility, + ForcedSubject, + MongoAbility} from "@casl/ability"; +import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js"; + +const $glob: FieldInstruction = { + type: "field", + validate(instruction, value) { + if (typeof value !== "string") { + throw new Error(`"${instruction.name}" expects value to be a string`); + } + } +}; + +const glob: JsInterpreter> = (node, object, context) => { + const secretPath = context.get(object, node.field); + const permissionSecretGlobPath = node.value; + return picomatch.isMatch(secretPath, permissionSecretGlobPath, { strictSlashes: false }); +}; + +export const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob }); + +export enum ProjectPermissionActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete" +} + +export enum ProjectPermissionSub { + Role = "role", + Member = "member", + Settings = "settings", + Integrations = "integrations", + Webhooks = "webhooks", + ServiceTokens = "service-tokens", + Environments = "environments", + Tags = "tags", + AuditLogs = "audit-logs", + IpAllowList = "ip-allowlist", + Project = "workspace", + Secrets = "secrets", + SecretRollback = "secret-rollback", + SecretApproval = "secret-approval", + SecretRotation = "secret-rotation", + Identity = "identity" +} + +type SubjectFields = { + environment: string; + secretPath: string; +}; + +export type ProjectPermissionSet = + | [ + ProjectPermissionActions, + ProjectPermissionSub.Secrets | (ForcedSubject & SubjectFields) + ] + | [ProjectPermissionActions, ProjectPermissionSub.Role] + | [ProjectPermissionActions, ProjectPermissionSub.Tags] + | [ProjectPermissionActions, ProjectPermissionSub.Member] + | [ProjectPermissionActions, ProjectPermissionSub.Integrations] + | [ProjectPermissionActions, ProjectPermissionSub.Webhooks] + | [ProjectPermissionActions, ProjectPermissionSub.AuditLogs] + | [ProjectPermissionActions, ProjectPermissionSub.Environments] + | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] + | [ProjectPermissionActions, ProjectPermissionSub.Settings] + | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] + | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] + | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] + | [ProjectPermissionActions, ProjectPermissionSub.Identity] + | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] + | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] + | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] + | [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback]; + +const buildAdminPermission = () => { + const { can, build } = new AbilityBuilder>(createMongoAbility); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Secrets); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.SecretApproval); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); + can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRotation); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretRotation); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.SecretRotation); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); + can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Member); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Role); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Role); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Role); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Integrations); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Webhooks); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Identity); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Identity); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); + can(ProjectPermissionActions.Create, ProjectPermissionSub.ServiceTokens); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.ServiceTokens); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.ServiceTokens); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Settings); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Settings); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Environments); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Environments); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Environments); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Tags); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Tags); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Tags); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + can(ProjectPermissionActions.Create, ProjectPermissionSub.AuditLogs); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.AuditLogs); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.AuditLogs); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); + can(ProjectPermissionActions.Create, ProjectPermissionSub.IpAllowList); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.IpAllowList); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.IpAllowList); + + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Project); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); + + return build({ conditionsMatcher }); +}; + +export const projectAdminPermissions = buildAdminPermission(); + +const buildMemberPermission = () => { + const { can, build } = new AbilityBuilder>(createMongoAbility); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Secrets); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); + can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Member); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Integrations); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Webhooks); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Identity); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Identity); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); + can(ProjectPermissionActions.Create, ProjectPermissionSub.ServiceTokens); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.ServiceTokens); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.ServiceTokens); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Settings); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Settings); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Environments); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Environments); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Environments); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Tags); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Tags); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Tags); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); + + return build({ conditionsMatcher }); +}; + +export const projectMemberPermissions = buildMemberPermission(); + +const buildViewerPermission = () => { + const { can, build } = new AbilityBuilder>(createMongoAbility); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); + can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); + + return build({ conditionsMatcher }); +}; + +export const projectViewerPermission = buildViewerPermission(); + +const buildNoAccessProjectPermission = () => { + const { build } = new AbilityBuilder>(createMongoAbility); + return build({ conditionsMatcher }); +}; + +export const projectNoAccessPermissions = buildNoAccessProjectPermission(); + +/** + * Extracts and formats permissions from a CASL Ability object or a raw permission set. + * @param ability + * @returns + */ +const extractPermissions = (ability: any) => + ability.A.map((permission: any) => `${permission.action}_${permission.subject}`); + +/** + * 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 isAtLeastAsPrivilegedWorkspace = ( + permissions1: MongoAbility | ProjectPermissionSet, + permissions2: MongoAbility | ProjectPermissionSet +) => { + const set1 = new Set(extractPermissions(permissions1)); + const set2 = new Set(extractPermissions(permissions2)); + + // eslint-disable-next-line + for (const perm of set2) { + if (!set1.has(perm)) { + return false; + } + } + + return set1.size >= set2.size; +}; diff --git a/backend-pg/src/lib/knex/index.ts b/backend-pg/src/lib/knex/index.ts index 7b75d8fb8..d6b80ecca 100644 --- a/backend-pg/src/lib/knex/index.ts +++ b/backend-pg/src/lib/knex/index.ts @@ -5,6 +5,8 @@ import { TableName } from "@app/db/schemas"; import { DatabaseError } from "../errors"; +export * from "./join"; + export const withTransaction = (db: Knex, dal: K) => ({ transaction: async (cb: (tx: Knex) => Promise) => db.transaction(async (trx) => { @@ -53,19 +55,29 @@ export const ormify = ( }, create: async (data: Tables[Tname]["insert"], tx?: Knex) => { try { - const [user] = await (tx || db)(tableName).insert(data).returning("*"); - return user; + const [res] = await (tx || db)(tableName).insert(data).returning("*"); + return res; + } catch (error) { + throw new DatabaseError({ error, name: "Create" }); + } + }, + insertMany: async (data: readonly Tables[Tname]["insert"][], tx?: Knex) => { + try { + const res = await (tx || db)(tableName) + .insert(data as any) + .returning("*"); + return res; } catch (error) { throw new DatabaseError({ error, name: "Create" }); } }, updateById: async (id: string, data: Tables[Tname]["update"], tx?: Knex) => { try { - const [user] = await (tx || db)(tableName) + const [res] = await (tx || db)(tableName) .where({ id } as any) .update(data as any) .returning("*"); - return user; + return res; } catch (error) { throw new DatabaseError({ error, name: "Update by id" }); } @@ -76,30 +88,30 @@ export const ormify = ( tx?: Knex ) => { try { - const user = await (tx || db)(tableName) + const res = await (tx || db)(tableName) .where(filter) .update(data as any) .returning("*"); - return user; + return res; } catch (error) { throw new DatabaseError({ error, name: "Update" }); } }, deleteById: async (id: string, tx?: Knex) => { try { - const [user] = await (tx || db)(tableName) + const [res] = await (tx || db)(tableName) .where({ id } as any) .delete() .returning("*"); - return user; + return res; } catch (error) { throw new DatabaseError({ error, name: "Delete by id" }); } }, delete: async (filter: Partial, tx?: Knex) => { try { - const user = await (tx || db)(tableName).where(filter).delete().returning("*"); - return user; + const res = await (tx || db)(tableName).where(filter).delete().returning("*"); + return res; } catch (error) { throw new DatabaseError({ error, name: "Delete" }); } diff --git a/backend-pg/src/lib/knex/join.ts b/backend-pg/src/lib/knex/join.ts new file mode 100644 index 000000000..68deb014d --- /dev/null +++ b/backend-pg/src/lib/knex/join.ts @@ -0,0 +1,28 @@ +export const mergeOneToManyRelation = < + T extends Record, + Pk extends keyof T, + P extends Record, + C extends Record, + Ck extends string = "child" +>( + data: T[], + key: Pk, + parentMapper: (arg: T) => P, + childMapper: (arg: T) => C, + childKey: Ck +) => { + const recordFirstVisitIndex: Record = {}; + const groupedRecord: (P & Record)[] = []; + for (let i = 0; i < data.length; i += 1) { + const pk = data[i][key]; + const row = data[i]; + if (typeof recordFirstVisitIndex[pk] === "undefined") { + const parent = parentMapper(row) as any; + parent[childKey] = []; + groupedRecord.push(parent); + recordFirstVisitIndex[pk] = i; + } + groupedRecord[recordFirstVisitIndex[pk]][childKey].push(childMapper(row)); + } + return groupedRecord; +}; diff --git a/backend-pg/src/lib/types/index.ts b/backend-pg/src/lib/types/index.ts index c70fe1fd7..2674c64f9 100644 --- a/backend-pg/src/lib/types/index.ts +++ b/backend-pg/src/lib/types/index.ts @@ -1,3 +1,11 @@ +import { ActorType } from "@app/services/auth/auth-type"; + +export type TProjectPermission = { + actor: ActorType; + actorId: string; + projectId: string; +}; + export type RequiredKeys = { [K in keyof T]-?: undefined extends T[K] ? never : K; }[keyof T]; diff --git a/backend-pg/src/server/plugins/auth/inject-identity.ts b/backend-pg/src/server/plugins/auth/inject-identity.ts index 33a6a54d3..ae9f9c9b0 100644 --- a/backend-pg/src/server/plugins/auth/inject-identity.ts +++ b/backend-pg/src/server/plugins/auth/inject-identity.ts @@ -4,29 +4,46 @@ import jwt, { JwtPayload } from "jsonwebtoken"; import { getConfig } from "@app/lib/config/env"; import { UnauthorizedError } from "@app/lib/errors"; -import { AuthMode, AuthModeJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type"; +import { + ActorType, + AuthMode, + AuthModeJwtTokenPayload, + AuthTokenType +} from "@app/services/auth/auth-type"; const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { const apiKey = req.headers?.["x-api-key"]; if (apiKey) { - return { authMode: AuthMode.API_KEY, token: apiKey }; + return { authMode: AuthMode.API_KEY, token: apiKey, actor: ActorType.USER } as const; } const authHeader = req.headers?.authorization; if (!authHeader) return { authMode: null, token: null }; const authTokenValue = authHeader.slice(7); // slice of after Bearer if (authTokenValue.startsWith("st.")) { - return { authMode: AuthMode.SERVICE_TOKEN, token: authTokenValue } as const; + return { + authMode: AuthMode.SERVICE_TOKEN, + token: authTokenValue, + actor: ActorType.USER + } as const; } const decodedToken = jwt.verify(authTokenValue, jwtSecret) as JwtPayload; switch (decodedToken.authTokenType) { case AuthTokenType.ACCESS_TOKEN: - return { authMode: AuthMode.JWT, token: decodedToken as AuthModeJwtTokenPayload } as const; + return { + authMode: AuthMode.JWT, + token: decodedToken as AuthModeJwtTokenPayload, + actor: ActorType.USER + } as const; case AuthTokenType.API_KEY: - return { authMode: AuthMode.API_KEY_V2, token: decodedToken } as const; + return { authMode: AuthMode.API_KEY_V2, token: decodedToken, actor: ActorType.USER } as const; case AuthMode.SERVICE_ACCESS_TOKEN: - return { authMode: AuthMode.SERVICE_ACCESS_TOKEN, token: decodedToken } as const; + return { + authMode: AuthMode.SERVICE_ACCESS_TOKEN, + token: decodedToken, + actor: ActorType.USER + } as const; default: return { authMode: null, token: null } as const; } @@ -52,7 +69,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { server.decorateRequest("auth", null); server.addHook("onRequest", async (req) => { const appCfg = getConfig(); - const { authMode, token } = await extractAuth(req, appCfg.JWT_AUTH_SECRET); + const { authMode, token, actor } = await extractAuth(req, appCfg.JWT_AUTH_SECRET); if (!authMode) return; // TODO(akhilmhdh-pg): fill in rest of auth mode logic switch (authMode) { @@ -61,7 +78,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { server, token as AuthModeJwtTokenPayload ); - req.auth = { authMode: AuthMode.JWT, user, userId: user.id, tokenVersionId }; + req.auth = { authMode: AuthMode.JWT, user, userId: user.id, tokenVersionId, actor }; break; } case AuthMode.SERVICE_TOKEN: diff --git a/backend-pg/src/server/plugins/auth/inject-permission.ts b/backend-pg/src/server/plugins/auth/inject-permission.ts new file mode 100644 index 000000000..23e5a0b35 --- /dev/null +++ b/backend-pg/src/server/plugins/auth/inject-permission.ts @@ -0,0 +1,13 @@ +import fp from "fastify-plugin"; + +import { ActorType } from "@app/services/auth/auth-type"; + +// inject permission type needed based on auth extracted +export const injectPermission = fp(async (server) => { + server.decorateRequest("permission", null); + server.addHook("onRequest", async (req) => { + if (req.auth.actor === ActorType.USER) { + req.permission = { type: ActorType.USER, id: req.auth.userId }; + } + }); +}); diff --git a/backend-pg/src/services/auth/auth-type.ts b/backend-pg/src/services/auth/auth-type.ts index e7537704f..ebfade21c 100644 --- a/backend-pg/src/services/auth/auth-type.ts +++ b/backend-pg/src/services/auth/auth-type.ts @@ -27,6 +27,12 @@ export enum AuthMode { API_KEY_V2 = "apiKeyV2" } +export enum ActorType { // would extend to AWS, Azure, ... + USER = "user", // userIdentity + SERVICE = "service", + IDENTITY = "identity" +} + export type AuthModeJwtTokenPayload = { authTokenType: AuthTokenType.ACCESS_TOKEN; userId: string; diff --git a/backend-pg/src/services/org/org-service.ts b/backend-pg/src/services/org/org-service.ts index 7554d21ea..c81f324c4 100644 --- a/backend-pg/src/services/org/org-service.ts +++ b/backend-pg/src/services/org/org-service.ts @@ -145,14 +145,20 @@ export const orgServiceFactory = ({ const customRole = await orgRoleDal.findOne({ slug: role, orgId }); if (!customRole) throw new BadRequestError({ name: "Update membership", message: "Role not found" }); - const membership = await orgDal.updateMembershipById(membershipId, { - role: OrgMembershipRole.Custom, - roleId: customRole.id - }); + const [membership] = await orgDal.updateMembership( + { id: membershipId, orgId }, + { + role: OrgMembershipRole.Custom, + roleId: customRole.id + } + ); return membership; } - const membership = await orgDal.updateMembershipById(membershipId, { role, roleId: null }); + const [membership] = await orgDal.updateMembership( + { id: membershipId, orgId }, + { role, roleId: null } + ); return membership; }; /* diff --git a/backend-pg/src/services/project-env/project-env-dal.ts b/backend-pg/src/services/project-env/project-env-dal.ts new file mode 100644 index 000000000..917a71eba --- /dev/null +++ b/backend-pg/src/services/project-env/project-env-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TProjectEnvDalFactory = ReturnType; + +export const projectEnvDalFactory = (db: TDbClient) => { + const projectEnvOrm = ormify(db, TableName.Environment); + return projectEnvOrm; +}; diff --git a/backend-pg/src/services/project-env/project-env-service.ts b/backend-pg/src/services/project-env/project-env-service.ts new file mode 100644 index 000000000..1bd10f5c8 --- /dev/null +++ b/backend-pg/src/services/project-env/project-env-service.ts @@ -0,0 +1,87 @@ +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; + +import { TProjectEnvDalFactory } from "./project-env-dal"; +import { TCreateEnvDTO, TDeleteEnvDTO, TUpdateEnvDTO } from "./project-env-types"; + +import { ForbiddenError } from "@casl/ability"; + +type TProjectEnvServiceFactoryDep = { + projectEnvDal: TProjectEnvDalFactory; + permissionService: Pick; +}; + +export type TProjectEnvServiceFactory = ReturnType; + +export const projectEnvServiceFactory = ({ + projectEnvDal, + permissionService +}: TProjectEnvServiceFactoryDep) => { + const createEnvironment = async ({ projectId, actorId, actor, name, slug }: TCreateEnvDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Environments + ); + + // TODO(akhilmhdh-pg): add licence service here + const existingEnv = await projectEnvDal.findOne({ slug }); + if (existingEnv) + throw new BadRequestError({ + message: "Environment with slug already exist", + name: "Create envv" + }); + + const env = await projectEnvDal.create({ slug, name, projectId }); + return env; + }; + + const updateEnvironment = async ({ + projectId, + slug, + actor, + actorId, + name, + id + }: TUpdateEnvDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Environments + ); + + if (slug) { + const existingEnv = await projectEnvDal.findOne({ slug }); + if (existingEnv && existingEnv.id !== id) { + throw new BadRequestError({ + message: "Environment with slug already exist", + name: "Create envv" + }); + } + } + + const [env] = await projectEnvDal.update({ id, projectId }, { name, slug }); + return env; + }; + + const deleteEnvironment = async ({ projectId, actor, actorId, id }: TDeleteEnvDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.Environments + ); + + const [env] = await projectEnvDal.delete({ id, projectId }); + return env; + }; + + return { + createEnvironment, + updateEnvironment, + deleteEnvironment + }; +}; diff --git a/backend-pg/src/services/project-env/project-env-types.ts b/backend-pg/src/services/project-env/project-env-types.ts new file mode 100644 index 000000000..aeea4b10f --- /dev/null +++ b/backend-pg/src/services/project-env/project-env-types.ts @@ -0,0 +1,16 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateEnvDTO = { + name: string; + slug: string; +} & TProjectPermission; + +export type TUpdateEnvDTO = { + id: string; + name?: string; + slug?: string; +} & TProjectPermission; + +export type TDeleteEnvDTO = { + id: string; +} & TProjectPermission; diff --git a/backend-pg/src/services/project-key/project-key-dal.ts b/backend-pg/src/services/project-key/project-key-dal.ts new file mode 100644 index 000000000..cd60c8d0c --- /dev/null +++ b/backend-pg/src/services/project-key/project-key-dal.ts @@ -0,0 +1,42 @@ +import { TDbClient } from "@app/db"; +import { TableName,TProjectKeys } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TProjectKeyDalFactory = ReturnType; + +export const projectKeyDalFactory = (db: TDbClient) => { + const projectKeyOrm = ormify(db, TableName.ProjectKeys); + + const findLatestProjectKey = async ( + userId: string, + projectId: string + ): Promise => { + try { + const projectKey = await db(TableName.ProjectKeys) + .where({ projectId, receiverId: userId }) + .join(TableName.Users, `${TableName.ProjectKeys}.senderId`, `${TableName.Users}.id`) + .join( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .orderBy("createdAt", "desc", "last") + .select(`${TableName.ProjectKeys}.*`, `${TableName.UserEncryptionKey}.publicKey`) + .first(); + if (projectKey) { + projectKey.sender = { + publicKey: projectKey.publicKey + }; + } + return projectKey; + } catch (error) { + throw new DatabaseError({ error, name: "Find latest project key" }); + } + }; + + return { + ...projectKeyOrm, + findLatestProjectKey + }; +}; diff --git a/backend-pg/src/services/project-key/project-key-service.ts b/backend-pg/src/services/project-key/project-key-service.ts new file mode 100644 index 000000000..d755efb56 --- /dev/null +++ b/backend-pg/src/services/project-key/project-key-service.ts @@ -0,0 +1,64 @@ +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; + +import { TProjectMembershipDalFactory } from "../project-membership/project-membership-dal"; +import { TProjectKeyDalFactory } from "./project-key-dal"; +import { TGetLatestProjectKeyDTO, TUploadProjectKeyDTO } from "./project-key-types"; + +import { ForbiddenError } from "@casl/ability"; + +type TProjectKeyServiceFactoryDep = { + permissionService: TPermissionServiceFactory; + projectKeyDal: TProjectKeyDalFactory; + projectMembershipDal: TProjectMembershipDalFactory; +}; + +export type TProjectKeyServiceFactory = ReturnType; + +export const projectKeyServiceFactory = ({ + projectKeyDal, + projectMembershipDal, + permissionService +}: TProjectKeyServiceFactoryDep) => { + const uploadProjectKeys = async ({ + receiverId, + actor, + actorId, + projectId, + nonce, + encryptedKey + }: TUploadProjectKeyDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Member + ); + + const receiverMembership = await projectMembershipDal.findOne({ + userId: receiverId, + projectId + }); + if (!receiverMembership) + throw new BadRequestError({ + message: "Failed to validate receiver membership", + name: "Upload project keys" + }); + + await projectKeyDal.create({ projectId, receiverId, encryptedKey, nonce, senderId: actorId }); + }; + + const getLatestProjectKey = async ({ actorId, projectId, actor }: TGetLatestProjectKeyDTO) => { + await permissionService.getProjectPermission(actor, actorId, projectId); + const latestKey = await projectKeyDal.findLatestProjectKey(actorId, projectId); + return latestKey; + }; + + return { + uploadProjectKeys, + getLatestProjectKey + }; +}; diff --git a/backend-pg/src/services/project-key/project-key-types.ts b/backend-pg/src/services/project-key/project-key-types.ts new file mode 100644 index 000000000..c9dac4ca2 --- /dev/null +++ b/backend-pg/src/services/project-key/project-key-types.ts @@ -0,0 +1,9 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TGetLatestProjectKeyDTO = TProjectPermission; + +export type TUploadProjectKeyDTO = { + encryptedKey: string; + nonce: string; + receiverId: string; +} & TProjectPermission; diff --git a/backend-pg/src/services/project-membership/project-membership-dal.ts b/backend-pg/src/services/project-membership/project-membership-dal.ts new file mode 100644 index 000000000..ef4c489a2 --- /dev/null +++ b/backend-pg/src/services/project-membership/project-membership-dal.ts @@ -0,0 +1,45 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TProjectMembershipDalFactory = ReturnType; + +export const projectMembershipDalFactory = (db: TDbClient) => { + const projectMemberOrm = ormify(db, TableName.ProjectMembership); + + // special query + const findAllProjectMembers = async (projectId: string) => { + try { + const members = await db(TableName.ProjectMembership) + .where({ projectId }) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .join( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .select( + db.ref("id").withSchema(TableName.ProjectMembership), + db.ref("inviteEmail").withSchema(TableName.ProjectMembership), + db.ref("orgId").withSchema(TableName.ProjectMembership), + db.ref("role").withSchema(TableName.ProjectMembership), + db.ref("roleId").withSchema(TableName.ProjectMembership), + db.ref("status").withSchema(TableName.ProjectMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey) + ); + return members.map(({ email, firstName, lastName, userId, publicKey, ...data }) => ({ + ...data, + user: { email, firstName, lastName, id: userId, publicKey } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find all project members" }); + } + }; + + return { ...projectMemberOrm, findAllProjectMembers }; +}; diff --git a/backend-pg/src/services/project-membership/project-membership-service.ts b/backend-pg/src/services/project-membership/project-membership-service.ts new file mode 100644 index 000000000..e7ae08bd9 --- /dev/null +++ b/backend-pg/src/services/project-membership/project-membership-service.ts @@ -0,0 +1,190 @@ +import { OrgMembershipStatus, 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 { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; + +import { TOrgDalFactory } from "../org/org-dal"; +import { TProjectDalFactory } from "../project/project-dal"; +import { TProjectKeyDalFactory } from "../project-key/project-key-dal"; +import { TProjectRoleDalFactory } from "../project-role/project-role-dal"; +import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; +import { TUserDalFactory } from "../user/user-dal"; +import { TProjectMembershipDalFactory } from "./project-membership-dal"; +import { + TDeleteProjectMembershipDTO, + TGetProjectMembershipDTO, + TInviteUserToProjectDTO, + TUpdateProjectMembershipDTO +} from "./project-membership-types"; + +import { ForbiddenError } from "@casl/ability"; + +type TProjectMembershipServiceFactoryDep = { + permissionService: Pick; + smtpService: TSmtpService; + projectMembershipDal: TProjectMembershipDalFactory; + userDal: Pick; + projectRoleDal: Pick; + orgDal: Pick; + projectDal: Pick; + projectKeyDal: Pick; +}; + +export type TProjectMembershipServiceFactory = ReturnType; + +export const projectMembershipServiceFactory = ({ + permissionService, + projectMembershipDal, + smtpService, + projectRoleDal, + orgDal, + userDal, + projectDal, + projectKeyDal +}: TProjectMembershipServiceFactoryDep) => { + const getProjectMemberships = async ({ actorId, actor, projectId }: TGetProjectMembershipDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Member + ); + + return projectMembershipDal.findAllProjectMembers(projectId); + }; + + const inviteUserToProject = async ({ + actorId, + actor, + projectId, + email + }: TInviteUserToProjectDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Member + ); + + const invitee = await userDal.findOne({ email }); + if (!invitee || !invitee.isAccepted) + throw new BadRequestError({ + message: "Faield to validate invitee", + name: "Invite user to project" + }); + + const inviteeMembership = await projectMembershipDal.findOne({ + userId: invitee.id, + projectId + }); + if (inviteeMembership) + throw new BadRequestError({ + message: "Existing member of project", + name: "Invite user to project" + }); + + const project = await projectDal.findById(projectId); + const inviteeMembershipOrg = await orgDal.findMembership({ + userId: invitee.id, + orgId: project.orgId, + status: OrgMembershipStatus.Accepted + }); + if (!inviteeMembershipOrg) + throw new BadRequestError({ + message: "Failed to validate invitee org membership", + name: "Invite user to project" + }); + + const latestKey = await projectKeyDal.findLatestProjectKey(actorId, projectId); + await projectMembershipDal.create({ + userId: invitee.id, + projectId, + role: ProjectMembershipRole.Member + }); + + const sender = await userDal.findById(actorId); + const appCfg = getConfig(); + await smtpService.sendMail({ + template: SmtpTemplates.WorkspaceInvite, + subjectLine: "Infisical workspace invitation", + recipients: [invitee.email], + substitutions: { + inviterFirstName: sender.firstName, + inviterEmail: sender.email, + workspaceName: project.name, + callback_url: `${appCfg.SITE_URL}/login` + } + }); + + // TODO(akhilmhdh-pg): Audit log + return { invitee, latestKey }; + }; + + const updateProjectMembership = async ({ + actorId, + actor, + projectId, + membershipId, + role + }: TUpdateProjectMembershipDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Member + ); + + const isCustomRole = !Object.values(ProjectMembershipRole).includes( + role as ProjectMembershipRole + ); + if (isCustomRole) { + const customRole = await projectRoleDal.findOne({ slug: role, projectId }); + if (!customRole) + throw new BadRequestError({ name: "Update project membership", message: "Role not found" }); + const [membership] = await projectMembershipDal.update( + { id: membershipId, projectId }, + { + role: ProjectMembershipRole.Custom, + roleId: customRole.id + } + ); + return membership; + } + + const [membership] = await projectMembershipDal.update( + { id: membershipId, projectId }, + { role, roleId: null } + ); + return membership; + }; + + const deleteProjectMembership = async ({ + actorId, + actor, + projectId, + membershipId + }: TDeleteProjectMembershipDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.Member + ); + + const membership = await projectMembershipDal.transaction(async (tx) => { + const [deletedMembership] = await projectMembershipDal.delete( + { projectId, id: membershipId }, + tx + ); + await projectKeyDal.delete({ receiverId: deletedMembership.userId, projectId }, tx); + }); + return membership; + }; + + return { + getProjectMemberships, + inviteUserToProject, + updateProjectMembership, + deleteProjectMembership + }; +}; diff --git a/backend-pg/src/services/project-membership/project-membership-types.ts b/backend-pg/src/services/project-membership/project-membership-types.ts new file mode 100644 index 000000000..2acbf6064 --- /dev/null +++ b/backend-pg/src/services/project-membership/project-membership-types.ts @@ -0,0 +1,16 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TGetProjectMembershipDTO = TProjectPermission; + +export type TInviteUserToProjectDTO = { + email: string; +} & TProjectPermission; + +export type TUpdateProjectMembershipDTO = { + membershipId: string; + role: string; +} & TProjectPermission; + +export type TDeleteProjectMembershipDTO = { + membershipId: string; +} & TProjectPermission; diff --git a/backend-pg/src/services/project-role/project-role-dal.ts b/backend-pg/src/services/project-role/project-role-dal.ts new file mode 100644 index 000000000..3a272f0dc --- /dev/null +++ b/backend-pg/src/services/project-role/project-role-dal.ts @@ -0,0 +1,7 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TProjectRoleDalFactory = ReturnType; + +export const projectRoleDalFactory = (db: TDbClient) => ormify(db, TableName.ProjectRoles); diff --git a/backend-pg/src/services/project-role/project-role-service.ts b/backend-pg/src/services/project-role/project-role-service.ts new file mode 100644 index 000000000..c5f0b009f --- /dev/null +++ b/backend-pg/src/services/project-role/project-role-service.ts @@ -0,0 +1,158 @@ +import { ProjectMembershipRole, TOrgRolesUpdate, TProjectRolesInsert } from "@app/db/schemas"; +import { + OrgPermissionActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + projectAdminPermissions, + projectMemberPermissions, + projectNoAccessPermissions, + projectViewerPermission +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; + +import { ActorType } from "../auth/auth-type"; +import { TProjectRoleDalFactory } from "./project-role-dal"; + +import { ForbiddenError } from "@casl/ability"; +import { packRules } from "@casl/ability/extra"; + +type TProjectRoleServiceFactoryDep = { + projectRoleDal: TProjectRoleDalFactory; + permissionService: Pick< + TPermissionServiceFactory, + "getProjectPermission" | "getUserOrgPermission" + >; +}; + +export type TProjectRoleServiceFactory = ReturnType; + +export const projectRoleServiceFactory = ({ + projectRoleDal, + permissionService +}: TProjectRoleServiceFactoryDep) => { + const createRole = async ( + actor: ActorType, + actorId: string, + projectId: string, + data: Omit + ) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Role + ); + const existingRole = await projectRoleDal.findOne({ slug: data.slug, projectId }); + if (existingRole) throw new BadRequestError({ name: "Create Role", message: "Duplicate role" }); + const role = await projectRoleDal.create({ + ...data, + projectId, + permissions: JSON.stringify(data.permissions) + }); + return role; + }; + + const updateRole = async ( + actor: ActorType, + actorId: string, + projectId: string, + roleId: string, + data: Omit + ) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.Role + ); + if (data?.slug) { + const existingRole = await projectRoleDal.findOne({ slug: data.slug, projectId }); + if (existingRole && existingRole.id !== roleId) + throw new BadRequestError({ name: "Update Role", message: "Duplicate role" }); + } + const [updatedRole] = await projectRoleDal.update({ id: roleId, projectId }, { ...data }); + if (!updateRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); + return updatedRole; + }; + + const deleteRole = async ( + actor: ActorType, + actorId: string, + projectId: string, + roleId: string + ) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.Role + ); + const [deletedRole] = await projectRoleDal.delete({ id: roleId, projectId }); + if (!deleteRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); + + return deletedRole; + }; + + const listRoles = async (actor: ActorType, actorId: string, projectId: string) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Role + ); + const customRoles = await projectRoleDal.find({ projectId }); + const roles = [ + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid + projectId, + name: "Admin", + slug: ProjectMembershipRole.Admin, + description: "Complete administration access over the organization", + permissions: packRules(projectAdminPermissions.rules), + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response + projectId, + name: "Developer", + slug: ProjectMembershipRole.Member, + description: "Non-administrative role in an organization", + permissions: packRules(projectMemberPermissions.rules), + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response + projectId, + name: "Viewer", + slug: ProjectMembershipRole.Viewer, + description: "Non-administrative role in an organization", + permissions: packRules(projectViewerPermission.rules), + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response + projectId, + name: "No Access", + slug: "no-access", + description: "No access to any resources in the organization", + permissions: packRules(projectNoAccessPermissions.rules), + createdAt: new Date(), + updatedAt: new Date() + }, + ...(customRoles || []).map(({ permissions, ...data }) => ({ + ...data, + permissions + })) + ]; + + return roles; + }; + + const getUserPermission = async (userId: string, orgId: string) => { + const { permission, membership } = await permissionService.getUserOrgPermission(userId, orgId); + return { permissions: packRules(permission.rules), membership }; + }; + + return { createRole, updateRole, deleteRole, listRoles, getUserPermission }; +}; diff --git a/backend-pg/src/services/project-role/project-role-types.ts b/backend-pg/src/services/project-role/project-role-types.ts new file mode 100644 index 000000000..e69de29bb diff --git a/backend-pg/src/services/project/project-dal.ts b/backend-pg/src/services/project/project-dal.ts new file mode 100644 index 000000000..38db9dd00 --- /dev/null +++ b/backend-pg/src/services/project/project-dal.ts @@ -0,0 +1,116 @@ +import { TDbClient } from "@app/db"; +import { TableName,TProjects } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { mergeOneToManyRelation, ormify } from "@app/lib/knex"; + +export type TProjectDalFactory = ReturnType; + +export const projectDalFactory = (db: TDbClient) => { + const projectOrm = ormify(db, TableName.Project); + + const findAllProjects = async ( + userId: string + ): Promise<(TProjects & { environments: { id: string; slug: string; name: string }[] })[]> => { + try { + const workspaces = await db(TableName.ProjectMembership) + .where({ userId }) + .join( + TableName.Environment, + `${TableName.Environment}.projectId`, + `${TableName.Project}.id` + ) + .join( + TableName.Project, + `${TableName.ProjectMembership}.projectId`, + `${TableName.Project}.id` + ) + .select( + db.ref("id").withSchema(TableName.Project), + db.ref("name").withSchema(TableName.Project), + db.ref("autoCapitalization").withSchema(TableName.Project), + db.ref("orgId").withSchema(TableName.Project), + db.ref("createdAt").withSchema(TableName.Project), + db.ref("updatedAt").withSchema(TableName.Project), + db.ref("id").withSchema(TableName.Environment).as("envId"), + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + db.ref("name").withSchema(TableName.Environment).as("envName") + ) + .orderBy("createdAt", "asc", "last"); + return mergeOneToManyRelation( + workspaces, + "id", + ({ envId, envSlug, envName, ...data }) => data, + ({ envName, envSlug, envId }) => ({ id: envId, slug: envSlug, name: envName }), + "environments" + ); + } catch (error) { + throw new DatabaseError({ error, name: "Find all projects" }); + } + }; + + const findProjectById = async ( + id: string + ): Promise< + (TProjects & { environments: { id: string; slug: string; name: string }[] }) | undefined + > => { + try { + const workspaces = await db(TableName.ProjectMembership) + .where(`${TableName.Project}.id`, id) + .join( + TableName.Environment, + `${TableName.Environment}.projectId`, + `${TableName.Project}.id` + ) + .join( + TableName.Project, + `${TableName.ProjectMembership}.projectId`, + `${TableName.Project}.id` + ) + .select( + db.ref("id").withSchema(TableName.Project), + db.ref("name").withSchema(TableName.Project), + db.ref("autoCapitalization").withSchema(TableName.Project), + db.ref("orgId").withSchema(TableName.Project), + db.ref("createdAt").withSchema(TableName.Project), + db.ref("updatedAt").withSchema(TableName.Project), + db.ref("id").withSchema(TableName.Environment).as("envId"), + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + db.ref("name").withSchema(TableName.Environment).as("envName") + ); + const [doc] = mergeOneToManyRelation( + workspaces, + "id", + ({ envId, envSlug, envName, ...data }) => data, + ({ envName, envSlug, envId }) => ({ id: envId, slug: envSlug, name: envName }), + "environments" + ); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "Find all projects" }); + } + }; + + const findAllProjectUserPubKeys = async (projectId: string) => { + try { + const pubKeys = await db(TableName.ProjectMembership) + .where({ projectId }) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .join( + TableName.UserEncryptionKey, + `${TableName.Users}.id`, + `${TableName.UserEncryptionKey}.userId` + ) + .select("userId", "publicKey"); + return pubKeys; + } catch (error) { + throw new DatabaseError({ error, name: "Find all workspace pub keys" }); + } + }; + + return { + ...projectOrm, + findAllProjects, + findProjectById, + findAllProjectUserPubKeys + }; +}; diff --git a/backend-pg/src/services/project/project-service.ts b/backend-pg/src/services/project/project-service.ts new file mode 100644 index 000000000..9dc02afd5 --- /dev/null +++ b/backend-pg/src/services/project/project-service.ts @@ -0,0 +1,113 @@ +import { ProjectMembershipRole } from "@app/db/schemas"; +import { + OrgPermissionActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; + +import { TProjectEnvDalFactory } from "../project-env/project-env-dal"; +import { TProjectMembershipDalFactory } from "../project-membership/project-membership-dal"; +import { TProjectDalFactory } from "./project-dal"; +import { TCreateProjectDTO, TDeleteProjectDTO, TGetProjectDTO } from "./project-types"; + +import { ForbiddenError } from "@casl/ability"; + +const DEFAULT_PROJECT_ENVS = [ + { name: "Development", slug: "dev" }, + { name: "Staging", slug: "staging" }, + { name: "Production", slug: "prod" } +]; + +type TProjectServiceFactoryDep = { + projectDal: TProjectDalFactory; + projectEnvDal: Pick; + projectMembershipDal: Pick; + permissionService: TPermissionServiceFactory; +}; + +export type TProjectServiceFactory = ReturnType; + +export const projectServiceFactory = ({ + projectDal, + permissionService, + projectMembershipDal, + projectEnvDal +}: TProjectServiceFactoryDep) => { + /* + * Create workspace. Make user the admin + * */ + const createProject = async ({ orgId, actor, actorId, workspaceName }: TCreateProjectDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Workspace + ); + + // TODO(backend-pg): licence server + const newProject = projectDal.transaction(async (tx) => { + const project = await projectDal.create({ name: workspaceName, orgId }, tx); + await projectMembershipDal.create({ + userId: actorId, + role: ProjectMembershipRole.Admin, + projectId: project.id + }); + const envs = await projectEnvDal.insertMany( + DEFAULT_PROJECT_ENVS.map((el) => ({ ...el, projectId: project.id })), + tx + ); + return { ...project, environments: envs }; + }); + + return newProject; + }; + + const deleteProject = async ({ actor, actorId, projectId }: TDeleteProjectDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.Project + ); + + // TODO(backend-pg): licence server + const deletedProject = await projectDal.deleteById(projectId); + return deletedProject; + }; + + const getProjects = async (actorId: string) => { + const workspaces = await projectDal.findAllProjects(actorId); + return workspaces; + }; + + const getAProject = async ({ actorId, projectId, actor }: TGetProjectDTO) => { + await permissionService.getProjectPermission(actor, actorId, projectId); + return projectDal.findProjectById(projectId); + }; + + const toggleAutoCapitalization = async ({ + projectId, + actor, + actorId, + autoCapitalization + }: TGetProjectDTO & { autoCapitalization: boolean }) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Settings + ); + + const updatedProject = await projectDal.updateById(projectId, { autoCapitalization }); + return updatedProject; + }; + + return { + createProject, + deleteProject, + getProjects, + getAProject, + toggleAutoCapitalization + }; +}; diff --git a/backend-pg/src/services/project/project-types.ts b/backend-pg/src/services/project/project-types.ts new file mode 100644 index 000000000..2b8c5e908 --- /dev/null +++ b/backend-pg/src/services/project/project-types.ts @@ -0,0 +1,20 @@ +import { ActorType } from "../auth/auth-type"; + +export type TCreateProjectDTO = { + actor: ActorType; + actorId: string; + orgId: string; + workspaceName: string; +}; + +export type TDeleteProjectDTO = { + actor: ActorType; + actorId: string; + projectId: string; +}; + +export type TGetProjectDTO = { + actor: ActorType; + actorId: string; + projectId: string; +};