diff --git a/backend-pg/folder.ts b/backend-pg/folder.ts new file mode 100644 index 000000000..dc7d6be5b --- /dev/null +++ b/backend-pg/folder.ts @@ -0,0 +1,57 @@ +import dotenv from "dotenv"; + +import { initDbConnection } from "./src/db"; +import { TableName } from "./src/db/schemas"; +import { selectAllTableCols } from "./src/lib/knex"; + +dotenv.config(); +const db = initDbConnection(process.env.DB_CONNECTION_URI); + +const main = async () => { + const folders = db + .withRecursive("parent", (qb) => { + qb.select({ + depth: 1, + path: db.raw("'/'") + }) + .select(selectAllTableCols(db, TableName.SecretFolder)) + .from(TableName.SecretFolder) + .join( + TableName.Environment, + `${TableName.SecretFolder}.envId`, + `${TableName.Environment}.id` + ) + .where({ + projectId: "01c10de1-8743-490f-9c8a-7a19c4dc72a9", + parentId: null + }) + .where(`${TableName.Environment}.slug`, "dev") + .union((qb) => + qb + .select({ + depth: db.raw("parent.depth + 1"), + path: db.raw( + "CONCAT((CASE WHEN parent.path = '/' THEN '' ELSE parent.path END),'/', secret_folders.name)" + ) + }) + .select(selectAllTableCols(db, TableName.SecretFolder)) + .whereRaw( + `depth = array_position(ARRAY[${[1, 2] + .map((_) => "?") + .join(",")}]::varchar[], secret_folders.name,depth)`, + [...["ui", "design"]] + ) + .from(TableName.SecretFolder) + .join("parent", "parent.id", `${TableName.SecretFolder}.parentId`) + ); + }) + .select("*") + .from("parent") + .orderBy("depth", "desc") + .first(); + console.log(folders.toSQL()); + console.log(JSON.stringify(await folders, null, 4)); + process.exit(0); +}; + +main(); diff --git a/backend-pg/package-lock.json b/backend-pg/package-lock.json index 7d959b124..d64856c04 100644 --- a/backend-pg/package-lock.json +++ b/backend-pg/package-lock.json @@ -18,6 +18,7 @@ "@fastify/session": "^10.7.0", "@fastify/swagger": "^8.12.0", "@fastify/swagger-ui": "^1.10.1", + "@octokit/rest": "^20.0.2", "@ucast/mongo2js": "^1.3.4", "argon2": "^0.31.2", "axios": "^1.6.2", @@ -943,6 +944,149 @@ "node": ">= 8" } }, + "node_modules/@octokit/auth-token": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", + "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.0.2.tgz", + "integrity": "sha512-cZUy1gUvd4vttMic7C0lwPed8IYXWYp8kHIMatyhY8t8n3Cpw2ILczkV5pGMPqef7v0bLo0pOHrEHarsau2Ydg==", + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.0.0", + "@octokit/request": "^8.0.2", + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^12.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.4.tgz", + "integrity": "sha512-DWPLtr1Kz3tv8L0UvXTDP1fNwM0S+z6EJpRcvH66orY6Eld4XBMCSYsaWp4xIm61jTWxK68BrR7ibO+vSDnZqw==", + "dependencies": { + "@octokit/types": "^12.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.0.2.tgz", + "integrity": "sha512-OJ2iGMtj5Tg3s6RaXH22cJcxXRi7Y3EBqbHTBRq+PQAqfaS8f/236fUrWhfSn8P4jovyzqucxme7/vWSSZBX2Q==", + "dependencies": { + "@octokit/request": "^8.0.1", + "@octokit/types": "^12.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-19.1.0.tgz", + "integrity": "sha512-6G+ywGClliGQwRsjvqVYpklIfa7oRPA0vyhPQG/1Feh+B+wU0vGH1JiJ5T25d3g1JZYBHzR2qefLi9x8Gt+cpw==" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "9.1.5", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.1.5.tgz", + "integrity": "sha512-WKTQXxK+bu49qzwv4qKbMMRXej1DU2gq017euWyKVudA6MldaSSQuxtz+vGbhxV4CjxpUxjZu6rM2wfc1FiWVg==", + "dependencies": { + "@octokit/types": "^12.4.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=5" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-4.0.0.tgz", + "integrity": "sha512-2uJI1COtYCq8Z4yNSnM231TgH50bRkheQ9+aH8TnZanB6QilOnx8RMD2qsnamSOXtDj0ilxvevf5fGsBhBBzKA==", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=5" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-10.2.0.tgz", + "integrity": "sha512-ePbgBMYtGoRNXDyKGvr9cyHjQ163PbwD0y1MkDJCpkO2YH4OeXX40c4wYHKikHGZcpGPbcRLuy0unPUuafco8Q==", + "dependencies": { + "@octokit/types": "^12.3.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=5" + } + }, + "node_modules/@octokit/request": { + "version": "8.1.6", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.1.6.tgz", + "integrity": "sha512-YhPaGml3ncZC1NfXpP3WZ7iliL1ap6tLkAp6MvbK2fTTPytzVUyUesBBogcdMm86uRYO5rHaM1xIWxigWZ17MQ==", + "dependencies": { + "@octokit/endpoint": "^9.0.0", + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^12.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.0.1.tgz", + "integrity": "sha512-X7pnyTMV7MgtGmiXBwmO6M5kIPrntOXdyKZLigNfQWSEQzVxR4a4vo49vJjTWX70mPndj8KhfT4Dx+2Ng3vnBQ==", + "dependencies": { + "@octokit/types": "^12.0.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest": { + "version": "20.0.2", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-20.0.2.tgz", + "integrity": "sha512-Ux8NDgEraQ/DMAU1PlAohyfBBXDwhnX2j33Z1nJNziqAfHi70PuxkFYIcIt8aIAxtRE7KVuKp8lSR8pA0J5iOQ==", + "dependencies": { + "@octokit/core": "^5.0.0", + "@octokit/plugin-paginate-rest": "^9.0.0", + "@octokit/plugin-request-log": "^4.0.0", + "@octokit/plugin-rest-endpoint-methods": "^10.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.4.0.tgz", + "integrity": "sha512-FLWs/AvZllw/AGVs+nJ+ELCDZZJk+kY0zMen118xhL2zD0s1etIUHm1odgjP7epxYU1ln7SZxEUWYop5bhsdgQ==", + "dependencies": { + "@octokit/openapi-types": "^19.1.0" + } + }, "node_modules/@phc/format": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", @@ -2544,6 +2688,11 @@ "node": ">= 10.0.0" } }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -3075,6 +3224,11 @@ "node": ">= 0.8" } }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" + }, "node_modules/detect-libc": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", @@ -8764,6 +8918,11 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true }, + "node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==" + }, "node_modules/untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", diff --git a/backend-pg/package.json b/backend-pg/package.json index 151c40a11..62434fd11 100644 --- a/backend-pg/package.json +++ b/backend-pg/package.json @@ -71,6 +71,7 @@ "@fastify/session": "^10.7.0", "@fastify/swagger": "^8.12.0", "@fastify/swagger-ui": "^1.10.1", + "@octokit/rest": "^20.0.2", "@ucast/mongo2js": "^1.3.4", "argon2": "^0.31.2", "axios": "^1.6.2", diff --git a/backend-pg/src/@types/fastify.d.ts b/backend-pg/src/@types/fastify.d.ts index eca5debce..6576953b9 100644 --- a/backend-pg/src/@types/fastify.d.ts +++ b/backend-pg/src/@types/fastify.d.ts @@ -8,9 +8,12 @@ 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 { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; +import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TProjectServiceFactory } from "@app/services/project/project-service"; +import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env-service"; import { TProjectKeyServiceFactory } from "@app/services/project-key/project-key-service"; import { TProjectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service"; @@ -69,7 +72,10 @@ declare module "fastify" { projectRole: TProjectRoleServiceFactory; secret: TSecretServiceFactory; secretImport: TSecretImportServiceFactory; + projectBot: TProjectBotServiceFactory; folder: TSecretFolderServiceFactory; + integration: TIntegrationServiceFactory; + integrationAuth: TIntegrationAuthServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data diff --git a/backend-pg/src/@types/knex.d.ts b/backend-pg/src/@types/knex.d.ts index afbb90709..1fff361a0 100644 --- a/backend-pg/src/@types/knex.d.ts +++ b/backend-pg/src/@types/knex.d.ts @@ -16,6 +16,12 @@ import { TIncidentContacts, TIncidentContactsInsert, TIncidentContactsUpdate, + TIntegrationAuths, + TIntegrationAuthsInsert, + TIntegrationAuthsUpdate, + TIntegrations, + TIntegrationsInsert, + TIntegrationsUpdate, TOrganizations, TOrganizationsInsert, TOrganizationsUpdate, @@ -25,6 +31,9 @@ import { TOrgRoles, TOrgRolesInsert, TOrgRolesUpdate, + TProjectBots, + TProjectBotsInsert, + TProjectBotsUpdate, TProjectEnvironments, TProjectEnvironmentsInsert, TProjectEnvironmentsUpdate, @@ -136,6 +145,11 @@ declare module "knex/types/tables" { TProjectEnvironmentsInsert, TProjectEnvironmentsUpdate >; + [TableName.ProjectBot]: Knex.CompositeTableType< + TProjectBots, + TProjectBotsInsert, + TProjectBotsUpdate + >; [TableName.ProjectRoles]: Knex.CompositeTableType< TProjectRoles, TProjectRolesInsert, @@ -172,6 +186,16 @@ declare module "knex/types/tables" { TSecretImportsInsert, TSecretImportsUpdate >; + [TableName.Integration]: Knex.CompositeTableType< + TIntegrations, + TIntegrationsInsert, + TIntegrationsUpdate + >; + [TableName.IntegrationAuth]: Knex.CompositeTableType< + TIntegrationAuths, + TIntegrationAuthsInsert, + TIntegrationAuthsUpdate + >; [TableName.JnSecretTag]: Knex.CompositeTableType< TSecretTagJunction, TSecretTagJunctionInsert, diff --git a/backend-pg/src/db/migrations/20231222092113_project-bot.ts b/backend-pg/src/db/migrations/20231222092113_project-bot.ts new file mode 100644 index 000000000..5a1e95682 --- /dev/null +++ b/backend-pg/src/db/migrations/20231222092113_project-bot.ts @@ -0,0 +1,35 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.ProjectBot))) { + await knex.schema.createTable(TableName.ProjectBot, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.boolean("isActive").defaultTo(false).notNullable(); + t.text("encryptedPrivateKey").notNullable(); + t.text("publicKey").notNullable(); + t.text("iv").notNullable(); + t.text("tag").notNullable(); + t.string("algorithm").notNullable(); + t.string("keyEncoding").notNullable(); + t.text("encryptedProjectKey"); + t.text("encryptedProjectKeyNonce"); + // one to one relationship + t.uuid("projectId").notNullable().unique(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("senderId"); + t.foreign("senderId").references("id").inTable(TableName.Users).onDelete("SET NULL"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.ProjectBot); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.ProjectBot); + await dropOnUpdateTrigger(knex, TableName.ProjectBot); +} diff --git a/backend-pg/src/db/migrations/20231222172455_integration.ts b/backend-pg/src/db/migrations/20231222172455_integration.ts new file mode 100644 index 000000000..346a97ff0 --- /dev/null +++ b/backend-pg/src/db/migrations/20231222172455_integration.ts @@ -0,0 +1,71 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IntegrationAuth))) { + await knex.schema.createTable(TableName.IntegrationAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("integration").notNullable(); + t.string("teamId"); // vercel-specific + t.string("url"); // for self hosted + t.string("namespace"); // hashicorp specific + t.string("accountId"); // netlify + t.string("refreshCiphertext"); + t.string("refreshIV"); + t.string("refreshTag"); + t.string("accessIdCiphertext"); + t.string("accessIdIV"); + t.string("accessIdTag"); + t.string("accessCiphertext"); + t.string("accessIV"); + t.string("accessTag"); + t.datetime("accessExpiresAt"); + t.jsonb("metadata"); + t.string("algorithm").notNullable(); + t.string("keyEncoding").notNullable(); + t.uuid("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.IntegrationAuth); + + if (!(await knex.schema.hasTable(TableName.Integration))) { + await knex.schema.createTable(TableName.Integration, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.boolean("isActive").notNullable(); + t.string("url"); // self hosted + t.string("app"); // name of app in provider + t.string("appId"); + t.string("targetEnvironment"); + t.string("targetEnvironmentId"); + t.string("targetService"); // railway - qovery specific + t.string("targetServiceId"); + t.string("owner"); // github specific + t.string("path"); // aws parameter store / vercel preview branch + t.string("region"); // aws + t.string("scope"); // qovery specific scope + t.string("integration").notNullable(); + t.jsonb("metadata"); + t.uuid("integrationAuthId").notNullable(); + t.foreign("integrationAuthId") + .references("id") + .inTable(TableName.IntegrationAuth) + .onDelete("CASCADE"); + t.uuid("envId").notNullable(); + t.string("secretPath").defaultTo("/").notNullable(); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.Integration); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IntegrationAuth); + await knex.schema.dropTableIfExists(TableName.Integration); + await dropOnUpdateTrigger(knex, TableName.IntegrationAuth); + await dropOnUpdateTrigger(knex, TableName.Integration); +} diff --git a/backend-pg/src/db/schemas/index.ts b/backend-pg/src/db/schemas/index.ts index 683e0aa65..06f8741b3 100644 --- a/backend-pg/src/db/schemas/index.ts +++ b/backend-pg/src/db/schemas/index.ts @@ -3,10 +3,13 @@ export * from "./auth-token-sessions"; export * from "./auth-tokens"; export * from "./backup-private-key"; export * from "./incident-contacts"; +export * from "./integration-auths"; +export * from "./integrations"; export * from "./models"; export * from "./org-memberships"; export * from "./org-roles"; export * from "./organizations"; +export * from "./project-bots"; export * from "./project-environments"; export * from "./project-keys"; export * from "./project-memberships"; diff --git a/backend-pg/src/db/schemas/integration-auths.ts b/backend-pg/src/db/schemas/integration-auths.ts new file mode 100644 index 000000000..8f930e83f --- /dev/null +++ b/backend-pg/src/db/schemas/integration-auths.ts @@ -0,0 +1,37 @@ +// 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 IntegrationAuthsSchema = z.object({ + id: z.string().uuid(), + integration: z.string(), + teamId: z.string().nullable().optional(), + url: z.string().nullable().optional(), + namespace: z.string().nullable().optional(), + accountId: z.string().nullable().optional(), + refreshCiphertext: z.string().nullable().optional(), + refreshIV: z.string().nullable().optional(), + refreshTag: z.string().nullable().optional(), + accessIdCiphertext: z.string().nullable().optional(), + accessIdIV: z.string().nullable().optional(), + accessIdTag: z.string().nullable().optional(), + accessCiphertext: z.string().nullable().optional(), + accessIV: z.string().nullable().optional(), + accessTag: z.string().nullable().optional(), + accessExpiresAt: z.date().nullable().optional(), + metadata: z.unknown().nullable().optional(), + algorithm: z.string(), + keyEncoding: z.string(), + projectId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TIntegrationAuths = z.infer; +export type TIntegrationAuthsInsert = Omit; +export type TIntegrationAuthsUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/integrations.ts b/backend-pg/src/db/schemas/integrations.ts new file mode 100644 index 000000000..b2163dc4d --- /dev/null +++ b/backend-pg/src/db/schemas/integrations.ts @@ -0,0 +1,35 @@ +// 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 IntegrationsSchema = z.object({ + id: z.string().uuid(), + isActive: z.boolean(), + url: z.string().nullable().optional(), + app: z.string().nullable().optional(), + appId: z.string().nullable().optional(), + targetEnvironment: z.string().nullable().optional(), + targetEnvironmentId: z.string().nullable().optional(), + targetService: z.string().nullable().optional(), + targetServiceId: z.string().nullable().optional(), + owner: z.string().nullable().optional(), + path: z.string().nullable().optional(), + region: z.string().nullable().optional(), + scope: z.string().nullable().optional(), + integration: z.string(), + metadata: z.unknown().nullable().optional(), + integrationAuthId: z.string().uuid(), + envId: z.string().uuid(), + secretPath: z.string().default('/'), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TIntegrations = z.infer; +export type TIntegrationsInsert = Omit; +export type TIntegrationsUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/models.ts b/backend-pg/src/db/schemas/models.ts index c7a1c443f..d7c2a234e 100644 --- a/backend-pg/src/db/schemas/models.ts +++ b/backend-pg/src/db/schemas/models.ts @@ -14,6 +14,7 @@ export enum TableName { SuperAdmin = "super_admin", ApiKey = "api_keys", Project = "projects", + ProjectBot = "project_bots", Environment = "project_environments", ProjectMembership = "project_memberships", ProjectRoles = "project_roles", @@ -24,6 +25,8 @@ export enum TableName { SecretFolder = "secret_folders", SecretImport = "secret_imports", SecretTag = "secret_tags", + Integration = "integrations", + IntegrationAuth = "integration_auths", JnSecretTag = "secret_tag_junction", JnSecretVersionTag = "secret_version_tag_junction" } diff --git a/backend-pg/src/db/schemas/project-bots.ts b/backend-pg/src/db/schemas/project-bots.ts new file mode 100644 index 000000000..bd5e91a1c --- /dev/null +++ b/backend-pg/src/db/schemas/project-bots.ts @@ -0,0 +1,30 @@ +// 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 ProjectBotsSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + isActive: z.boolean().default(false), + encryptedPrivateKey: z.string(), + publicKey: z.string(), + iv: z.string(), + tag: z.string(), + algorithm: z.string(), + keyEncoding: z.string(), + encryptedProjectKey: z.string().optional().nullable(), + encryptedProjectKeyNonce: z.string().optional().nullable(), + projectId: z.string().uuid(), + senderId: z.string().uuid().optional().nullable(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TProjectBots = z.infer; +export type TProjectBotsInsert = Omit; +export type TProjectBotsUpdate = Partial>; diff --git a/backend-pg/src/lib/config/env.ts b/backend-pg/src/lib/config/env.ts index 6587bcc9e..e75f05d2a 100644 --- a/backend-pg/src/lib/config/env.ts +++ b/backend-pg/src/lib/config/env.ts @@ -3,6 +3,8 @@ import { z } from "zod"; import { zpStr } from "../zod"; +export const GITLAB_URL = "https://gitlab.com"; + const zodStrBool = z .enum(["true", "false"]) .optional() @@ -46,7 +48,34 @@ const envSchema = z CLIENT_SECRET_GITHUB_LOGIN: zpStr(z.string().optional()), CLIENT_ID_GITLAB_LOGIN: zpStr(z.string().optional()), CLIENT_SECRET_GITLAB_LOGIN: zpStr(z.string().optional()), - CLIENT_GITLAB_LOGIN_URL: zpStr(z.string().optional()) + CLIENT_GITLAB_LOGIN_URL: zpStr(z.string().optional().default(GITLAB_URL)), + // integration client secrets + // heroku + CLIENT_ID_HEROKU: zpStr(z.string().optional()), + CLIENT_SECRET_HEROKU: zpStr(z.string().optional()), + // vercel + CLIENT_ID_VERCEL: zpStr(z.string().optional()), + CLIENT_SECRET_VERCEL: zpStr(z.string().optional()), + CLIENT_SLUG_VERCEL: zpStr(z.string().optional()), + // netlify + CLIENT_ID_NETLIFY: zpStr(z.string().optional()), + CLIENT_SECRET_NETLIFY: zpStr(z.string().optional()), + // bit bucket + CLIENT_ID_BITBUCKET: zpStr(z.string().optional()), + CLIENT_SECRET_BITBUCKET: zpStr(z.string().optional()), + // gcp secret manager + CLIENT_ID_GCP_SECRET_MANAGER: zpStr(z.string().optional()), + CLIENT_SECRET_GCP_SECRET_MANAGER: zpStr(z.string().optional()), + // github + CLIENT_ID_GITHUB: zpStr(z.string().optional()), + CLIENT_SECRET_GITHUB: zpStr(z.string().optional()), + // azure + CLIENT_ID_AZURE: zpStr(z.string().optional()), + CLIENT_SECRET_AZURE: zpStr(z.string().optional()), + // google + CLIENT_ID_GITLAB: zpStr(z.string().optional()), + CLIENT_SECRET_GITLAB: zpStr(z.string().optional()), + URL_GITLAB_URL: zpStr(z.string().optional().default(GITLAB_URL)) }) .transform((data) => ({ ...data, isSmtpConfigured: Boolean(data.SMTP_HOST) })); diff --git a/backend-pg/src/lib/crypto/encryption.ts b/backend-pg/src/lib/crypto/encryption.ts index 9e0edcba8..add6d2ccc 100644 --- a/backend-pg/src/lib/crypto/encryption.ts +++ b/backend-pg/src/lib/crypto/encryption.ts @@ -120,6 +120,15 @@ export const decryptAsymmetric = ({ return naclUtils.encodeUTF8(plaintext); }; +export const generateAsymmetricKeyPair = () => { + const pair = nacl.box.keyPair(); + + return { + publicKey: naclUtils.encodeBase64(pair.publicKey), + privateKey: naclUtils.encodeBase64(pair.secretKey) + }; +}; + export type TGenSecretBlindIndex = { secretName: string; keyEncoding: SecretKeyEncoding; diff --git a/backend-pg/src/lib/crypto/index.ts b/backend-pg/src/lib/crypto/index.ts index 7538b8167..62278de1d 100644 --- a/backend-pg/src/lib/crypto/index.ts +++ b/backend-pg/src/lib/crypto/index.ts @@ -6,6 +6,7 @@ export { decryptSymmetric128BitHexKeyUTF8, encryptAsymmetric, encryptSymmetric, - encryptSymmetric128BitHexKeyUTF8 + encryptSymmetric128BitHexKeyUTF8, + generateAsymmetricKeyPair } from "./encryption"; export { generateSrpServerKey, srpCheckClientProof } from "./srp"; diff --git a/backend-pg/src/lib/knex/select.ts b/backend-pg/src/lib/knex/select.ts index 3dfab971b..b7cc08e6d 100644 --- a/backend-pg/src/lib/knex/select.ts +++ b/backend-pg/src/lib/knex/select.ts @@ -1,5 +1,4 @@ -import { Knex } from "knex"; import { Tables } from "knex/types/tables"; -export const selectAllTableCols = (db: Knex, tableName: Tname) => - db.ref("*").withSchema(tableName) as unknown as keyof Tables[Tname]; +export const selectAllTableCols = (tableName: Tname) => + `${tableName}.*` as keyof Tables[Tname]["base"]; diff --git a/backend-pg/src/server/routes/index.ts b/backend-pg/src/server/routes/index.ts index d786d7c10..2462fe56d 100644 --- a/backend-pg/src/server/routes/index.ts +++ b/backend-pg/src/server/routes/index.ts @@ -13,6 +13,10 @@ import { authPaswordServiceFactory } from "@app/services/auth/auth-password-serv import { authSignupServiceFactory } from "@app/services/auth/auth-signup-service"; import { tokenDalFactory } from "@app/services/auth-token/auth-token-dal"; import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { integrationDalFactory } from "@app/services/integration/integration-dal"; +import { integrationServiceFactory } from "@app/services/integration/integration-service"; +import { integrationAuthDalFactory } from "@app/services/integration-auth/integration-auth-dal"; +import { integrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; import { incidentContactDalFactory } from "@app/services/org/incident-contacts-dal"; import { orgDalFactory } from "@app/services/org/org-dal"; import { orgRoleDalFactory } from "@app/services/org/org-role-dal"; @@ -20,6 +24,8 @@ import { orgRoleServiceFactory } from "@app/services/org/org-role-service"; import { orgServiceFactory } from "@app/services/org/org-service"; import { projectDalFactory } from "@app/services/project/project-dal"; import { projectServiceFactory } from "@app/services/project/project-service"; +import { projectBotDalFactory } from "@app/services/project-bot/project-bot-dal"; +import { projectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { projectEnvDalFactory } from "@app/services/project-env/project-env-dal"; import { projectEnvServiceFactory } from "@app/services/project-env/project-env-service"; import { projectKeyDalFactory } from "@app/services/project-key/project-key-dal"; @@ -67,6 +73,7 @@ export const registerRoutes = async ( const projectRoleDal = projectRoleDalFactory(db); const projectEnvDal = projectEnvDalFactory(db); const projectKeyDal = projectKeyDalFactory(db); + const projectBotDal = projectBotDalFactory(db); const secretDal = secretDalFactory(db); const folderDal = secretFolderDalFactory(db); @@ -74,6 +81,9 @@ export const registerRoutes = async ( const secretVersionDal = secretVersionDalFactory(db); const secretBlindIndexDal = secretBlindIndexDalFactory(db); + const integrationDal = integrationDalFactory(db); + const integrationAuthDal = integrationAuthDalFactory(db); + // ee db layer ops const permissionDal = permissionDalFactory(db); @@ -159,6 +169,20 @@ export const registerRoutes = async ( permissionService, secretImportDal }); + const projectBotService = projectBotServiceFactory({ permissionService, projectBotDal }); + const integrationService = integrationServiceFactory({ + permissionService, + folderDal, + integrationDal, + integrationAuthDal + }); + const integrationAuthService = integrationAuthServiceFactory({ + integrationAuthDal, + integrationDal, + permissionService, + projectBotDal, + projectBotService + }); await superAdminService.initServerCfg(); // inject all services @@ -180,7 +204,10 @@ export const registerRoutes = async ( projectRole: projectRoleService, secret: secretService, folder: folderService, - secretImport: secretImportService + secretImport: secretImportService, + projectBot: projectBotService, + integration: integrationService, + integrationAuth: integrationAuthService }); server.decorate("store", { diff --git a/backend-pg/src/server/routes/v1/bot-router.ts b/backend-pg/src/server/routes/v1/bot-router.ts new file mode 100644 index 000000000..c01418da6 --- /dev/null +++ b/backend-pg/src/server/routes/v1/bot-router.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +import { ProjectBotsSchema } from "@app/db/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerProjectBotRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/:projectId", + method: "GET", + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + response: { + 200: z.object({ + bot: ProjectBotsSchema.pick({ + name: true, + projectId: true, + isActive: true, + publicKey: true, + createdAt: true, + updatedAt: true + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const bot = await server.services.projectBot.findBotByProjectId({ + actor: req.permission.type, + actorId: req.permission.id, + projectId: req.params.workspaceId + }); + return { bot }; + } + }); + + server.route({ + url: "/:botId/active", + method: "PATCH", + schema: { + body: z.object({ + isActive: z.boolean(), + botKey: z + .object({ + nonce: z.string().trim().optional(), + encryptedKey: z.string().trim().optional() + }) + .optional() + }), + params: z.object({ + botId: z.string().trim() + }), + response: { + 200: z.object({ + bot: ProjectBotsSchema.pick({ + name: true, + projectId: true, + isActive: true, + publicKey: true, + createdAt: true, + updatedAt: true + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const bot = await server.services.projectBot.setBotActiveState({ + actor: req.permission.type, + actorId: req.permission.id, + botId: req.params.botId, + botKey: req.body.botKey, + isActive: req.body.isActive + }); + return { bot }; + } + }); +}; diff --git a/backend-pg/src/server/routes/v1/index.ts b/backend-pg/src/server/routes/v1/index.ts index a9f4d93db..7af65d176 100644 --- a/backend-pg/src/server/routes/v1/index.ts +++ b/backend-pg/src/server/routes/v1/index.ts @@ -1,5 +1,8 @@ import { registerAdminRouter } from "./admin-router"; import { registerAuthRoutes } from "./auth-router"; +import { registerProjectBotRouter } from "./bot-router"; +import { registerIntegrationAuthRouter } from "./integration-auth-router"; +import { registerIntegrationRouter } from "./integration-router"; import { registerInviteOrgRouter } from "./invite-org-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; @@ -34,4 +37,8 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { }, { prefix: "/workspace" } ); + + await server.register(registerProjectBotRouter, { prefix: "/bot" }); + await server.register(registerIntegrationRouter, { prefix: "/integration" }); + await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" }); }; diff --git a/backend-pg/src/server/routes/v1/integration-auth-router.ts b/backend-pg/src/server/routes/v1/integration-auth-router.ts new file mode 100644 index 000000000..b3266b58a --- /dev/null +++ b/backend-pg/src/server/routes/v1/integration-auth-router.ts @@ -0,0 +1,565 @@ +import { z } from "zod"; + +import { IntegrationAuthsSchema } from "@app/db/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/integration-options", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + response: { + 200: z.object({ + integrationOptions: z + .object({ + name: z.string(), + slug: z.string(), + image: z.string(), + isAvailable: z.boolean().optional(), + type: z.string(), + clientId: z.string().optional(), + docsLink: z.string().optional() + }) + .array() + }) + } + }, + handler: async () => { + const integrationOptions = await server.services.integrationAuth.getIntegrationOptions(); + return { integrationOptions }; + } + }); + + const integrationPublicSchema = IntegrationAuthsSchema.pick({ + projectId: true, + integration: true, + teamId: true, + url: true, + namespace: true, + accountId: true, + metadata: true, + createdAt: true, + updatedAt: true + }); + + server.route({ + url: "/:integrationAuthId", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + response: { + 200: z.object({ + integrationAuth: integrationPublicSchema + }) + } + }, + handler: async (req) => { + const integrationAuth = await server.services.integrationAuth.getIntegrationAuth({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId + }); + return { integrationAuth }; + } + }); + + server.route({ + url: "/:integrationAuthId", + method: "DELETE", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + response: { + 200: z.object({ + integrationAuth: integrationPublicSchema + }) + } + }, + handler: async (req) => { + const integrationAuth = await server.services.integrationAuth.deleteIntegrationAuth({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId + }); + return { integrationAuth }; + } + }); + + server.route({ + url: "/oauth-token", + method: "POST", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z.object({ + workspaceId: z.string().trim(), + integration: z.string().trim(), + accessId: z.string().trim().optional(), + accessToken: z.string().trim().optional(), + url: z.string().url().trim().optional(), + namespace: z.string().trim().optional(), + refreshToken: z.string().trim().optional() + }), + response: { + 200: z.object({ + integrationAuth: integrationPublicSchema + }) + } + }, + handler: async (req) => { + const integrationAuth = await server.services.integrationAuth.saveIntegrationToken({ + actorId: req.permission.id, + actor: req.permission.type, + projectId: req.body.workspaceId, + ...req.body + }); + return { integrationAuth }; + } + }); + + server.route({ + url: "/:integrationAuthId/apps", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + teamId: z.string().trim().optional(), + workspaceSlug: z.string().trim().optional() + }), + response: { + 200: z.object({ + apps: z + .object({ + name: z.string(), + appId: z.string().optional(), + owner: z.string().optional() + }) + .array() + }) + } + }, + handler: async (req) => { + const apps = await server.services.integrationAuth.getIntegrationApps({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId, + ...req.query + }); + return { apps }; + } + }); + + server.route({ + url: "/:integrationAuthId/teams", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + response: { + 200: z.object({ + teams: z + .object({ + name: z.string(), + id: z.string().optional() + }) + .array() + }) + } + }, + handler: async (req) => { + const teams = await server.services.integrationAuth.getIntegrationAuthTeams({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId + }); + return { teams }; + } + }); + + server.route({ + url: "/:integrationAuthId/vercel/branches", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + appId: z.string().trim() + }), + response: { + 200: z.object({ + branches: z.string().array() + }) + } + }, + handler: async (req) => { + const branches = await server.services.integrationAuth.getVercelBranches({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId, + appId: req.query.appId + }); + return { branches }; + } + }); + + server.route({ + url: "/:integrationAuthId/checkly/groups", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + accountId: z.string().trim() + }), + response: { + 200: z.object({ + groups: z.object({ name: z.string(), groupId: z.number() }).array() + }) + } + }, + handler: async (req) => { + const groups = await server.services.integrationAuth.getChecklyGroups({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId, + accountId: req.query.accountId + }); + return { groups }; + } + }); + + server.route({ + url: "/:integrationAuthId/qovery/orgs", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + response: { + 200: z.object({ + orgs: z.object({ name: z.string(), orgId: z.string() }).array() + }) + } + }, + handler: async (req) => { + const orgs = await server.services.integrationAuth.getQoveryOrgs({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId + }); + return { orgs }; + } + }); + + server.route({ + url: "/:integrationAuthId/qovery/projects", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + orgId: z.string().trim() + }), + response: { + 200: z.object({ + projects: z.object({ name: z.string(), projectId: z.string() }).array() + }) + } + }, + handler: async (req) => { + const projects = await server.services.integrationAuth.getQoveryProjects({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId, + orgId: req.query.orgId + }); + return { projects }; + } + }); + + server.route({ + url: "/:integrationAuthId/qovery/environments", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + projectId: z.string().trim() + }), + response: { + 200: z.object({ + environments: z.object({ name: z.string(), environmentId: z.string() }).array() + }) + } + }, + handler: async (req) => { + const environments = await server.services.integrationAuth.getQoveryEnvs({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId, + projectId: req.query.projectId + }); + return { environments }; + } + }); + + server.route({ + url: "/:integrationAuthId/qovery/apps", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + environmentId: z.string().trim() + }), + response: { + 200: z.object({ + apps: z.object({ name: z.string(), appId: z.string() }).array() + }) + } + }, + handler: async (req) => { + const apps = await server.services.integrationAuth.getQoveryApps({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId, + environmentId: req.query.environmentId + }); + return { apps }; + } + }); + + server.route({ + url: "/:integrationAuthId/qovery/containers", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + environmentId: z.string().trim() + }), + response: { + 200: z.object({ + containers: z.object({ name: z.string(), appId: z.string() }).array() + }) + } + }, + handler: async (req) => { + const containers = await server.services.integrationAuth.getQoveryContainers({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId, + environmentId: req.query.environmentId + }); + return { containers }; + } + }); + + server.route({ + url: "/:integrationAuthId/qovery/jobs", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + environmentId: z.string().trim() + }), + response: { + 200: z.object({ + jobs: z.object({ name: z.string(), appId: z.string() }).array() + }) + } + }, + handler: async (req) => { + const jobs = await server.services.integrationAuth.getQoveryJobs({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId, + environmentId: req.query.environmentId + }); + return { jobs }; + } + }); + + server.route({ + url: "/:integrationAuthId/railway/environments", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + appId: z.string().trim() + }), + response: { + 200: z.object({ + environments: z.object({ name: z.string(), environmentId: z.string() }).array() + }) + } + }, + handler: async (req) => { + const environments = await server.services.integrationAuth.getRailwayEnvironments({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId, + appId: req.query.appId + }); + return { environments }; + } + }); + + server.route({ + url: "/:integrationAuthId/railway/services", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + appId: z.string().trim() + }), + response: { + 200: z.object({ + services: z.object({ name: z.string(), serviceId: z.string() }).array() + }) + } + }, + handler: async (req) => { + const services = await server.services.integrationAuth.getRailwayServices({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId, + appId: req.query.appId + }); + return { services }; + } + }); + + server.route({ + url: "/:integrationAuthId/bitbucket/workspaces", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + response: { + 200: z.object({ + workspaces: z + .object({ + name: z.string(), + slug: z.string(), + uuid: z.string(), + type: z.string(), + is_private: z.boolean(), + created_on: z.string(), + updated_on: z.string() + }) + .array() + }) + } + }, + handler: async (req) => { + const workspaces = await server.services.integrationAuth.getBitbucketWorkspaces({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId + }); + return { workspaces }; + } + }); + + server.route({ + url: "/:integrationAuthId/northflank/secret-groups", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + appId: z.string().trim() + }), + response: { + 200: z.object({ + secretGroups: z + .object({ + name: z.string(), + groupId: z.string() + }) + .array() + }) + } + }, + handler: async (req) => { + const secretGroups = await server.services.integrationAuth.getNorthFlankSecretGroups({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId, + appId: req.query.appId + }); + return { secretGroups }; + } + }); + + server.route({ + url: "/:integrationAuthId/teamcity/build-configs", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + appId: z.string().trim() + }), + response: { + 200: z.object({ + buildConfigs: z + .object({ + name: z.string(), + buildConfigId: z.string() + }) + .array() + }) + } + }, + handler: async (req) => { + const buildConfigs = await server.services.integrationAuth.getTeamcityBuildConfigs({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationAuthId, + appId: req.query.appId + }); + return { buildConfigs }; + } + }); +}; diff --git a/backend-pg/src/server/routes/v1/integration-router.ts b/backend-pg/src/server/routes/v1/integration-router.ts new file mode 100644 index 000000000..5d2c14d59 --- /dev/null +++ b/backend-pg/src/server/routes/v1/integration-router.ts @@ -0,0 +1,116 @@ +import { z } from "zod"; + +import { IntegrationsSchema } from "@app/db/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerIntegrationRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z.object({ + integrationAuthId: z.string().trim(), + app: z.string().trim().optional(), + isActive: z.boolean(), + appId: z.string().trim().optional(), + secretPath: z.string().trim().default("/"), + sourceEnvironment: z.string().trim(), + targetEnvironment: z.string().trim().optional(), + targetEnvironmentId: z.string().trim().optional(), + targetService: z.string().trim().optional(), + targetServiceId: z.string().trim().optional(), + owner: z.string().trim().optional(), + path: z.string().trim().optional(), + region: z.string().trim().optional(), + scope: z.string().trim().optional(), + metadata: z + .object({ + secretPrefix: z.string().optional(), + secretSuffix: z.string().optional(), + secretGCPLabel: z + .object({ + labelName: z.string(), + labelValue: z.string() + }) + .optional() + }) + .optional() + }), + response: { + 200: z.object({ + integration: IntegrationsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const integration = await server.services.integration.createIntegration({ + actorId: req.permission.id, + actor: req.permission.type, + ...req.body + }); + return { integration }; + } + }); + + server.route({ + url: "/:integrationId", + method: "PATCH", + schema: { + params: z.object({ + integrationId: z.string().trim() + }), + body: z.object({ + app: z.string().trim(), + appId: z.string().trim(), + isActive: z.boolean(), + secretPath: z.string().trim().default("/"), + targetEnvironment: z.string().trim(), + owner: z.string().trim(), + environment: z.string().trim() + }), + response: { + 200: z.object({ + integration: IntegrationsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const integration = await server.services.integration.updateIntegration({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationId, + ...req.body + }); + return { integration }; + } + }); + + server.route({ + url: "/:integrationId", + method: "DELETE", + schema: { + params: z.object({ + integrationId: z.string().trim() + }), + response: { + 200: z.object({ + integration: IntegrationsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const integration = await server.services.integration.deleteIntegration({ + actorId: req.permission.id, + actor: req.permission.type, + id: req.params.integrationId + }); + return { integration }; + } + }); + + // TODO(akhilmhdh-pg): manual sync +}; diff --git a/backend-pg/src/services/integration-auth/integration-app-list.ts b/backend-pg/src/services/integration-auth/integration-app-list.ts new file mode 100644 index 000000000..39e629606 --- /dev/null +++ b/backend-pg/src/services/integration-auth/integration-app-list.ts @@ -0,0 +1,1161 @@ +/* eslint-disable no-await-in-loop */ +import { Octokit } from "@octokit/rest"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; + +import { Integrations, IntegrationUrls } from "./integration-list"; + +// akhilmhdh: check this part later. Copied from old base +// There are couple of improvements to be done from here + +type App = { + name: string; + appId?: string; + owner?: string; +}; + +/** + * Return list of apps for GCP secret manager integration + */ +const getAppsGCPSecretManager = async ({ accessToken }: { accessToken: string }) => { + interface GCPApp { + projectNumber: string; + projectId: string; + lifecycleState: + | "ACTIVE" + | "LIFECYCLE_STATE_UNSPECIFIED" + | "DELETE_REQUESTED" + | "DELETE_IN_PROGRESS"; + name: string; + createTime: string; + parent: { + type: "organization" | "folder" | "project"; + id: string; + }; + } + + interface GCPGetProjectsRes { + projects: GCPApp[]; + nextPageToken?: string; + } + + interface GCPGetServiceRes { + name: string; + parent: string; + state: "ENABLED" | "DISABLED" | "STATE_UNSPECIFIED"; + } + + let gcpApps: GCPApp[] = []; + const apps: App[] = []; + + const pageSize = 100; + let pageToken: string | undefined; + let hasMorePages = true; + + while (hasMorePages) { + const params = new URLSearchParams({ + pageSize: String(pageSize), + ...(pageToken ? { pageToken } : {}) + }); + + const res: GCPGetProjectsRes = ( + await request.get(`${IntegrationUrls.GCP_API_URL}/v1/projects`, { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }) + ).data; + + gcpApps = gcpApps.concat(res.projects); + + if (!res.nextPageToken) { + hasMorePages = false; + } + + pageToken = res.nextPageToken; + } + + // eslint-disable-next-line + for await (const gcpApp of gcpApps) { + try { + const res: GCPGetServiceRes = ( + await request.get( + `${IntegrationUrls.GCP_SERVICE_USAGE_URL}/v1/projects/${gcpApp.projectId}/services/${IntegrationUrls.GCP_SECRET_MANAGER_SERVICE_NAME}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ) + ).data; + + if (res.state === "ENABLED") { + apps.push({ + name: gcpApp.name, + appId: gcpApp.projectId + }); + } + } catch { + // eslint-disable-next-line + continue; + } + } + + return apps; +}; + +/** + * Return list of apps for Heroku integration + */ +const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => { + const res = ( + await request.get(`${IntegrationUrls.HEROKU_API_URL}/apps`, { + headers: { + Accept: "application/vnd.heroku+json; version=3", + Authorization: `Bearer ${accessToken}` + } + }) + ).data; + + const apps = res.map((a: any) => ({ + name: a.name + })); + + return apps; +}; + +/** + * Return list of names of apps for Vercel integration + */ +const getAppsVercel = async ({ + accessToken, + teamId +}: { + teamId?: string | null; + accessToken: string; +}) => { + const res = ( + await request.get(`${IntegrationUrls.VERCEL_API_URL}/v9/projects`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + }, + ...(teamId + ? { + params: { + teamId + } + } + : {}) + }) + ).data; + + const apps = res.projects.map((a: any) => ({ + name: a.name, + appId: a.id + })); + + return apps; +}; + +/** + * Return list of sites for Netlify integration + */ +const getAppsNetlify = async ({ accessToken }: { accessToken: string }) => { + const apps: any = []; + let page = 1; + const perPage = 10; + let hasMorePages = true; + + // paginate through all sites + while (hasMorePages) { + const params = new URLSearchParams({ + page: String(page), + per_page: String(perPage), + filter: "all" + }); + + const { data } = await request.get(`${IntegrationUrls.NETLIFY_API_URL}/api/v1/sites`, { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }); + + data.forEach((a: any) => { + apps.push({ + name: a.name, + appId: a.site_id + }); + }); + + if (data.length < perPage) { + hasMorePages = false; + } + + page += 1; + } + + return apps; +}; + +/** + * Return list of repositories for Github integration + */ +const getAppsGithub = async ({ accessToken }: { accessToken: string }) => { + interface GitHubApp { + id: string; + name: string; + permissions: { + admin: boolean; + }; + owner: { + login: string; + }; + } + + const octokit = new Octokit({ + auth: accessToken + }); + + const getAllRepos = async () => { + let repos: GitHubApp[] = []; + let page = 1; + const perPage = 100; + let hasMore = true; + + while (hasMore) { + const response = await octokit.request( + "GET /user/repos{?visibility,affiliation,type,sort,direction,per_page,page,since,before}", + { + per_page: perPage, + page + } + ); + + if (response.data.length > 0) { + repos = repos.concat(response.data); + page += 1; + } else { + hasMore = false; + } + } + + return repos; + }; + + const repos = await getAllRepos(); + + const apps = repos + .filter((a: GitHubApp) => a.permissions.admin === true) + .map((a: GitHubApp) => ({ + appId: a.id, + name: a.name, + owner: a.owner.login + })); + + return apps; +}; + +/** + * Return list of services for Render integration + */ +const getAppsRender = async ({ accessToken }: { accessToken: string }) => { + const res = ( + await request.get(`${IntegrationUrls.RENDER_API_URL}/v1/services`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + "Accept-Encoding": "application/json" + } + }) + ).data; + + const apps = res.map((a: any) => ({ + name: a.service.name, + appId: a.service.id + })); + + return apps; +}; + +/** + * Return list of projects for Railway integration + */ +const getAppsRailway = async ({ accessToken }: { accessToken: string }) => { + const query = ` + query GetProjects($userId: String, $teamId: String) { + projects(userId: $userId, teamId: $teamId) { + edges { + node { + id + name + } + } + } + } + `; + + const variables = {}; + + const { + data: { + data: { + projects: { edges } + } + } + } = await request.post( + IntegrationUrls.RAILWAY_API_URL, + { + query, + variables + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "Accept-Encoding": "application/json" + } + } + ); + + const apps = edges.map((e: any) => ({ + name: e.node.name, + appId: e.node.id + })); + + return apps; +}; + +/** + * Return list of sites for Laravel Forge integration + */ +const getAppsLaravelForge = async ({ + accessToken, + serverId +}: { + accessToken: string; + serverId?: string; +}) => { + const res = ( + await request.get(`${IntegrationUrls.LARAVELFORGE_API_URL}/api/v1/servers/${serverId}/sites`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + }) + ).data.sites; + + const apps = res.map((a: any) => ({ + name: a.name, + appId: a.id + })); + + return apps; +}; + +/** + * Return list of apps for Fly.io integration + */ +const getAppsFlyio = async ({ accessToken }: { accessToken: string }) => { + interface FlyioApp { + id: string; + name: string; + hostname: string; + } + + const query = ` + query($role: String) { + apps(type: "container", first: 400, role: $role) { + nodes { + id + name + hostname + } + } + } + `; + + const res: FlyioApp[] = ( + await request.post( + IntegrationUrls.FLYIO_API_URL, + { + query, + variables: { + role: null + } + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + "Accept-Encoding": "application/json" + } + } + ) + ).data.data.apps.nodes; + + const apps = res.map((a: FlyioApp) => ({ + name: a.name, + appId: a.id + })); + + return apps; +}; + +/** + * Return list of projects for CircleCI integration + */ +const getAppsCircleCI = async ({ accessToken }: { accessToken: string }) => { + const res = ( + await request.get(`${IntegrationUrls.CIRCLECI_API_URL}/v1.1/projects`, { + headers: { + "Circle-Token": accessToken, + "Accept-Encoding": "application/json" + } + }) + ).data; + + const apps = res?.map((a: any) => ({ + name: a?.reponame + })); + + return apps; +}; + +const getAppsTravisCI = async ({ accessToken }: { accessToken: string }) => { + const res = ( + await request.get(`${IntegrationUrls.TRAVISCI_API_URL}/repos`, { + headers: { + Authorization: `token ${accessToken}`, + "Accept-Encoding": "application/json" + } + }) + ).data; + + const apps = res?.map((a: any) => ({ + name: a?.slug?.split("/")[1], + appId: a?.id + })); + + return apps; +}; + +/** + * Return list of projects for Terraform Cloud integration + */ +const getAppsTerraformCloud = async ({ + accessToken, + workspacesId +}: { + accessToken: string; + workspacesId?: string; +}) => { + const res = ( + await request.get( + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${workspacesId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ) + ).data.data; + + const apps = []; + + const appsObj = { + name: res?.attributes.name, + appId: res?.id + }; + + apps.push(appsObj); + + return apps; +}; + +/** + * Return list of repositories for GitLab integration + */ +const getAppsGitlab = async ({ + url, + accessToken, + teamId +}: { + accessToken: string; + teamId?: string | null; + url?: string | null; +}) => { + const gitLabApiUrl = url ? `${url}/api` : IntegrationUrls.GITLAB_API_URL; + + const apps: App[] = []; + + let page = 1; + const perPage = 10; + let hasMorePages = true; + + if (teamId) { + // case: fetch projects for group with id [teamId] in GitLab + + while (hasMorePages) { + const params = new URLSearchParams({ + page: String(page), + per_page: String(perPage) + }); + + const { data } = await request.get(`${gitLabApiUrl}/v4/groups/${teamId}/projects`, { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }); + + data.forEach((a: any) => { + apps.push({ + name: a.name, + appId: a.id + }); + }); + + if (data.length < perPage) { + hasMorePages = false; + } + + page += 1; + } + } else { + // case: fetch projects for individual in GitLab + + const { id } = ( + await request.get(`${gitLabApiUrl}/v4/user`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }) + ).data; + + while (hasMorePages) { + const params = new URLSearchParams({ + page: String(page), + per_page: String(perPage) + }); + + const { data } = await request.get(`${gitLabApiUrl}/v4/users/${id}/projects`, { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }); + + data.forEach((a: any) => { + apps.push({ + name: a.name, + appId: a.id + }); + }); + + if (data.length < perPage) { + hasMorePages = false; + } + + page += 1; + } + } + + return apps; +}; + +/** + * Return list of projects for TeamCity integration + */ +const getAppsTeamCity = async ({ accessToken, url }: { url: string; accessToken: string }) => { + const res = ( + await request.get(`${url}/app/rest/projects`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + }) + ).data.project.slice(1); + + const apps = res.map((a: any) => ({ + name: a.name, + appId: a.id + })); + + return apps; +}; + +/** + * Return list of projects for Supabase integration + */ +const getAppsSupabase = async ({ accessToken }: { accessToken: string }) => { + const { data } = await request.get(`${IntegrationUrls.SUPABASE_API_URL}/v1/projects`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }); + + const apps = data.map((a: any) => ({ + name: a.name, + appId: a.id + })); + + return apps; +}; + +/** + * Return list of accounts for the Checkly integration + */ +const getAppsCheckly = async ({ accessToken }: { accessToken: string }) => { + const { data } = await request.get(`${IntegrationUrls.CHECKLY_API_URL}/v1/accounts`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + }); + + const apps = data.map((a: any) => ({ + name: a.name, + appId: a.id + })); + + return apps; +}; + +/** + * Return list of projects for the Cloudflare Pages integration + */ +const getAppsCloudflarePages = async ({ + accessToken, + accountId +}: { + accessToken: string; + accountId?: string; +}) => { + const { data } = await request.get( + `${IntegrationUrls.CLOUDFLARE_PAGES_API_URL}/client/v4/accounts/${accountId}/pages/projects`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + + const apps = data.result.map((a: any) => ({ + name: a.name, + appId: a.id + })); + return apps; +}; + +/** + * Return list of projects for the Cloudflare Workers integration + */ +const getAppsCloudflareWorkers = async ({ + accessToken, + accountId +}: { + accessToken: string; + accountId?: string; +}) => { + const { data } = await request.get( + `${IntegrationUrls.CLOUDFLARE_WORKERS_API_URL}/client/v4/accounts/${accountId}/workers/services`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + + const apps = data.result.map((a: any) => ({ + name: a.id, + appId: a.id + })); + return apps; +}; + +/** + * Return list of repositories for the BitBucket integration based on provided BitBucket workspace + */ +const getAppsBitBucket = async ({ + accessToken, + workspaceSlug +}: { + accessToken: string; + workspaceSlug?: string; +}) => { + interface RepositoriesResponse { + size: number; + page: number; + pageLen: number; + next: string; + previous: string; + values: Array; + } + + interface Repository { + type: string; + uuid: string; + name: string; + is_private: boolean; + created_on: string; + updated_on: string; + } + + if (!workspaceSlug) { + return []; + } + + const repositories: Repository[] = []; + let hasNextPage = true; + let repositoriesUrl = `${IntegrationUrls.BITBUCKET_API_URL}/2.0/repositories/${workspaceSlug}`; + + while (hasNextPage) { + const { data }: { data: RepositoriesResponse } = await request.get(repositoriesUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + }); + + if (data?.values.length > 0) { + data.values.forEach((repository) => { + repositories.push(repository); + }); + } + + if (data.next) { + repositoriesUrl = data.next; + } else { + hasNextPage = false; + } + } + + const apps = repositories.map((repository) => ({ + name: repository.name, + appId: repository.uuid + })); + return apps; +}; + +/** Return list of projects for Northflank integration + */ +const getAppsNorthflank = async ({ accessToken }: { accessToken: string }) => { + const { + data: { + data: { projects } + } + } = await request.get(`${IntegrationUrls.NORTHFLANK_API_URL}/v1/projects`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }); + + const apps = projects.map((a: any) => ({ + name: a.name, + appId: a.id + })); + + return apps; +}; + +/** + * Return list of projects for Supabase integration + */ +const getAppsCodefresh = async ({ accessToken }: { accessToken: string }) => { + const res = ( + await request.get(`${IntegrationUrls.CODEFRESH_API_URL}/projects`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }) + ).data; + + const apps = res.projects.map((a: any) => ({ + name: a.projectName, + appId: a.id + })); + + return apps; +}; + +/** + * Return list of projects for Windmill integration + */ +const getAppsWindmill = async ({ accessToken }: { accessToken: string }) => { + const { data } = await request.get(`${IntegrationUrls.WINDMILL_API_URL}/workspaces/list`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }); + + // check for write access of secrets in windmill workspaces + const writeAccessCheck = data.map(async (app: any) => { + try { + const userPath = "u/user/variable"; + const folderPath = "f/folder/variable"; + + const { data: writeUser } = await request.post( + `${IntegrationUrls.WINDMILL_API_URL}/w/${app.id}/variables/create`, + { + path: userPath, + value: "variable", + is_secret: true, + description: "variable description" + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + const { data: writeFolder } = await request.post( + `${IntegrationUrls.WINDMILL_API_URL}/w/${app.id}/variables/create`, + { + path: folderPath, + value: "variable", + is_secret: true, + description: "variable description" + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + // is write access is allowed then delete the created secrets from workspace + if (writeUser && writeFolder) { + await request.delete( + `${IntegrationUrls.WINDMILL_API_URL}/w/${app.id}/variables/delete/${userPath}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + await request.delete( + `${IntegrationUrls.WINDMILL_API_URL}/w/${app.id}/variables/delete/${folderPath}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + return app; + } + return { error: "cannot write secret" }; + } catch (err: any) { + return { error: err.message }; + } + }); + + const appsWriteResponses = await Promise.all(writeAccessCheck); + const appsWithWriteAccess = appsWriteResponses.filter((appRes: any) => !appRes.error); + + const apps = appsWithWriteAccess.map((a: any) => ({ + name: a.name, + appId: a.id + })); + + return apps; +}; + +/** + * Return list of applications for DigitalOcean App Platform integration + */ +const getAppsDigitalOceanAppPlatform = async ({ accessToken }: { accessToken: string }) => { + interface DigitalOceanApp { + id: string; + owner_uuid: string; + spec: Spec; + } + + interface Spec { + name: string; + region: string; + envs: Env[]; + } + + interface Env { + key: string; + value: string; + scope: string; + } + + const res = ( + await request.get(`${IntegrationUrls.DIGITAL_OCEAN_API_URL}/v2/apps`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }) + ).data; + + return (res.apps ?? []).map((a: DigitalOceanApp) => ({ + name: a.spec.name, + appId: a.id + })); +}; + +const getAppsHasuraCloud = async ({ accessToken }: { accessToken: string }) => { + const res = await request.post( + IntegrationUrls.HASURA_CLOUD_API_URL, + { + query: "query MyQuery { projects { name tenant { id } } }" + }, + { + headers: { + Authorization: `pat ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + + const data = (res?.data?.data?.projects ?? []).map( + ({ name, tenant: { id: appId } }: { name: string; tenant: { id: string } }) => ({ name, appId }) + ); + return data; +}; + +/** + * Return list of applications for Cloud66 integration + * @param {Object} obj + * @param {String} obj.accessToken - personal access token for Cloud66 API + * @returns {Object[]} apps - Cloud66 apps + * @returns {String} apps.name - name of Cloud66 app + * @returns {String} apps.appId - uid of Cloud66 app + */ +const getAppsCloud66 = async ({ accessToken }: { accessToken: string }) => { + interface Cloud66Apps { + uid: string; + name: string; + account_id: number; + git: string; + git_branch: string; + environment: string; + cloud: string; + fqdn: string; + language: string; + framework: string; + status: number; + health: number; + last_activity: string; + last_activity_iso: string; + maintenance_mode: boolean; + has_loadbalancer: boolean; + created_at: string; + updated_at: string; + deploy_directory: string; + cloud_status: string; + backend: string; + version: string; + revision: string; + is_busy: boolean; + account_name: string; + is_cluster: boolean; + is_inside_cluster: boolean; + cluster_name: any; + application_address: string; + configstore_namespace: string; + } + + const stacks = ( + await request.get(`${IntegrationUrls.CLOUD_66_API_URL}/3/stacks`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }) + ).data.response as Cloud66Apps[]; + + const apps = stacks.map((app) => ({ + name: app.name, + appId: app.uid + })); + + return apps; +}; + +export const getApps = async ({ + integration, + accessToken, + accessId, + teamId, + workspaceSlug, + url +}: { + integration: string; + accessToken: string; + accessId?: string; + teamId?: string | null; + workspaceSlug?: string; + url?: string | null; +}): Promise => { + switch (integration) { + case Integrations.GCP_SECRET_MANAGER: + return getAppsGCPSecretManager({ + accessToken + }); + case Integrations.AZURE_KEY_VAULT: + return []; + case Integrations.AWS_PARAMETER_STORE: + return []; + case Integrations.AWS_SECRET_MANAGER: + return []; + case Integrations.HEROKU: + return getAppsHeroku({ + accessToken + }); + case Integrations.VERCEL: + return getAppsVercel({ + accessToken, + teamId + }); + + case Integrations.NETLIFY: + return getAppsNetlify({ + accessToken + }); + + case Integrations.GITHUB: + return getAppsGithub({ + accessToken + }); + + case Integrations.GITLAB: + return getAppsGitlab({ + accessToken, + teamId, + url + }); + + case Integrations.RENDER: + return getAppsRender({ + accessToken + }); + + case Integrations.RAILWAY: + return getAppsRailway({ + accessToken + }); + + case Integrations.FLYIO: + return getAppsFlyio({ + accessToken + }); + + case Integrations.CIRCLECI: + return getAppsCircleCI({ + accessToken + }); + + case Integrations.LARAVELFORGE: + return getAppsLaravelForge({ + accessToken, + serverId: accessId + }); + + case Integrations.TERRAFORM_CLOUD: + return getAppsTerraformCloud({ + accessToken, + workspacesId: accessId + }); + + case Integrations.TRAVISCI: + return getAppsTravisCI({ + accessToken + }); + + case Integrations.TEAMCITY: + return getAppsTeamCity({ + accessToken, + url: url as string + }); + + case Integrations.SUPABASE: + return getAppsSupabase({ + accessToken + }); + + case Integrations.CHECKLY: + return getAppsCheckly({ + accessToken + }); + + case Integrations.CLOUDFLARE_PAGES: + return getAppsCloudflarePages({ + accessToken, + accountId: accessId + }); + + case Integrations.CLOUDFLARE_WORKERS: + return getAppsCloudflareWorkers({ + accessToken, + accountId: accessId + }); + + case Integrations.NORTHFLANK: + return getAppsNorthflank({ + accessToken + }); + + case Integrations.BITBUCKET: + return getAppsBitBucket({ + accessToken, + workspaceSlug + }); + + case Integrations.CODEFRESH: + return getAppsCodefresh({ + accessToken + }); + + case Integrations.WINDMILL: + return getAppsWindmill({ + accessToken + }); + + case Integrations.DIGITAL_OCEAN_APP_PLATFORM: + return getAppsDigitalOceanAppPlatform({ + accessToken + }); + + case Integrations.CLOUD_66: + return getAppsCloud66({ + accessToken + }); + + case Integrations.HASURA_CLOUD: + return getAppsHasuraCloud({ + accessToken + }); + + default: + throw new BadRequestError({ message: "integration not found" }); + } +}; diff --git a/backend-pg/src/services/integration-auth/integration-auth-dal.ts b/backend-pg/src/services/integration-auth/integration-auth-dal.ts new file mode 100644 index 000000000..a7a4578d4 --- /dev/null +++ b/backend-pg/src/services/integration-auth/integration-auth-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 TIntegrationAuthDalFactory = ReturnType; + +export const integrationAuthDalFactory = (db: TDbClient) => { + const integrationAuthOrm = ormify(db, TableName.IntegrationAuth); + return integrationAuthOrm; +}; diff --git a/backend-pg/src/services/integration-auth/integration-auth-service.ts b/backend-pg/src/services/integration-auth/integration-auth-service.ts new file mode 100644 index 000000000..bb5b634e3 --- /dev/null +++ b/backend-pg/src/services/integration-auth/integration-auth-service.ts @@ -0,0 +1,1008 @@ +import { ForbiddenError } from "@casl/ability"; + +import { + SecretEncryptionAlgo, + SecretKeyEncoding, + TIntegrationAuths, + TIntegrationAuthsInsert +} from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { request } from "@app/lib/config/request"; +import { + decryptSymmetric128BitHexKeyUTF8, + encryptSymmetric128BitHexKeyUTF8 +} from "@app/lib/crypto"; +import { BadRequestError } from "@app/lib/errors"; + +import { TIntegrationDalFactory } from "../integration/integration-dal"; +import { TProjectBotDalFactory } from "../project-bot/project-bot-dal"; +import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; +import { getApps } from "./integration-app-list"; +import { TIntegrationAuthDalFactory } from "./integration-auth-dal"; +import { + TBitbucketWorkspace, + TChecklyGroups, + TDeleteIntegrationAuthDTO, + TGetIntegrationAuthDTO, + TGetIntegrationAuthTeamCityBuildConfigDTO, + TIntegrationAuthAppsDTO, + TIntegrationAuthBitbucketWorkspaceDTO, + TIntegrationAuthChecklyGroupsDTO, + TIntegrationAuthNorthflankSecretGroupDTO, + TIntegrationAuthQoveryEnvironmentsDTO, + TIntegrationAuthQoveryOrgsDTO, + TIntegrationAuthQoveryProjectDTO, + TIntegrationAuthQoveryScopesDTO, + TIntegrationAuthRailwayEnvDTO, + TIntegrationAuthRailwayServicesDTO, + TIntegrationAuthTeamsDTO, + TIntegrationAuthVercelBranchesDTO, + TNorthflankSecretGroup, + TOauthExchangeDTO, + TSaveIntegrationAccessTokenDTO, + TTeamCityBuildConfig, + TVercelBranches +} from "./integration-auth-types"; +import { getIntegrationOptions,Integrations, IntegrationUrls } from "./integration-list"; +import { getTeams } from "./integration-team"; +import { exchangeCode, exchangeRefresh } from "./integration-token"; + +type TIntegrationAuthServiceFactoryDep = { + integrationAuthDal: TIntegrationAuthDalFactory; + integrationDal: Pick; + projectBotService: Pick; + projectBotDal: Pick; + permissionService: Pick; +}; + +export type TIntegrationAuthServiceFactory = ReturnType; + +export const integrationAuthServiceFactory = ({ + permissionService, + integrationAuthDal, + integrationDal, + projectBotDal, + projectBotService +}: TIntegrationAuthServiceFactoryDep) => { + const getIntegrationAuth = async ({ actor, id, actorId }: TGetIntegrationAuthDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + return integrationAuth; + }; + + const oauthExchange = async ({ + projectId, + actorId, + actor, + integration, + url, + code + }: TOauthExchangeDTO) => { + if (!Object.values(Integrations).includes(integration as Integrations)) + throw new BadRequestError({ message: "Invalid integration" }); + + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Integrations + ); + + const bot = await projectBotDal.findOne({ isActive: true, projectId }); + if (!bot) + throw new BadRequestError({ message: "Bot must be enabled for oauth2 code token exchange" }); + + const tokenExchange = await exchangeCode({ integration, code, url }); + const updateDoc: TIntegrationAuthsInsert = { + projectId, + integration, + url: tokenExchange?.url, + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8, + accessExpiresAt: tokenExchange.accessExpiresAt + }; + + if (integration === Integrations.VERCEL) { + updateDoc.teamId = tokenExchange.teamId; + } else if (integration === Integrations.NETLIFY) { + updateDoc.accountId = tokenExchange.accountId; + } else if (integration === Integrations.GCP_SECRET_MANAGER) { + updateDoc.metadata = { + authMethod: "oauth2" + }; + } + + const key = await projectBotService.getBotKey(projectId); + if (tokenExchange.refreshToken) { + const refreshEncToken = encryptSymmetric128BitHexKeyUTF8(tokenExchange.refreshToken, key); + updateDoc.refreshIV = refreshEncToken.iv; + updateDoc.refreshTag = refreshEncToken.tag; + updateDoc.refreshCiphertext = refreshEncToken.ciphertext; + } + if (tokenExchange.accessToken) { + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(tokenExchange.accessToken, key); + updateDoc.accessIV = accessEncToken.iv; + updateDoc.accessTag = accessEncToken.tag; + updateDoc.accessCiphertext = accessEncToken.ciphertext; + } + return integrationAuthDal.transaction(async (tx) => { + const doc = await integrationAuthDal.findOne({ projectId, integration }, tx); + if (!doc) { + return integrationAuthDal.create(updateDoc, tx); + } + return integrationAuthDal.updateById(doc.id, updateDoc, tx); + }); + }; + + const saveIntegrationToken = async ({ + projectId, + refreshToken, + actorId, + integration, + url, + actor, + accessId, + namespace, + accessToken + }: TSaveIntegrationAccessTokenDTO) => { + if (!Object.values(Integrations).includes(integration as Integrations)) + throw new BadRequestError({ message: "Invalid integration" }); + + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Integrations + ); + + const bot = await projectBotDal.findOne({ isActive: true, projectId }); + if (!bot) + throw new BadRequestError({ message: "Bot must be enabled for oauth2 code token exchange" }); + + const updateDoc: TIntegrationAuthsInsert = { + projectId, + namespace, + integration, + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8, + ...(integration === Integrations.GCP_SECRET_MANAGER + ? { + metadata: { + authMethod: "serviceAccount" + } + } + : {}) + }; + + const key = await projectBotService.getBotKey(projectId); + if (refreshToken) { + const tokenDetails = await exchangeRefresh( + integration, + refreshToken, + url, + updateDoc.metadata as Record + ); + const refreshEncToken = encryptSymmetric128BitHexKeyUTF8(tokenDetails.refreshToken, key); + updateDoc.refreshIV = refreshEncToken.iv; + updateDoc.refreshTag = refreshEncToken.tag; + updateDoc.refreshCiphertext = refreshEncToken.ciphertext; + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(tokenDetails.accessToken, key); + updateDoc.accessIV = accessEncToken.iv; + updateDoc.accessTag = accessEncToken.tag; + updateDoc.accessCiphertext = accessEncToken.ciphertext; + updateDoc.accessExpiresAt = tokenDetails.accessExpiresAt; + } + + if (!refreshToken && (accessId || accessToken)) { + if (accessToken) { + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(accessToken, key); + updateDoc.accessIV = accessEncToken.iv; + updateDoc.accessTag = accessEncToken.tag; + updateDoc.accessCiphertext = accessEncToken.ciphertext; + } + if (accessId) { + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(accessId, key); + updateDoc.accessIdIV = accessEncToken.iv; + updateDoc.accessIdTag = accessEncToken.tag; + updateDoc.accessIdCiphertext = accessEncToken.ciphertext; + } + } + return integrationAuthDal.transaction(async (tx) => { + const doc = await integrationAuthDal.findOne({ projectId, integration }, tx); + if (!doc) { + return integrationAuthDal.create(updateDoc, tx); + } + return integrationAuthDal.updateById(doc.id, updateDoc, tx); + }); + }; + + // helper function + const getIntegrationAccessToken = async (integrationAuth: TIntegrationAuths, botKey: string) => { + let accessToken: string | undefined; + let accessId: string | undefined; + if (integrationAuth.accessTag && integrationAuth.accessIV && integrationAuth.accessCiphertext) { + accessToken = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: integrationAuth.accessCiphertext, + iv: integrationAuth.accessIV, + tag: integrationAuth.accessIV, + key: botKey + }); + } + + if ( + integrationAuth.refreshCiphertext && + integrationAuth.refreshIV && + integrationAuth.refreshTag + ) { + const refreshToken = decryptSymmetric128BitHexKeyUTF8({ + key: botKey, + ciphertext: integrationAuth.refreshCiphertext, + iv: integrationAuth.refreshIV, + tag: integrationAuth.refreshTag + }); + + if (integrationAuth.accessExpiresAt && integrationAuth.accessExpiresAt < new Date()) { + // refer above it contains same logic except not saving + const tokenDetails = await exchangeRefresh( + integrationAuth.integration, + refreshToken, + integrationAuth?.url, + integrationAuth.metadata as Record + ); + const refreshEncToken = encryptSymmetric128BitHexKeyUTF8(tokenDetails.refreshToken, botKey); + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(tokenDetails.accessToken, botKey); + accessToken = tokenDetails.accessToken; + await integrationAuthDal.updateById(integrationAuth.id, { + refreshIV: refreshEncToken.iv, + refreshTag: refreshEncToken.tag, + refreshCiphertext: refreshEncToken.ciphertext, + accessExpiresAt: tokenDetails.accessExpiresAt, + accessIV: accessEncToken.iv, + accessTag: accessEncToken.tag, + accessCiphertext: accessEncToken.ciphertext + }); + } + } + if (!accessToken) throw new BadRequestError({ message: "Missing access token" }); + + if ( + integrationAuth.accessIdTag && + integrationAuth.accessIdIV && + integrationAuth.accessIdCiphertext + ) { + accessId = decryptSymmetric128BitHexKeyUTF8({ + key: botKey, + ciphertext: integrationAuth.accessIdCiphertext, + iv: integrationAuth.accessIdIV, + tag: integrationAuth.accessIdTag + }); + } + return { accessId, accessToken }; + }; + + const getIntegrationApps = async ({ + actor, + actorId, + teamId, + id, + workspaceSlug + }: TIntegrationAuthAppsDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken, accessId } = await getIntegrationAccessToken(integrationAuth, botKey); + const apps = await getApps({ + integration: integrationAuth.integration, + accessToken, + accessId, + teamId, + workspaceSlug, + url: integrationAuth.url + }); + return apps; + }; + + const getIntegrationAuthTeams = async ({ actor, actorId, id }: TIntegrationAuthTeamsDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + const teams = await getTeams({ + integration: integrationAuth.integration, + accessToken, + url: integrationAuth.url || "" + }); + return teams; + }; + + const getVercelBranches = async ({ + appId, + id, + actor, + actorId + }: TIntegrationAuthVercelBranchesDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + + if (appId) { + const { data } = await request.get( + `${IntegrationUrls.VERCEL_API_URL}/v1/integrations/git-branches`, + { + params: { + projectId: appId, + teamId: integrationAuth.teamId + }, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + return data.map((b) => b.ref); + } + return []; + }; + + const getChecklyGroups = async ({ + actorId, + actor, + id, + accountId + }: TIntegrationAuthChecklyGroupsDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + if (accountId) { + const { data } = await request.get( + `${IntegrationUrls.CHECKLY_API_URL}/v1/check-groups`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + "X-Checkly-Account": accountId + } + } + ); + return data.map(({ name, id: groupId }) => ({ name, groupId })); + } + return []; + }; + + const getQoveryOrgs = async ({ actorId, actor, id }: TIntegrationAuthQoveryOrgsDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + const { data } = await request.get<{ results: Array<{ id: string; name: string }> }>( + `${IntegrationUrls.QOVERY_API_URL}/organization`, + { + headers: { + Authorization: `Token ${accessToken}`, + Accept: "application/json" + } + } + ); + + return data.results.map(({ name, id: orgId }) => ({ name, orgId })); + }; + + const getQoveryProjects = async ({ + actorId, + actor, + id, + orgId + }: TIntegrationAuthQoveryProjectDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + if (orgId) { + const { data } = await request.get<{ results: Array<{ id: string; name: string }> }>( + `${IntegrationUrls.QOVERY_API_URL}/organization/${orgId}/project`, + { + headers: { + Authorization: `Token ${accessToken}`, + Accept: "application/json" + } + } + ); + return data.results.map(({ name, id: projectId }) => ({ name, projectId })); + } + return []; + }; + + const getQoveryEnvs = async ({ + projectId, + id, + actor, + actorId + }: TIntegrationAuthQoveryEnvironmentsDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + if (projectId && projectId !== "none") { + // TODO: fix + const { data } = await request.get<{ results: { id: string; name: string }[] }>( + `${IntegrationUrls.QOVERY_API_URL}/project/${projectId}/environment`, + { + headers: { + Authorization: `Token ${accessToken}`, + Accept: "application/json" + } + } + ); + + return data.results.map(({ id: environmentId, name }) => ({ + name, + environmentId + })); + } + return []; + }; + + const getQoveryApps = async ({ + id, + actor, + actorId, + environmentId + }: TIntegrationAuthQoveryScopesDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + if (environmentId) { + const { data } = await request.get<{ results: { id: string; name: string }[] }>( + `${IntegrationUrls.QOVERY_API_URL}/environment/${environmentId}/application`, + { + headers: { + Authorization: `Token ${accessToken}`, + Accept: "application/json" + } + } + ); + + return data.results.map(({ id: appId, name }) => ({ + name, + appId + })); + } + return []; + }; + + const getQoveryContainers = async ({ + id, + actor, + actorId, + environmentId + }: TIntegrationAuthQoveryScopesDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + if (environmentId) { + const { data } = await request.get<{ results: { id: string; name: string }[] }>( + `${IntegrationUrls.QOVERY_API_URL}/environment/${environmentId}/container`, + { + headers: { + Authorization: `Token ${accessToken}`, + Accept: "application/json" + } + } + ); + + return data.results.map(({ id: appId, name }) => ({ + name, + appId + })); + } + return []; + }; + + const getQoveryJobs = async ({ + id, + actor, + actorId, + environmentId + }: TIntegrationAuthQoveryScopesDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + if (environmentId) { + const { data } = await request.get<{ results: { id: string; name: string }[] }>( + `${IntegrationUrls.QOVERY_API_URL}/environment/${environmentId}/job`, + { + headers: { + Authorization: `Token ${accessToken}`, + Accept: "application/json" + } + } + ); + + return data.results.map(({ id: appId, name }) => ({ + name, + appId + })); + } + return []; + }; + + const getRailwayEnvironments = async ({ + id, + actor, + actorId, + appId + }: TIntegrationAuthRailwayEnvDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + if (appId) { + const query = ` + query GetEnvironments($projectId: String!, $after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int) { + environments(projectId: $projectId, after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last) { + edges { + node { + id + name + isEphemeral + } + } + } + } + `; + + const variables = { + projectId: appId + }; + + const { + data: { + data: { + environments: { edges } + } + } + } = await request.post<{ + data: { + environments: { edges: { node: { id: string; name: string; isEphemeral: boolean } }[] }; + }; + }>( + IntegrationUrls.RAILWAY_API_URL, + { + query, + variables + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + return edges.map(({ node: { name, id: environmentId } }) => ({ name, environmentId })); + } + return []; + }; + const getRailwayServices = async ({ + id, + actor, + actorId, + appId + }: TIntegrationAuthRailwayServicesDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + if (appId) { + const query = ` + query project($id: String!) { + project(id: $id) { + createdAt + deletedAt + id + description + expiredAt + isPublic + isTempProject + isUpdatable + name + prDeploys + teamId + updatedAt + upstreamUrl + services { + edges { + node { + id + name + } + } + } + } + } + `; + + const variables = { + projectId: appId + }; + + const { + data: { + data: { + project: { + services: { edges } + } + } + } + } = await request.post<{ + data: { + project: { + services: { edges: { node: { id: string; name: string; isEphemeral: boolean } }[] }; + }; + }; + }>( + IntegrationUrls.RAILWAY_API_URL, + { + query, + variables + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + return edges.map(({ node: { name, id: serviceId } }) => ({ name, serviceId })); + } + return []; + }; + + const getBitbucketWorkspaces = async ({ + actorId, + actor, + id + }: TIntegrationAuthBitbucketWorkspaceDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + const workspaces: TBitbucketWorkspace[] = []; + let hasNextPage = true; + let workspaceUrl = `${IntegrationUrls.BITBUCKET_API_URL}/2.0/workspaces`; + + while (hasNextPage) { + // eslint-disable-next-line + const { data }: { data: { values: TBitbucketWorkspace[]; next: string } } = await request.get( + workspaceUrl, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + if (data?.values.length > 0) { + data.values.forEach((workspace) => { + workspaces.push(workspace); + }); + } + + if (data.next) { + workspaceUrl = data.next; + } else { + hasNextPage = false; + } + } + return workspaces; + }; + + const getNorthFlankSecretGroups = async ({ + id, + actor, + actorId, + appId + }: TIntegrationAuthNorthflankSecretGroupDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + const secretGroups: { name: string; groupId: string }[] = []; + + if (appId) { + let page = 1; + const perPage = 10; + let hasMorePages = true; + + while (hasMorePages) { + const params = new URLSearchParams({ + page: String(page), + per_page: String(perPage), + filter: "all" + }); + + const { + data: { + data: { secrets } + } + // eslint-disable-next-line + } = await request.get<{ data: { secrets: TNorthflankSecretGroup[] } }>( + `${IntegrationUrls.NORTHFLANK_API_URL}/v1/projects/${appId}/secrets`, + { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + secrets.forEach((a: any) => { + secretGroups.push({ + name: a.name, + groupId: a.id + }); + }); + + if (secrets.length < perPage) { + hasMorePages = false; + } + + page += 1; + } + } + return secretGroups; + }; + + const getTeamcityBuildConfigs = async ({ + appId, + id, + actorId, + actor + }: TGetIntegrationAuthTeamCityBuildConfigDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + if (appId) { + const { + data: { buildType } + } = await request.get<{ buildType: TTeamCityBuildConfig[] }>( + `${integrationAuth.url}/app/rest/buildTypes`, + { + params: { + locator: `project:${appId}` + }, + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + + return buildType.map(({ name, id: buildConfigId }) => ({ + name, + buildConfigId + })); + } + return []; + }; + + const deleteIntegrationAuth = async ({ id, actorId, actor }: TDeleteIntegrationAuthDTO) => { + const integrationAuth = await integrationAuthDal.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.Integrations + ); + + const delIntegrationAuth = await integrationAuthDal.transaction(async (tx) => { + const doc = await integrationAuthDal.deleteById(integrationAuth.id, tx); + if (!doc) throw new BadRequestError({ message: "Faled to find integration" }); + await integrationDal.delete({ integrationAuthId: doc.id }, tx); + return doc; + }); + + return delIntegrationAuth; + // TODO(akhilmhdh-pg): add audit log + }; + + return { + getIntegrationOptions, + getIntegrationAuth, + oauthExchange, + saveIntegrationToken, + deleteIntegrationAuth, + getIntegrationAuthTeams, + getIntegrationApps, + getVercelBranches, + getApps, + getChecklyGroups, + getQoveryApps, + getQoveryEnvs, + getQoveryJobs, + getQoveryOrgs, + getQoveryProjects, + getQoveryContainers, + getRailwayServices, + getRailwayEnvironments, + getNorthFlankSecretGroups, + getTeamcityBuildConfigs, + getBitbucketWorkspaces + }; +}; diff --git a/backend-pg/src/services/integration-auth/integration-auth-types.ts b/backend-pg/src/services/integration-auth/integration-auth-types.ts new file mode 100644 index 000000000..d830c9176 --- /dev/null +++ b/backend-pg/src/services/integration-auth/integration-auth-types.ts @@ -0,0 +1,134 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TGetIntegrationAuthDTO = { + id: string; +} & Omit; + +export type TOauthExchangeDTO = { + integration: string; + code: string; + url: string; +} & TProjectPermission; + +export type TSaveIntegrationAccessTokenDTO = { + integration: string; + accessId?: string; + accessToken?: string; + url?: string; + namespace?: string; + refreshToken?: string; +} & TProjectPermission; + +export type TIntegrationAuthAppsDTO = { + id: string; + teamId?: string; + workspaceSlug?: string; +} & Omit; + +export type TIntegrationAuthTeamsDTO = { + id: string; +} & Omit; + +export type TIntegrationAuthVercelBranchesDTO = { + id: string; + appId: string; +} & Omit; + +export type TIntegrationAuthChecklyGroupsDTO = { + id: string; + accountId: string; +} & Omit; + +export type TIntegrationAuthQoveryOrgsDTO = { + id: string; +} & Omit; + +export type TIntegrationAuthQoveryProjectDTO = { + id: string; + orgId: string; +} & Omit; + +export type TIntegrationAuthQoveryEnvironmentsDTO = { + id: string; +} & TProjectPermission; + +export type TIntegrationAuthQoveryScopesDTO = { + id: string; + environmentId: string; +} & Omit; + +export type TIntegrationAuthRailwayEnvDTO = { + id: string; + appId: string; +} & Omit; + +export type TIntegrationAuthRailwayServicesDTO = { + id: string; + appId: string; +} & Omit; + +export type TIntegrationAuthBitbucketWorkspaceDTO = { + id: string; +} & Omit; + +export type TIntegrationAuthNorthflankSecretGroupDTO = { + id: string; + appId: string; +} & Omit; + +export type TDeleteIntegrationAuthDTO = { + id: string; +} & Omit; + +export type TGetIntegrationAuthTeamCityBuildConfigDTO = { + id: string; + appId: string; +} & Omit; + +export type TVercelBranches = { + ref: string; + lastCommit: string; + isProtected: boolean; +}; + +export type TChecklyGroups = { + id: number; + name: string; +}; + +export type TQoveryProjects = { + id: string; + name: string; +}; + +export type TQoveryEnvironments = { + id: string; + name: string; +}; + +export type TBitbucketWorkspace = { + type: string; + uuid: string; + name: string; + slug: string; + is_private: boolean; + created_on: string; + updated_on: string; +}; + +export type TNorthflankSecretGroup = { + id: string; + name: string; + description: string; + priority: number; + projectId: string; +}; + +export type TTeamCityBuildConfig = { + id: string; + name: string; + projectName: string; + projectId: string; + href: string; + webUrl: string; +}; diff --git a/backend-pg/src/services/integration-auth/integration-list.ts b/backend-pg/src/services/integration-auth/integration-list.ts new file mode 100644 index 000000000..43cf02e1f --- /dev/null +++ b/backend-pg/src/services/integration-auth/integration-list.ts @@ -0,0 +1,363 @@ +import { getConfig } from "@app/lib/config/env"; + +export enum Integrations { + AZURE_KEY_VAULT = "azure-key-vault", + AWS_PARAMETER_STORE = "aws-parameter-store", + AWS_SECRET_MANAGER = "aws-secret-manager", + GCP_SECRET_MANAGER = "gcp-secret-manager", + HEROKU = "heroku", + VERCEL = "vercel", + NETLIFY = "netlify", + GITHUB = "github", + GITLAB = "gitlab", + RENDER = "render", + RAILWAY = "railway", + FLYIO = "flyio", + LARAVELFORGE = "laravel-forge", + CIRCLECI = "circleci", + TRAVISCI = "travisci", + TEAMCITY = "teamcity", + SUPABASE = "supabase", + CHECKLY = "checkly", + QOVERY = "qovery", + TERRAFORM_CLOUD = "terraform-cloud", + HASHICORP_VAULT = "hashicorp-vault", + CLOUDFLARE_PAGES = "cloudflare-pages", + CLOUDFLARE_WORKERS = "cloudflare-workers", + BITBUCKET = "bitbucket", + CODEFRESH = "codefresh", + WINDMILL = "windmill", + DIGITAL_OCEAN_APP_PLATFORM = "digital-ocean-app-platform", + CLOUD_66 = "cloud-66", + NORTHFLANK = "northflank", + HASURA_CLOUD = "hasura-cloud" +} + +export enum IntegrationType { + OAUTH2 = "oauth2" +} + +export enum IntegrationUrls { + // integration oauth endpoints + GCP_TOKEN_URL = "https://oauth2.googleapis.com/token", + AZURE_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token", + HEROKU_TOKEN_URL = "https://id.heroku.com/oauth/token", + VERCEL_TOKEN_URL = "https://api.vercel.com/v2/oauth/access_token", + NETLIFY_TOKEN_URL = "https://api.netlify.com/oauth/token", + GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token", + GITLAB_TOKEN_URL = "https://gitlab.com/oauth/token", + BITBUCKET_TOKEN_URL = "https://bitbucket.org/site/oauth2/access_token", + + // integration apps endpoints + GCP_API_URL = "https://cloudresourcemanager.googleapis.com", + HEROKU_API_URL = "https://api.heroku.com", + GITLAB_URL = "https://gitlab.com", + GITLAB_API_URL = `${GITLAB_URL}/api`, + GITHUB_API_URL = "https://api.github.com", + VERCEL_API_URL = "https://api.vercel.com", + NETLIFY_API_URL = "https://api.netlify.com", + RENDER_API_URL = "https://api.render.com", + RAILWAY_API_URL = "https://backboard.railway.app/graphql/v2", + FLYIO_API_URL = "https://api.fly.io/graphql", + CIRCLECI_API_URL = "https://circleci.com/api", + TRAVISCI_API_URL = "https://api.travis-ci.com", + SUPABASE_API_URL = "https://api.supabase.com", + LARAVELFORGE_API_URL = "https://forge.laravel.com", + CHECKLY_API_URL = "https://api.checklyhq.com", + QOVERY_API_URL = "https://api.qovery.com", + TERRAFORM_CLOUD_API_URL = "https://app.terraform.io", + CLOUDFLARE_PAGES_API_URL = "https://api.cloudflare.com", + CLOUDFLARE_WORKERS_API_URL = "https://api.cloudflare.com", + BITBUCKET_API_URL = "https://api.bitbucket.org", + CODEFRESH_API_URL = "https://g.codefresh.io/api", + WINDMILL_API_URL = "https://app.windmill.dev/api", + DIGITAL_OCEAN_API_URL = "https://api.digitalocean.com", + CLOUD_66_API_URL = "https://app.cloud66.com/api", + NORTHFLANK_API_URL = "https://api.northflank.com", + HASURA_CLOUD_API_URL = "https://data.pro.hasura.io/v1/graphql", + + GCP_SECRET_MANAGER_SERVICE_NAME = "secretmanager.googleapis.com", + GCP_SECRET_MANAGER_URL = `https://${GCP_SECRET_MANAGER_SERVICE_NAME}`, + GCP_SERVICE_USAGE_URL = "https://serviceusage.googleapis.com", + GCP_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform" +} + +export const getIntegrationOptions = async () => { + const appCfg = getConfig(); + + const INTEGRATION_OPTIONS = [ + { + name: "Heroku", + slug: "heroku", + image: "Heroku.png", + isAvailable: true, + type: "oauth", + clientId: appCfg.CLIENT_ID_HEROKU, + docsLink: "" + }, + { + name: "Vercel", + slug: "vercel", + image: "Vercel.png", + isAvailable: true, + type: "oauth", + clientId: "", + clientSlug: appCfg.CLIENT_ID_VERCEL, + docsLink: "" + }, + { + name: "Netlify", + slug: "netlify", + image: "Netlify.png", + isAvailable: true, + type: "oauth", + clientId: appCfg.CLIENT_ID_NETLIFY, + docsLink: "" + }, + { + name: "GitHub", + slug: "github", + image: "GitHub.png", + isAvailable: true, + type: "oauth", + clientId: appCfg.CLIENT_ID_BITBUCKET, + docsLink: "" + }, + { + name: "Render", + slug: "render", + image: "Render.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "Railway", + slug: "railway", + image: "Railway.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "Fly.io", + slug: "flyio", + image: "Flyio.svg", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "AWS Parameter Store", + slug: "aws-parameter-store", + image: "Amazon Web Services.png", + isAvailable: true, + type: "custom", + clientId: "", + docsLink: "" + }, + { + name: "Laravel Forge", + slug: "laravel-forge", + image: "Laravel Forge.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "AWS Secrets Manager", + slug: "aws-secret-manager", + image: "Amazon Web Services.png", + isAvailable: true, + type: "custom", + clientId: "", + docsLink: "" + }, + { + name: "Azure Key Vault", + slug: "azure-key-vault", + image: "Microsoft Azure.png", + isAvailable: true, + type: "oauth", + clientId: appCfg.CLIENT_ID_AZURE, + docsLink: "" + }, + { + name: "Circle CI", + slug: "circleci", + image: "Circle CI.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "GitLab", + slug: "gitlab", + image: "GitLab.png", + isAvailable: true, + type: "custom", + clientId: appCfg.CLIENT_ID_GITLAB, + docsLink: "" + }, + { + name: "Terraform Cloud", + slug: "terraform-cloud", + image: "Terraform Cloud.png", + isAvailable: true, + type: "pat", + cliendId: "", + docsLink: "" + }, + { + name: "Travis CI", + slug: "travisci", + image: "Travis CI.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "TeamCity", + slug: "teamcity", + image: "TeamCity.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "Supabase", + slug: "supabase", + image: "Supabase.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "Checkly", + slug: "checkly", + image: "Checkly.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "Qovery", + slug: "qovery", + image: "Qovery.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "HashiCorp Vault", + slug: "hashicorp-vault", + image: "Vault.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "GCP Secret Manager", + slug: "gcp-secret-manager", + image: "Google Cloud Platform.png", + isAvailable: true, + type: "oauth", + clientId: appCfg.CLIENT_ID_GCP_SECRET_MANAGER, + docsLink: "" + }, + { + name: "Cloudflare Pages", + slug: "cloudflare-pages", + image: "Cloudflare.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "Cloudflare Workers", + slug: "cloudflare-workers", + image: "Cloudflare.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "BitBucket", + slug: "bitbucket", + image: "BitBucket.png", + isAvailable: true, + type: "oauth", + clientId: appCfg.CLIENT_ID_BITBUCKET, + docsLink: "" + }, + { + name: "Codefresh", + slug: "codefresh", + image: "Codefresh.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "Windmill", + slug: "windmill", + image: "Windmill.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "Digital Ocean App Platform", + slug: "digital-ocean-app-platform", + image: "Digital Ocean.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "Cloud 66", + slug: "cloud-66", + image: "Cloud 66.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "Northflank", + slug: "northflank", + image: "Northflank.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, + { + name: "Hasura Cloud", + slug: "hasura-cloud", + image: "Hasura.svg", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + } + ]; + + return INTEGRATION_OPTIONS; +}; diff --git a/backend-pg/src/services/integration-auth/integration-team.ts b/backend-pg/src/services/integration-auth/integration-team.ts new file mode 100644 index 000000000..287e7cc7a --- /dev/null +++ b/backend-pg/src/services/integration-auth/integration-team.ts @@ -0,0 +1,49 @@ +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; + +import { Integrations,IntegrationUrls } from "./integration-list"; + +type Team = { + name: string; + teamId: string; +}; +const getTeamsGitLab = async ({ url, accessToken }: { url: string; accessToken: string }) => { + const gitLabApiUrl = url ? `${url}/api` : IntegrationUrls.GITLAB_API_URL; + + let teams: Team[] = []; + const res = ( + await request.get(`${gitLabApiUrl}/v4/groups`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + }) + ).data; + + teams = res.map((t: any) => ({ + name: t.name, + teamId: t.id + })); + + return teams; +}; + +export const getTeams = async ({ + accessToken, + url, + integration +}: { + accessToken: string; + url?: string; + integration: string; +}) => { + switch (integration) { + case Integrations.GITLAB: + return getTeamsGitLab({ + url: url as string, + accessToken + }); + default: + throw new BadRequestError({ message: "Integration doesn't have team support" }); + } +}; diff --git a/backend-pg/src/services/integration-auth/integration-token.ts b/backend-pg/src/services/integration-auth/integration-token.ts new file mode 100644 index 000000000..3405321fd --- /dev/null +++ b/backend-pg/src/services/integration-auth/integration-token.ts @@ -0,0 +1,725 @@ +import jwt from "jsonwebtoken"; + +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; + +import { Integrations,IntegrationUrls } from "./integration-list"; + +type ExchangeCodeAzureResponse = { + token_type: string; + scope: string; + expires_in: number; + ext_expires_in: number; + access_token: string; + refresh_token: string; + id_token: string; +}; + +type ExchangeCodeGCPResponse = { + access_token: string; + expires_in: number; + refresh_token: string; + scope: string; + token_type: string; +}; + +type ExchangeCodeHerokuResponse = { + token_type: string; + access_token: string; + expires_in: number; + refresh_token: string; + user_id: string; + session_nonce?: string; +}; + +type ExchangeCodeVercelResponse = { + token_type: string; + access_token: string; + installation_id: string; + user_id: string; + team_id?: string; +}; + +type ExchangeCodeNetlifyResponse = { + access_token: string; + token_type: string; + refresh_token: string; + scope: string; + created_at: number; +}; + +type ExchangeCodeGithubResponse = { + access_token: string; + scope: string; + token_type: string; +}; + +type ExchangeCodeGitlabResponse = { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope: string; + created_at: number; +}; + +type ExchangeCodeBitBucketResponse = { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scopes: string; + state: string; +}; + +const exchangeCodeGCP = async ({ code }: { code: string }) => { + const accessExpiresAt = new Date(); + const appCfg = getConfig(); + if (!appCfg.CLIENT_SECRET_GCP_SECRET_MANAGER || !appCfg.CLIENT_ID_GCP_SECRET_MANAGER) { + throw new BadRequestError({ message: "Missing client id and client secret" }); + } + + const res: ExchangeCodeGCPResponse = ( + await request.post( + IntegrationUrls.GCP_TOKEN_URL, + new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: appCfg.CLIENT_ID_GCP_SECRET_MANAGER, + client_secret: appCfg.CLIENT_SECRET_GCP_SECRET_MANAGER, + redirect_uri: `${appCfg.SITE_URL}/integrations/gcp-secret-manager/oauth2/callback` + }) + ) + ).data; + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + res.expires_in); + + return { + accessToken: res.access_token, + refreshToken: res.refresh_token, + accessExpiresAt + }; +}; + +const exchangeCodeAzure = async ({ code }: { code: string }) => { + const accessExpiresAt = new Date(); + const appCfg = getConfig(); + if (!appCfg.CLIENT_ID_AZURE || !appCfg.CLIENT_SECRET_AZURE) { + throw new BadRequestError({ message: "Missing client id and client secret" }); + } + const res: ExchangeCodeAzureResponse = ( + await request.post( + IntegrationUrls.AZURE_TOKEN_URL, + new URLSearchParams({ + grant_type: "authorization_code", + code, + scope: "https://vault.azure.net/.default openid offline_access", + client_id: appCfg.CLIENT_ID_AZURE, + client_secret: appCfg.CLIENT_SECRET_AZURE, + redirect_uri: `${appCfg.SITE_URL}/integrations/azure-key-vault/oauth2/callback` + } as any) + ) + ).data; + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + res.expires_in); + + return { + accessToken: res.access_token, + refreshToken: res.refresh_token, + accessExpiresAt + }; +}; + +const exchangeCodeHeroku = async ({ code }: { code: string }) => { + const accessExpiresAt = new Date(); + const appCfg = getConfig(); + if (!appCfg.CLIENT_SECRET_HEROKU) { + throw new BadRequestError({ message: "Missing client id and client secret" }); + } + + const res: ExchangeCodeHerokuResponse = ( + await request.post( + IntegrationUrls.HEROKU_TOKEN_URL, + new URLSearchParams({ + grant_type: "authorization_code", + code, + client_secret: appCfg.CLIENT_SECRET_HEROKU + }) + ) + ).data; + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + res.expires_in); + + return { + accessToken: res.access_token, + refreshToken: res.refresh_token, + accessExpiresAt + }; +}; + +/** + * Return [accessToken], [accessExpiresAt], and [refreshToken] for Vercel + * code-token exchange + * @param {Object} obj1 + * @param {Object} obj1.code - code for code-token exchange + * @returns {Object} obj2 + * @returns {String} obj2.accessToken - access token for Heroku API + * @returns {String} obj2.refreshToken - refresh token for Heroku API + * @returns {Date} obj2.accessExpiresAt - date of expiration for access token + */ +const exchangeCodeVercel = async ({ code }: { code: string }) => { + const appCfg = getConfig(); + if (!appCfg.CLIENT_ID_VERCEL || !appCfg.CLIENT_SECRET_VERCEL) { + throw new BadRequestError({ message: "Missing client id and client secret" }); + } + + const res: ExchangeCodeVercelResponse = ( + await request.post( + IntegrationUrls.VERCEL_TOKEN_URL, + new URLSearchParams({ + code, + client_id: appCfg.CLIENT_ID_VERCEL, + client_secret: appCfg.CLIENT_SECRET_VERCEL, + redirect_uri: `${appCfg.SITE_URL}/integrations/vercel/oauth2/callback` + } as any) + ) + ).data; + + return { + accessToken: res.access_token, + refreshToken: null, + accessExpiresAt: null, + teamId: res.team_id + }; +}; + +/** + * Return [accessToken], [accessExpiresAt], and [refreshToken] for Vercel + * code-token exchange + * @param {Object} obj1 + * @param {Object} obj1.code - code for code-token exchange + * @returns {Object} obj2 + * @returns {String} obj2.accessToken - access token for Heroku API + * @returns {String} obj2.refreshToken - refresh token for Heroku API + * @returns {Date} obj2.accessExpiresAt - date of expiration for access token + */ +const exchangeCodeNetlify = async ({ code }: { code: string }) => { + const appCfg = getConfig(); + if (!appCfg.CLIENT_ID_NETLIFY || !appCfg.CLIENT_SECRET_NETLIFY) { + throw new BadRequestError({ message: "Missing client id and client secret" }); + } + + const res: ExchangeCodeNetlifyResponse = ( + await request.post( + IntegrationUrls.NETLIFY_TOKEN_URL, + new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: appCfg.CLIENT_ID_NETLIFY, + client_secret: appCfg.CLIENT_SECRET_NETLIFY, + redirect_uri: `${appCfg.SITE_URL}/integrations/netlify/oauth2/callback` + } as any) + ) + ).data; + + // akhilmhdh: commented out by me. Not sure why its being called but never used in prev codebase + // const res2 = await request.get("https://api.netlify.com/api/v1/sites", { + // headers: { + // Authorization: `Bearer ${res.access_token}` + // } + // }); + + const res3 = ( + await request.get("https://api.netlify.com/api/v1/accounts", { + headers: { + Authorization: `Bearer ${res.access_token}` + } + }) + ).data; + + const accountId = res3[0].id; + + return { + accessToken: res.access_token, + refreshToken: res.refresh_token, + accountId + }; +}; + +const exchangeCodeGithub = async ({ code }: { code: string }) => { + const appCfg = getConfig(); + if (!appCfg.CLIENT_ID_GITHUB || !appCfg.CLIENT_SECRET_GITHUB) { + throw new BadRequestError({ message: "Missing client id and client secret" }); + } + + const res: ExchangeCodeGithubResponse = ( + await request.get(IntegrationUrls.GITHUB_TOKEN_URL, { + params: { + client_id: appCfg.CLIENT_ID_GITHUB, + client_secret: appCfg.CLIENT_SECRET_GITHUB, + code, + redirect_uri: `${appCfg.SITE_URL}/integrations/github/oauth2/callback` + }, + headers: { + Accept: "application/json", + "Accept-Encoding": "application/json" + } + }) + ).data; + + return { + accessToken: res.access_token, + refreshToken: null, + accessExpiresAt: null + }; +}; + +/** + * Return [accessToken], [accessExpiresAt], and [refreshToken] for Gitlab + * code-token exchange + */ +const exchangeCodeGitlab = async ({ code, url }: { code: string; url?: string }) => { + const accessExpiresAt = new Date(); + const appCfg = getConfig(); + if (!appCfg.CLIENT_ID_GITLAB || !appCfg.CLIENT_SECRET_GITLAB) { + throw new BadRequestError({ message: "Missing client id and client secret" }); + } + + const res: ExchangeCodeGitlabResponse = ( + await request.post( + url ? `${url}/oauth/token` : IntegrationUrls.GITLAB_TOKEN_URL, + new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: appCfg.CLIENT_ID_GITLAB, + client_secret: appCfg.CLIENT_SECRET_GITLAB, + redirect_uri: `${appCfg.SITE_URL}/integrations/gitlab/oauth2/callback` + } as any), + { + headers: { + "Accept-Encoding": "application/json" + } + } + ) + ).data; + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + res.expires_in); + + return { + accessToken: res.access_token, + refreshToken: res.refresh_token, + accessExpiresAt, + url + }; +}; + +/** + * Return [accessToken], [accessExpiresAt], and [refreshToken] for BitBucket + * code-token exchange + */ +const exchangeCodeBitBucket = async ({ code }: { code: string }) => { + const accessExpiresAt = new Date(); + const appCfg = getConfig(); + if (!appCfg.CLIENT_SECRET_BITBUCKET || !appCfg.CLIENT_ID_BITBUCKET) { + throw new BadRequestError({ message: "Missing client id and client secret" }); + } + + const res: ExchangeCodeBitBucketResponse = ( + await request.post( + IntegrationUrls.BITBUCKET_TOKEN_URL, + new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: appCfg.CLIENT_ID_BITBUCKET, + client_secret: appCfg.CLIENT_SECRET_BITBUCKET, + redirect_uri: `${appCfg.SITE_URL}/integrations/bitbucket/oauth2/callback` + } as any), + { + headers: { + "Accept-Encoding": "application/json" + } + } + ) + ).data; + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + res.expires_in); + + return { + accessToken: res.access_token, + refreshToken: res.refresh_token, + accessExpiresAt + }; +}; + +type TExchangeReturn = { + accessToken: string; + refreshToken?: string | null; + accessExpiresAt?: Date | null; + url?: string; + teamId?: string; + accountId?: string; +}; + +/** + * Return [accessToken], [accessExpiresAt], and [refreshToken] for OAuth2 + * code-token exchange for integration named [integration] + */ +export const exchangeCode = async ({ + integration, + code, + url +}: { + integration: string; + code: string; + url?: string; +}): Promise => { + switch (integration) { + case Integrations.GCP_SECRET_MANAGER: + return exchangeCodeGCP({ + code + }); + case Integrations.AZURE_KEY_VAULT: + return exchangeCodeAzure({ + code + }); + case Integrations.HEROKU: + return exchangeCodeHeroku({ + code + }); + case Integrations.VERCEL: + return exchangeCodeVercel({ + code + }); + case Integrations.NETLIFY: + return exchangeCodeNetlify({ + code + }); + case Integrations.GITHUB: + return exchangeCodeGithub({ + code + }); + case Integrations.GITLAB: + return exchangeCodeGitlab({ + code, + url + }); + case Integrations.BITBUCKET: + return exchangeCodeBitBucket({ + code + }); + default: + throw new BadRequestError({ message: "Unknown integration" }); + } +}; + +type RefreshTokenAzureResponse = { + token_type: string; + scope: string; + expires_in: number; + ext_expires_in: 4871; + access_token: string; + refresh_token: string; +}; + +type RefreshTokenHerokuResponse = { + access_token: string; + expires_in: number; + refresh_token: string; + token_type: string; + user_id: string; +}; + +type RefreshTokenGitLabResponse = { + token_type: string; + scope: string; + expires_in: number; + access_token: string; + refresh_token: string; + created_at: number; +}; + +type RefreshTokenBitBucketResponse = { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scopes: string; + state: string; +}; + +type ServiceAccountAccessTokenGCPSecretManagerResponse = { + access_token: string; + expires_in: number; + token_type: string; +}; + +type RefreshTokenGCPSecretManagerResponse = { + access_token: string; + expires_in: number; + scope: string; + token_type: string; +}; + +/** + * Return new access token by exchanging refresh token [refreshToken] for the + * Azure integration + */ +const exchangeRefreshAzure = async ({ refreshToken }: { refreshToken: string }) => { + const accessExpiresAt = new Date(); + const appCfg = getConfig(); + if (!appCfg.CLIENT_ID_AZURE || !appCfg.CLIENT_SECRET_AZURE) { + throw new BadRequestError({ message: "Missing client id and client secret" }); + } + + const { data }: { data: RefreshTokenAzureResponse } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL, + new URLSearchParams({ + client_id: appCfg.CLIENT_ID_AZURE, + scope: "openid offline_access", + refresh_token: refreshToken, + grant_type: "refresh_token", + client_secret: appCfg.CLIENT_SECRET_AZURE + }) + ); + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in); + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + accessExpiresAt + }; +}; + +/** + * Return new access token by exchanging refresh token [refreshToken] for the + * Heroku integration + */ +const exchangeRefreshHeroku = async ({ refreshToken }: { refreshToken: string }) => { + const accessExpiresAt = new Date(); + const appCfg = getConfig(); + if (!appCfg.CLIENT_SECRET_HEROKU) { + throw new BadRequestError({ message: "Missing client id and client secret" }); + } + const { + data + }: { + data: RefreshTokenHerokuResponse; + } = await request.post( + IntegrationUrls.HEROKU_TOKEN_URL, + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_secret: appCfg.CLIENT_SECRET_HEROKU + }) + ); + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in); + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + accessExpiresAt + }; +}; + +/** + * Return new access token by exchanging refresh token [refreshToken] for the + * GitLab integration + * @param {Object} obj + * @param {String} obj.refreshToken - refresh token to use to get new access token for GitLab + * @returns + */ +const exchangeRefreshGitLab = async ({ + refreshToken, + url +}: { + url?: string | null; + refreshToken: string; +}) => { + const accessExpiresAt = new Date(); + const appCfg = getConfig(); + if (!appCfg.CLIENT_ID_GITLAB || !appCfg.CLIENT_SECRET_GITLAB) { + throw new BadRequestError({ message: "Missing client id and client secret" }); + } + const { + data + }: { + data: RefreshTokenGitLabResponse; + } = await request.post( + url ? `${url}/oauth/token` : IntegrationUrls.GITLAB_TOKEN_URL, + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: appCfg.CLIENT_ID_GITLAB, + client_secret: appCfg.CLIENT_SECRET_GITLAB, + redirect_uri: `${appCfg.SITE_URL}/integrations/gitlab/oauth2/callback` + }), + { + headers: { + "Accept-Encoding": "application/json" + } + } + ); + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in); + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + accessExpiresAt + }; +}; + +/** + * Return new access token by exchanging refresh token [refreshToken] for the + * BitBucket integration + */ +const exchangeRefreshBitBucket = async ({ refreshToken }: { refreshToken: string }) => { + const accessExpiresAt = new Date(); + const appCfg = getConfig(); + if (!appCfg.CLIENT_SECRET_BITBUCKET || !appCfg.CLIENT_ID_BITBUCKET) { + throw new BadRequestError({ message: "Missing client id and client secret" }); + } + const { + data + }: { + data: RefreshTokenBitBucketResponse; + } = await request.post( + IntegrationUrls.BITBUCKET_TOKEN_URL, + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: appCfg.CLIENT_ID_BITBUCKET, + client_secret: appCfg.CLIENT_SECRET_BITBUCKET, + redirect_uri: `${appCfg.SITE_URL}/integrations/bitbucket/oauth2/callback` + } as any), + { + headers: { + "Accept-Encoding": "application/json" + } + } + ); + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in); + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + accessExpiresAt + }; +}; + +/** + * Return new access token by exchanging refresh token [refreshToken] for the + * GCP Secret Manager integration + */ +const exchangeRefreshGCPSecretManager = async ({ + refreshToken, + metadata = {} +}: { + metadata?: Record; + refreshToken: string; +}) => { + const accessExpiresAt = new Date(); + + if (metadata?.authMethod === "serviceAccount") { + const serviceAccount = JSON.parse(refreshToken); + + const payload = { + iss: serviceAccount.client_email, + aud: serviceAccount.token_uri, + scope: IntegrationUrls.GCP_CLOUD_PLATFORM_SCOPE, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600 + }; + + const token = jwt.sign(payload, serviceAccount.private_key, { algorithm: "RS256" }); + + const { data }: { data: ServiceAccountAccessTokenGCPSecretManagerResponse } = + await request.post( + IntegrationUrls.GCP_TOKEN_URL, + new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: token + }).toString(), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded" + } + } + ); + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in); + + return { + accessToken: data.access_token, + refreshToken, + accessExpiresAt + }; + } + + const appCfg = getConfig(); + if (!appCfg.CLIENT_SECRET_GCP_SECRET_MANAGER || !appCfg.CLIENT_ID_GCP_SECRET_MANAGER) { + throw new BadRequestError({ message: "Missing client id and client secret" }); + } + const { data }: { data: RefreshTokenGCPSecretManagerResponse } = await request.post( + IntegrationUrls.GCP_TOKEN_URL, + new URLSearchParams({ + client_id: appCfg.CLIENT_ID_GCP_SECRET_MANAGER, + client_secret: appCfg.CLIENT_SECRET_GCP_SECRET_MANAGER, + refresh_token: refreshToken, + grant_type: "refresh_token" + } as any) + ); + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in); + + return { + accessToken: data.access_token, + refreshToken, + accessExpiresAt + }; +}; + +/** + * Return new access token by exchanging refresh token [refreshToken] for integration + */ +export const exchangeRefresh = async ( + integration: string, + refreshToken: string, + url?: string | null, + metadata?: Record +): Promise<{ + accessToken: string; + refreshToken: string; + accessExpiresAt: Date; +}> => { + switch (integration) { + case Integrations.AZURE_KEY_VAULT: + return exchangeRefreshAzure({ + refreshToken + }); + case Integrations.HEROKU: + return exchangeRefreshHeroku({ + refreshToken + }); + case Integrations.GITLAB: + return exchangeRefreshGitLab({ + refreshToken, + url + }); + case Integrations.BITBUCKET: + return exchangeRefreshBitBucket({ + refreshToken + }); + case Integrations.GCP_SECRET_MANAGER: + return exchangeRefreshGCPSecretManager({ + refreshToken, + metadata + }); + default: + throw new Error("Failed to exchange token for incompatible integration"); + } +}; diff --git a/backend-pg/src/services/integration/integration-dal.ts b/backend-pg/src/services/integration/integration-dal.ts new file mode 100644 index 000000000..87faff272 --- /dev/null +++ b/backend-pg/src/services/integration/integration-dal.ts @@ -0,0 +1,63 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName,TIntegrations } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TIntegrationDalFactory = ReturnType; + +export const integrationDalFactory = (db: TDbClient) => { + const integrationOrm = ormify(db, TableName.Integration); + + const integrationFindQuery = (tx: Knex, filter: Partial) => + tx(TableName.Integration) + .where(filter) + .select(tx.ref("name").withSchema(TableName.Environment).as("envName")) + .select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug")) + .select(tx.ref("id").withSchema(TableName.Environment).as("envId")) + .select(tx.ref("projectId").withSchema(TableName.Environment)) + .select(selectAllTableCols(TableName.Integration)); + + const find = async (filter: Partial, tx?: Knex) => { + try { + const docs = await integrationFindQuery(tx || db, filter); + return docs.map(({ envId, envSlug, envName, ...el }) => ({ + ...el, + environment: { + id: envId, + slug: envSlug, + name: envName + } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find by id integrations" }); + } + }; + + const findOne = async (filter: Partial, tx?: Knex) => { + try { + const doc = await integrationFindQuery(tx || db, filter).first(); + if (!doc) return; + + const { envName: name, envSlug: slug, envId: id, ...el } = doc; + return { ...el, environment: { id, name, slug } }; + } catch (error) { + throw new DatabaseError({ error, name: "Find one integrations" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const doc = await integrationFindQuery(tx || db, { id }).first(); + if (!doc) return; + + const { envName: name, envSlug: slug, envId, ...el } = doc; + return { ...el, environment: { id: envId, name, slug } }; + } catch (error) { + throw new DatabaseError({ error, name: "Find by id integrations" }); + } + }; + + return { ...integrationOrm, find, findOne, findById }; +}; diff --git a/backend-pg/src/services/integration/integration-service.ts b/backend-pg/src/services/integration/integration-service.ts new file mode 100644 index 000000000..90025b462 --- /dev/null +++ b/backend-pg/src/services/integration/integration-service.ts @@ -0,0 +1,160 @@ +import { ForbiddenError } from "@casl/ability"; + +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 { TIntegrationAuthDalFactory } from "../integration-auth/integration-auth-dal"; +import { TSecretFolderDalFactory } from "../secret-folder/secret-folder-dal"; +import { TIntegrationDalFactory } from "./integration-dal"; +import { + TCreateIntegrationDTO, + TDeleteIntegrationDTO, + TUpdateIntegrationDTO +} from "./integration-types"; + +type TIntegrationServiceFactoryDep = { + integrationDal: TIntegrationDalFactory; + integrationAuthDal: TIntegrationAuthDalFactory; + folderDal: Pick; + permissionService: Pick; +}; + +export type TIntegrationServiceFactory = ReturnType; + +export const integrationServiceFactory = ({ + integrationDal, + integrationAuthDal, + folderDal, + permissionService +}: TIntegrationServiceFactoryDep) => { + const createIntegration = async ({ + app, + actor, + path, + appId, + owner, + scope, + actorId, + region, + isActive, + metadata, + secretPath, + targetService, + targetServiceId, + integrationAuthId, + sourceEnvironment, + targetEnvironment, + targetEnvironmentId + }: TCreateIntegrationDTO) => { + const integrationAuth = await integrationAuthDal.findById(integrationAuthId); + if (!integrationAuth) throw new BadRequestError({ message: "Integration auth not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Integrations + ); + + const folder = await folderDal.findBySecretPath( + integrationAuth.projectId, + sourceEnvironment, + secretPath + ); + if (!folder) throw new BadRequestError({ message: "Folder path not found" }); + + const integration = await integrationDal.create({ + envId: folder.envId, + secretPath, + isActive, + integrationAuthId, + targetEnvironmentId, + targetEnvironment, + targetServiceId, + targetService, + metadata, + region, + scope, + owner, + appId, + path, + app, + integration: integrationAuth.integration + }); + + // TODO(akhilmhdh-pg): audit log + return integration; + }; + + const updateIntegration = async ({ + actorId, + actor, + targetEnvironment, + app, + id, + appId, + owner, + isActive, + environment, + secretPath + }: TUpdateIntegrationDTO) => { + const integration = await integrationDal.findById(id); + if (!integration) throw new BadRequestError({ message: "Integration auth not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integration.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Integrations + ); + + const folder = await folderDal.findBySecretPath(integration.projectId, environment, secretPath); + if (!folder) throw new BadRequestError({ message: "Folder path not found" }); + + const updatedIntegration = await integrationDal.updateById(id, { + envId: folder.envId, + isActive, + app, + appId, + targetEnvironment, + owner, + secretPath + }); + + return updatedIntegration; + }; + + const deleteIntegration = async ({ actorId, id, actor }: TDeleteIntegrationDTO) => { + const integration = await integrationDal.findById(id); + if (!integration) throw new BadRequestError({ message: "Integration auth not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integration.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.Integrations + ); + + const deletedIntegration = await integrationDal.deleteById(id); + return deletedIntegration; + }; + + return { + createIntegration, + updateIntegration, + deleteIntegration + }; +}; diff --git a/backend-pg/src/services/integration/integration-types.ts b/backend-pg/src/services/integration/integration-types.ts new file mode 100644 index 000000000..8f54c4fdb --- /dev/null +++ b/backend-pg/src/services/integration/integration-types.ts @@ -0,0 +1,41 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateIntegrationDTO = { + secretPath: string; + integrationAuthId: string; + app?: string; + isActive: boolean; + appId?: string; + sourceEnvironment: string; + targetEnvironment?: string; + targetEnvironmentId?: string; + targetService?: string; + targetServiceId?: string; + owner?: string; + path?: string; + region?: string; + scope?: string; + metadata?: { + secretPrefix?: string; + secretSuffix?: string; + secretGCPLabel?: { + labelName: string; + labelValue: string; + }; + }; +} & Omit; + +export type TUpdateIntegrationDTO = { + id: string; + app: string; + appId: string; + isActive?: boolean; + secretPath: string; + targetEnvironment: string; + owner: string; + environment: string; +} & Omit; + +export type TDeleteIntegrationDTO = { + id: string; +} & Omit; diff --git a/backend-pg/src/services/project-bot/project-bot-dal.ts b/backend-pg/src/services/project-bot/project-bot-dal.ts new file mode 100644 index 000000000..e829f19ca --- /dev/null +++ b/backend-pg/src/services/project-bot/project-bot-dal.ts @@ -0,0 +1,35 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName,TProjectBots } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TProjectBotDalFactory = ReturnType; + +export const projectBotDalFactory = (db: TDbClient) => { + const projectBotOrm = ormify(db, TableName.ProjectBot); + + const findOne = async (filter: Partial, tx?: Knex) => { + try { + const bot = await (tx || db)(TableName.ProjectBot) + .where(filter) + .join(TableName.Users, `${TableName.ProjectBot}.senderId`, `${TableName.Users}.id`) + .join( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .select(selectAllTableCols(TableName.ProjectBot)) + .select(db.ref("publicKey").withSchema(TableName.UserEncryptionKey).as("senderPubKey")) + .first(); + if (!bot) return bot; + const { senderPubKey, ...el } = bot; + return { ...el, sender: { publicKey: senderPubKey } }; + } catch (error) { + throw new DatabaseError({ error, name: "Find on project bot" }); + } + }; + + return { ...projectBotOrm, findOne }; +}; diff --git a/backend-pg/src/services/project-bot/project-bot-service.ts b/backend-pg/src/services/project-bot/project-bot-service.ts new file mode 100644 index 000000000..8fb1a75c0 --- /dev/null +++ b/backend-pg/src/services/project-bot/project-bot-service.ts @@ -0,0 +1,183 @@ +import { ForbiddenError } from "@casl/ability"; + +import { SecretEncryptionAlgo, SecretKeyEncoding } 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 { + decryptAsymmetric, + decryptSymmetric, + decryptSymmetric128BitHexKeyUTF8, + encryptSymmetric, + encryptSymmetric128BitHexKeyUTF8, + generateAsymmetricKeyPair +} from "@app/lib/crypto"; +import { BadRequestError } from "@app/lib/errors"; +import { TProjectPermission } from "@app/lib/types"; + +import { TProjectBotDalFactory } from "./project-bot-dal"; +import { TSetActiveStateDTO } from "./project-bot-types"; + +type TProjectBotServiceFactoryDep = { + permissionService: Pick; + projectBotDal: TProjectBotDalFactory; +}; + +export type TProjectBotServiceFactory = ReturnType; + +export const projectBotServiceFactory = ({ + projectBotDal, + permissionService +}: TProjectBotServiceFactoryDep) => { + const getBotKey = async (projectId: string) => { + const appCfg = getConfig(); + const encryptionKey = appCfg.ENCRYPTION_KEY; + const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY; + + const bot = await projectBotDal.findOne({ projectId }); + if (!bot) throw new BadRequestError({ message: "failed to find bot key" }); + if (!bot.isActive) throw new BadRequestError({ message: "Bot is not active" }); + if (!bot.encryptedProjectKeyNonce || !bot.encryptedProjectKey) + throw new BadRequestError({ message: "Encryption key missing" }); + + if (rootEncryptionKey && bot.keyEncoding === SecretKeyEncoding.BASE64) { + const privateKeyBot = decryptSymmetric({ + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey, + key: rootEncryptionKey + }); + return decryptAsymmetric({ + ciphertext: bot.encryptedProjectKey, + privateKey: privateKeyBot, + nonce: bot.encryptedProjectKeyNonce, + publicKey: bot.sender.publicKey + }); + } + if (encryptionKey && bot.keyEncoding === SecretKeyEncoding.UTF8) { + const privateKeyBot = decryptSymmetric128BitHexKeyUTF8({ + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey, + key: encryptionKey + }); + return decryptAsymmetric({ + ciphertext: bot.encryptedProjectKey, + privateKey: privateKeyBot, + nonce: bot.encryptedProjectKeyNonce, + publicKey: bot.sender.publicKey + }); + } + + throw new BadRequestError({ + message: "Failed to obtain bot copy of workspace key needed for operation" + }); + }; + + const findBotByProjectId = async ({ actorId, actor, projectId }: TProjectPermission) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + const appCfg = getConfig(); + + const bot = await projectBotDal.transaction(async (tx) => { + const doc = await projectBotDal.findOne({ projectId }, tx); + if (doc) return doc; + + const { publicKey, privateKey } = generateAsymmetricKeyPair(); + if (appCfg.ROOT_ENCRYPTION_KEY) { + const { iv, tag, ciphertext } = encryptSymmetric(privateKey, appCfg.ROOT_ENCRYPTION_KEY); + return projectBotDal.create( + { + name: "Infisical Bot", + projectId, + tag, + iv, + encryptedPrivateKey: ciphertext, + isActive: false, + publicKey, + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.BASE64 + }, + tx + ); + } + if (appCfg.ENCRYPTION_KEY) { + const { iv, tag, ciphertext } = encryptSymmetric128BitHexKeyUTF8( + privateKey, + appCfg.ENCRYPTION_KEY + ); + return projectBotDal.create( + { + name: "Infisical Bot", + projectId, + tag, + iv, + encryptedPrivateKey: ciphertext, + isActive: false, + publicKey, + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8 + }, + tx + ); + } + throw new BadRequestError({ message: "Failed to create bot due to missing encryption key" }); + }); + return bot; + }; + + const setBotActiveState = async ({ + actor, + botId, + botKey, + actorId, + isActive + }: TSetActiveStateDTO) => { + const bot = await projectBotDal.findOne({ id: botId }); + if (!bot) throw new BadRequestError({ message: "Bot not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + bot.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Integrations + ); + + if (isActive) { + if (!botKey?.nonce || !botKey?.encryptionKey) { + throw new BadRequestError({ message: "Failed to set bot active - missing bot key" }); + } + const doc = await projectBotDal.updateById(botId, { + isActive: true, + encryptedProjectKey: botKey.encryptionKey, + encryptedProjectKeyNonce: botKey.nonce, + senderId: actorId + }); + if (!doc) throw new BadRequestError({ message: "Failed to update bot active state" }); + return doc; + } + + const doc = await projectBotDal.updateById(botId, { + isActive: false, + encryptedProjectKey: null, + encryptedProjectKeyNonce: null + }); + if (!doc) throw new BadRequestError({ message: "Failed to update bot active state" }); + return doc; + }; + + return { + findBotByProjectId, + setBotActiveState, + getBotKey + }; +}; diff --git a/backend-pg/src/services/project-bot/project-bot-types.ts b/backend-pg/src/services/project-bot/project-bot-types.ts new file mode 100644 index 000000000..e983ee39f --- /dev/null +++ b/backend-pg/src/services/project-bot/project-bot-types.ts @@ -0,0 +1,10 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TSetActiveStateDTO = { + isActive: boolean; + botKey?: { + nonce?: string; + encryptionKey?: string; + }; + botId: string; +} & Omit; diff --git a/backend-pg/src/services/secret-folder/secret-folder-dal.ts b/backend-pg/src/services/secret-folder/secret-folder-dal.ts index be3bc43b4..c425f2d63 100644 --- a/backend-pg/src/services/secret-folder/secret-folder-dal.ts +++ b/backend-pg/src/services/secret-folder/secret-folder-dal.ts @@ -37,7 +37,7 @@ const sqlFindFolderByPathQuery = ( depth: 1, path: db.raw("'/'") }) - .select(selectAllTableCols(db, TableName.SecretFolder)) + .select(selectAllTableCols(TableName.SecretFolder)) .from(TableName.SecretFolder) .join( TableName.Environment, @@ -60,7 +60,7 @@ const sqlFindFolderByPathQuery = ( "CONCAT((CASE WHEN parent.path = '/' THEN '' ELSE parent.path END),'/', secret_folders.name)" ) }) - .select(selectAllTableCols(db, TableName.SecretFolder)) + .select(selectAllTableCols(TableName.SecretFolder)) .whereRaw( `depth = array_position(ARRAY[${pathSegments .map(() => "?") diff --git a/backend-pg/src/services/secret-folder/secret-folder-service.ts b/backend-pg/src/services/secret-folder/secret-folder-service.ts index 14abc490a..d666c7f9e 100644 --- a/backend-pg/src/services/secret-folder/secret-folder-service.ts +++ b/backend-pg/src/services/secret-folder/secret-folder-service.ts @@ -8,7 +8,7 @@ import { import { BadRequestError } from "@app/lib/errors"; import { TProjectEnvDalFactory } from "../project-env/project-env-dal"; -import { ROOT_FOLDER_NAME, TSecretFolderDalFactory } from "./secret-folder-dal"; +import { TSecretFolderDalFactory } from "./secret-folder-dal"; import { TCreateFolderDTO, TDeleteFolderDTO,