diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 6cc850847..85a19b676 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -13,6 +13,7 @@ import { TCertificateEstServiceFactory } from "@app/ee/services/certificate-est/ import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; import { TExternalKmsServiceFactory } from "@app/ee/services/external-kms/external-kms-service"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TGroupServiceFactory } from "@app/ee/services/group/group-service"; import { TIdentityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { TIdentityProjectAdditionalPrivilegeV2ServiceFactory } from "@app/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service"; @@ -95,7 +96,6 @@ import { TUserServiceFactory } from "@app/services/user/user-service"; import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service"; import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service"; -import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; declare module "@fastify/request-context" { interface RequestContextData { diff --git a/backend/src/db/migrations/20250212191958_create-gateway.ts b/backend/src/db/migrations/20250212191958_create-gateway.ts index ee9ef0ef7..87f58bc78 100644 --- a/backend/src/db/migrations/20250212191958_create-gateway.ts +++ b/backend/src/db/migrations/20250212191958_create-gateway.ts @@ -66,6 +66,14 @@ export async function up(knex: Knex): Promise { await createOnUpdateTrigger(knex, TableName.Gateway); } + + if (await knex.schema.hasTable(TableName.DynamicSecret)) { + const doesGatewayColExist = await knex.schema.hasColumn(TableName.DynamicSecret, "gatewayId"); + await knex.schema.alterTable(TableName.DynamicSecret, (t) => { + // not setting a foreign constraint so that cascade effects are not triggered + if (!doesGatewayColExist) t.uuid("gatewayId"); + }); + } } export async function down(knex: Knex): Promise { @@ -74,4 +82,11 @@ export async function down(knex: Knex): Promise { await knex.schema.dropTableIfExists(TableName.OrgGatewayConfig); await dropOnUpdateTrigger(knex, TableName.OrgGatewayConfig); + + if (await knex.schema.hasTable(TableName.DynamicSecret)) { + const doesGatewayColExist = await knex.schema.hasColumn(TableName.DynamicSecret, "gatewayId"); + await knex.schema.alterTable(TableName.DynamicSecret, (t) => { + if (doesGatewayColExist) t.dropColumn("gatewayId"); + }); + } } diff --git a/backend/src/db/schemas/dynamic-secrets.ts b/backend/src/db/schemas/dynamic-secrets.ts index eaddea8fe..b60683e85 100644 --- a/backend/src/db/schemas/dynamic-secrets.ts +++ b/backend/src/db/schemas/dynamic-secrets.ts @@ -26,7 +26,8 @@ export const DynamicSecretsSchema = z.object({ statusDetails: z.string().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), - encryptedInput: zodBuffer + encryptedInput: zodBuffer, + gatewayId: z.string().uuid().nullable().optional() }); export type TDynamicSecrets = z.infer; diff --git a/backend/src/ee/routes/v1/gateway-router.ts b/backend/src/ee/routes/v1/gateway-router.ts index 4282d41e0..332997619 100644 --- a/backend/src/ee/routes/v1/gateway-router.ts +++ b/backend/src/ee/routes/v1/gateway-router.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { GatewaysSchema } from "@app/db/schemas"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -37,7 +38,8 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const relayDetails = await server.services.gateway.getGatewayRelayDetails( req.permission.id, - req.permission.orgId + req.permission.orgId, + req.permission.authMethod ); return relayDetails; } @@ -67,7 +69,8 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => { const gatewayCertificates = await server.services.gateway.exchangeAllocatedRelayAddress({ identityOrg: req.permission.orgId, identityId: req.permission.id, - relayAddress: req.body.relayAddress + relayAddress: req.body.relayAddress, + identityOrgAuthMethod: req.permission.authMethod }); return gatewayCertificates; } @@ -80,6 +83,9 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + querystring: z.object({ + projectId: z.string().optional() + }), response: { 200: z.object({ gateways: SanitizedGatewaySchema.extend({ @@ -93,6 +99,14 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN, AuthMode.JWT]), handler: async (req) => { + if (req.query.projectId) { + const gateways = await server.services.gateway.getProjectGateways({ + projectId: req.query.projectId, + projectPermission: req.permission + }); + return { gateways }; + } + const gateways = await server.services.gateway.listGateways({ orgPermission: req.permission }); @@ -131,11 +145,41 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "PATCH", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string() + }), + body: z.object({ + name: slugSchema({ field: "name" }).optional() + }), + response: { + 200: z.object({ + gateway: SanitizedGatewaySchema + }) + } + }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN, AuthMode.JWT]), + handler: async (req) => { + const gateway = await server.services.gateway.updateGatewayById({ + orgPermission: req.permission, + id: req.params.id, + name: req.body.name + }); + return { gateway }; + } + }); + server.route({ method: "DELETE", url: "/:id", config: { - rateLimit: readLimit + rateLimit: writeLimit }, schema: { params: z.object({ diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts index 04aeb3950..ac6a66d3b 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts @@ -1,20 +1,31 @@ +import crypto from "node:crypto"; + import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { getDbConnectionHost } from "@app/lib/knex"; -export const verifyHostInputValidity = (host: string) => { +export const verifyHostInputValidity = (host: string, isGateway = false) => { const appCfg = getConfig(); const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI); + // no need for validation when it's dev + if (appCfg.NODE_ENV === "development") return; + + if (host === "host.docker.internal") throw new BadRequestError({ message: "Invalid db host" }); if ( appCfg.isCloud && + !isGateway && // localhost // internal ips - (host === "host.docker.internal" || host.match(/^10\.\d+\.\d+\.\d+/) || host.match(/^192\.168\.\d+\.\d+/)) + (host.match(/^10\.\d+\.\d+\.\d+/) || host.match(/^192\.168\.\d+\.\d+/)) ) throw new BadRequestError({ message: "Invalid db host" }); - if (host === "localhost" || host === "127.0.0.1" || dbHost === host) { + if ( + host === "localhost" || + host === "127.0.0.1" || + crypto.timingSafeEqual(Buffer.from(dbHost || ""), Buffer.from(host)) + ) { throw new BadRequestError({ message: "Invalid db host" }); } }; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index eac5e2ecf..5579bc9af 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -16,6 +16,8 @@ import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-fold import { TDynamicSecretLeaseDALFactory } from "../dynamic-secret-lease/dynamic-secret-lease-dal"; import { TDynamicSecretLeaseQueueServiceFactory } from "../dynamic-secret-lease/dynamic-secret-lease-queue"; +import { TGatewayDALFactory } from "../gateway/gateway-dal"; +import { TOrgGatewayConfigDALFactory } from "../gateway/org-gateway-config-dal"; import { TDynamicSecretDALFactory } from "./dynamic-secret-dal"; import { DynamicSecretStatus, @@ -44,6 +46,8 @@ type TDynamicSecretServiceFactoryDep = { projectDAL: Pick; permissionService: Pick; kmsService: Pick; + gatewayDAL: Pick; + orgGatewayConfigDAL: Pick; }; export type TDynamicSecretServiceFactory = ReturnType; @@ -57,7 +61,9 @@ export const dynamicSecretServiceFactory = ({ permissionService, dynamicSecretQueueService, projectDAL, - kmsService + kmsService, + gatewayDAL, + orgGatewayConfigDAL }: TDynamicSecretServiceFactoryDep) => { const create = async ({ path, @@ -108,6 +114,23 @@ export const dynamicSecretServiceFactory = ({ const selectedProvider = dynamicSecretProviders[provider.type]; const inputs = await selectedProvider.validateProviderInputs(provider.inputs); + let selectedGatewayId: string | null = null; + if (inputs && typeof inputs === "object" && "gatewayId" in inputs && inputs.gatewayId) { + const gatewayId = inputs.gatewayId as string; + + const orgGateway = await orgGatewayConfigDAL.findOne({ orgId: actorOrgId }); + if (!orgGateway) + throw new NotFoundError({ + message: `Gateway with ${gatewayId} not found` + }); + const gateway = await gatewayDAL.findOne({ id: gatewayId, orgGatewayRootCaId: orgGateway.id }); + if (!gateway) + throw new NotFoundError({ + message: `Gateway with ${gatewayId} not found` + }); + selectedGatewayId = gateway.id; + } + const isConnected = await selectedProvider.validateConnection(provider.inputs); if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); @@ -123,7 +146,8 @@ export const dynamicSecretServiceFactory = ({ maxTTL, defaultTTL, folderId: folder.id, - name + name, + gatewayId: selectedGatewayId }); return dynamicSecretCfg; }; @@ -195,6 +219,23 @@ export const dynamicSecretServiceFactory = ({ const newInput = { ...decryptedStoredInput, ...(inputs || {}) }; const updatedInput = await selectedProvider.validateProviderInputs(newInput); + let selectedGatewayId: string | null = null; + if (updatedInput && typeof updatedInput === "object" && "gatewayId" in updatedInput && updatedInput?.gatewayId) { + const gatewayId = updatedInput.gatewayId as string; + + const orgGateway = await orgGatewayConfigDAL.findOne({ orgId: actorOrgId }); + if (!orgGateway) + throw new NotFoundError({ + message: `Gateway with ${gatewayId} not found` + }); + const gateway = await gatewayDAL.findOne({ id: gatewayId, orgGatewayRootCaId: orgGateway.id }); + if (!gateway) + throw new NotFoundError({ + message: `Gateway with ${gatewayId} not found` + }); + selectedGatewayId = gateway.id; + } + const isConnected = await selectedProvider.validateConnection(newInput); if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); @@ -204,7 +245,8 @@ export const dynamicSecretServiceFactory = ({ defaultTTL, name: newName ?? name, status: null, - statusDetails: null + statusDetails: null, + gatewayId: selectedGatewayId }); return updatedDynamicCfg; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 9ed757a37..faa671980 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -1,5 +1,6 @@ import { SnowflakeProvider } from "@app/ee/services/dynamic-secret/providers/snowflake"; +import { TGatewayServiceFactory } from "../../gateway/gateway-service"; import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache"; import { AwsIamProvider } from "./aws-iam"; import { AzureEntraIDProvider } from "./azure-entra-id"; @@ -16,8 +17,14 @@ import { SapHanaProvider } from "./sap-hana"; import { SqlDatabaseProvider } from "./sql-database"; import { TotpProvider } from "./totp"; -export const buildDynamicSecretProviders = (): Record => ({ - [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider(), +type TBuildDynamicSecretProviderDTO = { + gatewayService: Pick; +}; + +export const buildDynamicSecretProviders = ({ + gatewayService +}: TBuildDynamicSecretProviderDTO): Record => ({ + [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider({ gatewayService }), [DynamicSecretProviders.Cassandra]: CassandraProvider(), [DynamicSecretProviders.AwsIam]: AwsIamProvider(), [DynamicSecretProviders.Redis]: RedisDatabaseProvider(), diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index e8b9e6548..8b742dc54 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -103,7 +103,8 @@ export const DynamicSecretSqlDBSchema = z.object({ creationStatement: z.string().trim(), revocationStatement: z.string().trim(), renewStatement: z.string().trim().optional(), - ca: z.string().optional() + ca: z.string().optional(), + gatewayId: z.string().nullable().optional() }); export const DynamicSecretCassandraSchema = z.object({ diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index a9bfad525..53b16e026 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -3,8 +3,10 @@ import knex from "knex"; import { customAlphabet } from "nanoid"; import { z } from "zod"; +import { withGatewayProxy } from "@app/lib/gateway"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { TGatewayServiceFactory } from "../../gateway/gateway-service"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretSqlDBSchema, SqlProviders, TDynamicProviderFns } from "./models"; @@ -25,10 +27,14 @@ const generateUsername = (provider: SqlProviders) => { return alphaNumericNanoId(32); }; -export const SqlDatabaseProvider = (): TDynamicProviderFns => { +type TSqlDatabaseProviderDTO = { + gatewayService: Pick; +}; + +export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretSqlDBSchema.parseAsync(inputs); - verifyHostInputValidity(providerInputs.host); + verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.gatewayId)); return providerInputs; }; @@ -61,61 +67,107 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => { return db; }; + const gatewayProxyWrapper = async ( + providerInputs: z.infer, + gatewayCallback: (port: number) => Promise + ) => { + const relayDetails = await gatewayService.fnGetGatewayClientTls(providerInputs.gatewayId as string); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + await withGatewayProxy( + async (port) => { + await gatewayCallback(port); + }, + { + targetHost: providerInputs.host, + targetPort: providerInputs.port, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey + } + } + ); + }; + const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const db = await $getClient(providerInputs); - // oracle needs from keyword - const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1"; + let isConnected = false; + const gatewayCallback = async (port = providerInputs.port) => { + const db = await $getClient({ ...providerInputs, port }); + // oracle needs from keyword + const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1"; - const isConnected = await db.raw(testStatement).then(() => true); - await db.destroy(); + isConnected = await db.raw(testStatement).then(() => true); + await db.destroy(); + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } return isConnected; }; const create = async (inputs: unknown, expireAt: number) => { const providerInputs = await validateProviderInputs(inputs); - const db = await $getClient(providerInputs); - const username = generateUsername(providerInputs.client); const password = generatePassword(providerInputs.client); - const { database } = providerInputs; - const expiration = new Date(expireAt).toISOString(); + const gatewayCallback = async (port = providerInputs.port) => { + const db = await $getClient({ ...providerInputs, port }); + const { database } = providerInputs; + const expiration = new Date(expireAt).toISOString(); - const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ - username, - password, - expiration, - database - }); + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password, + expiration, + database + }); - const queries = creationStatement.toString().split(";").filter(Boolean); - await db.transaction(async (tx) => { - for (const query of queries) { - // eslint-disable-next-line - await tx.raw(query); - } - }); - await db.destroy(); + const queries = creationStatement.toString().split(";").filter(Boolean); + await db.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); + await db.destroy(); + }; + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; }; const revoke = async (inputs: unknown, entityId: string) => { const providerInputs = await validateProviderInputs(inputs); - const db = await $getClient(providerInputs); - const username = entityId; const { database } = providerInputs; + const gatewayCallback = async (port = providerInputs.port) => { + const db = await $getClient({ ...providerInputs, port }); + const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, database }); + const queries = revokeStatement.toString().split(";").filter(Boolean); + await db.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); - const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, database }); - const queries = revokeStatement.toString().split(";").filter(Boolean); - await db.transaction(async (tx) => { - for (const query of queries) { - // eslint-disable-next-line - await tx.raw(query); - } - }); - - await db.destroy(); + await db.destroy(); + }; + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } return { entityId: username }; }; @@ -123,28 +175,34 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => { const providerInputs = await validateProviderInputs(inputs); if (!providerInputs.renewStatement) return { entityId }; - const db = await $getClient(providerInputs); + const gatewayCallback = async (port = providerInputs.port) => { + const db = await $getClient({ ...providerInputs, port }); + const expiration = new Date(expireAt).toISOString(); + const { database } = providerInputs; - const expiration = new Date(expireAt).toISOString(); - const { database } = providerInputs; - - const renewStatement = handlebars.compile(providerInputs.renewStatement)({ - username: entityId, - expiration, - database - }); - - if (renewStatement) { - const queries = renewStatement.toString().split(";").filter(Boolean); - await db.transaction(async (tx) => { - for (const query of queries) { - // eslint-disable-next-line - await tx.raw(query); - } + const renewStatement = handlebars.compile(providerInputs.renewStatement)({ + username: entityId, + expiration, + database }); - } - await db.destroy(); + if (renewStatement) { + const queries = renewStatement.toString().split(";").filter(Boolean); + await db.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); + } + + await db.destroy(); + }; + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } return { entityId }; }; diff --git a/backend/src/ee/services/gateway/gateway-dal.ts b/backend/src/ee/services/gateway/gateway-dal.ts index 86a40a3fe..5d0be06bf 100644 --- a/backend/src/ee/services/gateway/gateway-dal.ts +++ b/backend/src/ee/services/gateway/gateway-dal.ts @@ -1,3 +1,5 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; import { TableName, TGateways } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; @@ -29,5 +31,24 @@ export const gatewayDALFactory = (db: TDbClient) => { } }; - return { ...orm, find }; + const findByProjectId = async (projectId: string, tx?: Knex) => { + try { + const query = (tx || db)(TableName.Gateway) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Gateway}.identityId`) + .join( + TableName.IdentityProjectMembership, + `${TableName.Identity}.id`, + `${TableName.IdentityProjectMembership}.identityId` + ) + .select(selectAllTableCols(TableName.Gateway)) + .select(db.ref("name").withSchema(TableName.Identity).as("identityName")) + .where({ [`${TableName.IdentityProjectMembership}.projectId` as "projectId"]: projectId }); + const docs = await query; + return docs.map((el) => ({ ...el, identity: { id: el.identityId, name: el.identityName } })); + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.Gateway}: Find by project id` }); + } + }; + + return { ...orm, find, findByProjectId }; }; diff --git a/backend/src/ee/services/gateway/gateway-service.ts b/backend/src/ee/services/gateway/gateway-service.ts index 330d48d0e..e3d07041e 100644 --- a/backend/src/ee/services/gateway/gateway-service.ts +++ b/backend/src/ee/services/gateway/gateway-service.ts @@ -1,15 +1,16 @@ import crypto from "node:crypto"; -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; -import fs from "fs/promises"; -import path from "path/posix"; +import { z } from "zod"; -import { PgSqlLock } from "@app/keystore/keystore"; +import { ActionProjectType } from "@app/db/schemas"; +import { KeyStorePrefixes, PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { getTurnCredentials } from "@app/lib/turn/credentials"; +import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; import { createSerialNumber, @@ -21,29 +22,41 @@ import { KmsDataKey } from "@app/services/kms/kms-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgGatewayPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; import { TGatewayDALFactory } from "./gateway-dal"; -import { TExchangeAllocatedRelayAddressDTO, TGetGatewayByIdDTO, TListGatewaysDTO } from "./gateway-types"; +import { + TExchangeAllocatedRelayAddressDTO, + TGetGatewayByIdDTO, + TGetProjectGatewayByIdDTO, + TListGatewaysDTO, + TUpdateGatewayByIdDTO +} from "./gateway-types"; import { TOrgGatewayConfigDALFactory } from "./org-gateway-config-dal"; type TGatewayServiceFactoryDep = { gatewayDAL: TGatewayDALFactory; - orgGatewayConfigDAL: Pick; + orgGatewayConfigDAL: Pick; licenseService: Pick; kmsService: Pick; - permissionService: Pick; + permissionService: Pick; + keyStore: Pick; }; export type TGatewayServiceFactory = ReturnType; +const TURN_SERVER_CREDENTIALS_SCHEMA = z.object({ + username: z.string(), + password: z.string() +}); -// TODO(gateway): missing permission check export const gatewayServiceFactory = ({ gatewayDAL, licenseService, kmsService, permissionService, - orgGatewayConfigDAL + orgGatewayConfigDAL, + keyStore }: TGatewayServiceFactoryDep) => { - const $validateOrgAccessToGateway = async (orgId: string) => { + const $validateOrgAccessToGateway = async (orgId: string, actorId: string, actorAuthMethod: ActorAuthMethod) => { if (!licenseService.onPremFeatures.gateway) { throw new BadRequestError({ message: @@ -57,11 +70,20 @@ export const gatewayServiceFactory = ({ "Gateway handshake failed due to organization plan restrictions. Please upgrade your instance to Infisical's Enterprise plan." }); } + const { permission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + actorId, + orgId, + actorAuthMethod, + orgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgGatewayPermissionActions.Create, OrgPermissionSubjects.Gateway); }; - const getGatewayRelayDetails = async (actorId: string, actorOrgId: string) => { + const getGatewayRelayDetails = async (actorId: string, actorOrgId: string, actorAuthMethod: ActorAuthMethod) => { + const TURN_CRED_EXPIRY = 5 * 60; const envCfg = getConfig(); - await $validateOrgAccessToGateway(actorOrgId); + await $validateOrgAccessToGateway(actorOrgId, actorId, actorAuthMethod); if ( !envCfg.GATEWAY_RELAY_AUTH_SECRET || @@ -73,11 +95,25 @@ export const gatewayServiceFactory = ({ message: "Gateway handshake failed due to missing instance config." }); } - // TODO(gateway): keep it in 30mins redis after encryption to avoid multiple credentials spinning up - const { username: turnServerUsername, password: turnServerPassword } = getTurnCredentials( - actorId, - envCfg.GATEWAY_RELAY_AUTH_SECRET - ); + + let turnServerUsername = ""; + let turnServerPassword = ""; + // keep it in redis for 5mins to avoid generating so many credentials + const previousCredential = await keyStore.getItem(KeyStorePrefixes.GatewayIdentityCredential(actorId)); + if (previousCredential) { + const el = await TURN_SERVER_CREDENTIALS_SCHEMA.parseAsync(JSON.parse(previousCredential)); + turnServerUsername = el.username; + turnServerPassword = el.password; + } else { + const el = getTurnCredentials(actorId, envCfg.GATEWAY_RELAY_AUTH_SECRET); + await keyStore.setItemWithExpiry( + KeyStorePrefixes.GatewayIdentityCredential(actorId), + TURN_CRED_EXPIRY, + JSON.stringify({ username: el.username, password: el.password }) + ); + turnServerUsername = el.username; + turnServerPassword = el.password; + } return { turnServerUsername, @@ -91,8 +127,10 @@ export const gatewayServiceFactory = ({ const exchangeAllocatedRelayAddress = async ({ identityId, identityOrg, - relayAddress + relayAddress, + identityOrgAuthMethod }: TExchangeAllocatedRelayAddressDTO) => { + await $validateOrgAccessToGateway(identityOrg, identityId, identityOrgAuthMethod); const { encryptor: orgKmsEncryptor, decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: identityOrg @@ -160,7 +198,7 @@ export const gatewayServiceFactory = ({ const clientCertSerialNumber = createSerialNumber(); const clientCert = await x509.X509CertificateGenerator.create({ serialNumber: clientCertSerialNumber, - subject: "O=infisical,OU=gateway,CN=cloud-client", + subject: `O=${identityOrg},OU=gateway-client,CN=cloud`, issuer: clientCaCert.subject, notAfter: clientCaExpiration, notBefore: clientCaIssuedAt, @@ -214,15 +252,6 @@ export const gatewayServiceFactory = ({ ] }); - await fs.writeFile(path.join(__dirname, "./root-ca-cert"), rootCaCert.toString("pem")); - await fs.writeFile(path.join(__dirname, "./client-ca-cert"), clientCaCert.toString("pem")); - await fs.writeFile(path.join(__dirname, "./gateway-ca-cert"), gatewayCaCert.toString("pem")); - await fs.writeFile(path.join(__dirname, "./client-cert"), clientCert.toString("pem")); - await fs.writeFile( - path.join(__dirname, "./client-key"), - clientSkObj.export({ type: "pkcs8", format: "pem" }) as string - ); - return orgGatewayConfigDAL.create({ orgId: identityOrg, rootCaIssuedAt, @@ -322,7 +351,6 @@ export const gatewayServiceFactory = ({ ), new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.SERVER_AUTH]], true), // san - // TODO(gateway): change this later new x509.SubjectAlternativeNameExtension([{ type: "ip", value: "127.0.0.1" }], false) ]; @@ -340,16 +368,23 @@ export const gatewayServiceFactory = ({ extensions }); + const appCfg = getConfig(); + // just for local development + const formatedRelayAddress = + appCfg.NODE_ENV === "development" ? relayAddress.replace("127.0.0.1", "host.docker.internal") : relayAddress; await gatewayDAL.transaction(async (tx) => { await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.OrgGatewayCertExchange(identityOrg)]); const existingGateway = await gatewayDAL.findOne({ identityId, orgGatewayRootCaId: orgGatewayConfig.id }); + if (existingGateway) { return gatewayDAL.updateById(existingGateway.id, { keyAlgorithm: CertKeyAlgorithm.RSA_2048, issuedAt: certIssuedAt, expiration: certExpireAt, serialNumber, - relayAddress: orgKmsEncryptor({ plainText: Buffer.from(relayAddress) }).cipherTextBlob + relayAddress: orgKmsEncryptor({ + plainText: Buffer.from(formatedRelayAddress) + }).cipherTextBlob }); } @@ -358,7 +393,9 @@ export const gatewayServiceFactory = ({ issuedAt: certIssuedAt, expiration: certExpireAt, serialNumber, - relayAddress: orgKmsEncryptor({ plainText: Buffer.from(relayAddress) }).cipherTextBlob, + relayAddress: orgKmsEncryptor({ + plainText: Buffer.from(formatedRelayAddress) + }).cipherTextBlob, identityId, orgGatewayRootCaId: orgGatewayConfig.id, name: alphaNumericNanoId(8) @@ -410,6 +447,23 @@ export const gatewayServiceFactory = ({ return gateway; }; + const updateGatewayById = async ({ orgPermission, id, name }: TUpdateGatewayByIdDTO) => { + const { permission } = await permissionService.getOrgPermission( + orgPermission.type, + orgPermission.id, + orgPermission.orgId, + orgPermission.authMethod, + orgPermission.orgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgGatewayPermissionActions.Delete, OrgPermissionSubjects.Gateway); + const orgGatewayConfig = await orgGatewayConfigDAL.findOne({ orgId: orgPermission.orgId }); + if (!orgGatewayConfig) throw new NotFoundError({ message: `Gateway with ID ${id} not found.` }); + + const [gateway] = await gatewayDAL.update({ id, orgGatewayRootCaId: orgGatewayConfig.id }, { name }); + if (!gateway) throw new NotFoundError({ message: `Gateway with ID ${id} not found.` }); + return gateway; + }; + const deleteGatewayById = async ({ orgPermission, id }: TGetGatewayByIdDTO) => { const { permission } = await permissionService.getOrgPermission( orgPermission.type, @@ -427,11 +481,78 @@ export const gatewayServiceFactory = ({ return gateway; }; + const getProjectGateways = async ({ projectId, projectPermission }: TGetProjectGatewayByIdDTO) => { + const { permission } = await permissionService.getProjectPermission({ + projectId, + actor: projectPermission.type, + actorId: projectPermission.id, + actorOrgId: projectPermission.orgId, + actorAuthMethod: projectPermission.authMethod, + actionProjectType: ActionProjectType.Any + }); + + const gateways = await gatewayDAL.findByProjectId(projectId); + const allowedGateways = gateways.filter((el) => + permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: el.identityId }) + ) + ); + return allowedGateways; + }; + + // this has no permission check and used for dynamic secrets directly + // assumes permission check is already done + const fnGetGatewayClientTls = async (gatewayId: string) => { + const gateway = await gatewayDAL.findById(gatewayId); + if (!gateway) throw new NotFoundError({ message: `Gateway with ID ${gatewayId} not found.` }); + + const orgGatewayConfig = await orgGatewayConfigDAL.findById(gateway.orgGatewayRootCaId); + const { decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: orgGatewayConfig.orgId + }); + + const rootCaCert = new x509.X509Certificate( + orgKmsDecryptor({ + cipherTextBlob: orgGatewayConfig.encryptedRootCaCertificate + }) + ); + const gatewayCaCert = new x509.X509Certificate( + orgKmsDecryptor({ + cipherTextBlob: orgGatewayConfig.encryptedGatewayCaCertificate + }) + ); + const clientCert = new x509.X509Certificate( + orgKmsDecryptor({ + cipherTextBlob: orgGatewayConfig.encryptedClientCertificate + }) + ); + + const clientSkObj = crypto.createPrivateKey({ + key: orgKmsDecryptor({ cipherTextBlob: orgGatewayConfig.encryptedClientPrivateKey }), + format: "der", + type: "pkcs8" + }); + + return { + relayAddress: orgKmsDecryptor({ cipherTextBlob: gateway.relayAddress }).toString(), + privateKey: clientSkObj.export({ type: "pkcs8", format: "pem" }), + certificate: clientCert.toString("pem"), + certChain: `${gatewayCaCert.toString("pem")}\n${rootCaCert.toString("pem")}`.trim(), + identityId: gateway.identityId, + orgId: orgGatewayConfig.orgId + }; + }; + return { getGatewayRelayDetails, exchangeAllocatedRelayAddress, listGateways, getGatewayById, - deleteGatewayById + updateGatewayById, + deleteGatewayById, + getProjectGateways, + fnGetGatewayClientTls }; }; diff --git a/backend/src/ee/services/gateway/gateway-types.ts b/backend/src/ee/services/gateway/gateway-types.ts index 40328d041..666f65e6b 100644 --- a/backend/src/ee/services/gateway/gateway-types.ts +++ b/backend/src/ee/services/gateway/gateway-types.ts @@ -1,8 +1,10 @@ import { OrgServiceActor } from "@app/lib/types"; +import { ActorAuthMethod } from "@app/services/auth/auth-type"; export type TExchangeAllocatedRelayAddressDTO = { identityId: string; identityOrg: string; + identityOrgAuthMethod: ActorAuthMethod; relayAddress: string; }; @@ -15,7 +17,18 @@ export type TGetGatewayByIdDTO = { orgPermission: OrgServiceActor; }; +export type TUpdateGatewayByIdDTO = { + id: string; + name?: string; + orgPermission: OrgServiceActor; +}; + export type TDeleteGatewayByIdDTO = { id: string; orgPermission: OrgServiceActor; }; + +export type TGetProjectGatewayByIdDTO = { + projectId: string; + projectPermission: OrgServiceActor; +}; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 3f9e1b42c..21d378802 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -52,7 +52,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ enforceMfa: false, projectTemplates: false, kmip: false, - gateway: true + gateway: false }); export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 626a0c999..34e350cf6 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -69,7 +69,7 @@ export type TFeatureSet = { enforceMfa: boolean; projectTemplates: false; kmip: false; - gateway: true; + gateway: false; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 272dc6de4..efed6a1b2 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -275,6 +275,7 @@ const buildAdminPermission = () => { can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections); can(OrgGatewayPermissionActions.Read, OrgPermissionSubjects.Gateway); + can(OrgGatewayPermissionActions.Create, OrgPermissionSubjects.Gateway); can(OrgGatewayPermissionActions.Edit, OrgPermissionSubjects.Gateway); can(OrgGatewayPermissionActions.Delete, OrgPermissionSubjects.Gateway); @@ -315,6 +316,7 @@ const buildMemberPermission = () => { can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections); can(OrgGatewayPermissionActions.Read, OrgPermissionSubjects.Gateway); + can(OrgGatewayPermissionActions.Create, OrgPermissionSubjects.Gateway); return rules; }; diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 76e93ce9f..04d31fc1a 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -1,7 +1,7 @@ import { Redis } from "ioredis"; -import { Redlock, Settings } from "@app/lib/red-lock"; import { pgAdvisoryLockHashText } from "@app/lib/crypto/hashtext"; +import { Redlock, Settings } from "@app/lib/red-lock"; export const PgSqlLock = { BootUpMigration: 2023, @@ -36,7 +36,8 @@ export const KeyStorePrefixes = { SecretSyncLastRunTimestamp: (syncId: string) => `secret-sync-last-run-${syncId}` as const, IdentityAccessTokenStatusUpdate: (identityAccessTokenId: string) => `identity-access-token-status:${identityAccessTokenId}`, - ServiceTokenStatusUpdate: (serviceTokenId: string) => `service-token-status:${serviceTokenId}` + ServiceTokenStatusUpdate: (serviceTokenId: string) => `service-token-status:${serviceTokenId}`, + GatewayIdentityCredential: (identityId: string) => `gateway-credentails:${identityId}` }; export const KeyStoreTtls = { diff --git a/backend/src/lib/gateway/index.ts b/backend/src/lib/gateway/index.ts new file mode 100644 index 000000000..b4963cdff --- /dev/null +++ b/backend/src/lib/gateway/index.ts @@ -0,0 +1,258 @@ +/* eslint-disable no-await-in-loop */ +import net from "node:net"; +import tls from "node:tls"; + +import { BadRequestError } from "../errors"; +import { logger } from "../logger"; + +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_RETRY_DELAY = 1000; // 1 second + +const createTLSConnection = (relayHost: string, relayPort: number, tlsOptions: tls.TlsOptions = {}) => { + return new Promise((resolve, reject) => { + // @ts-expect-error this is resolved in next connect + const socket = new tls.TLSSocket(null, { + rejectUnauthorized: true, + ...tlsOptions + }); + + const cleanup = () => { + socket.removeAllListeners(); + socket.end(); + }; + + socket.once("error", (err) => { + cleanup(); + reject(err); + }); + + socket.connect(relayPort, relayHost, () => { + resolve(socket); + }); + }); +}; + +type TPingGatewayAndVerifyDTO = { + relayHost: string; + relayPort: number; + tlsOptions: tls.TlsOptions; + maxRetries: number; + identityId: string; + orgId: string; +}; + +const pingGatewayAndVerifyIdentity = async ({ + relayHost, + relayPort, + tlsOptions = {}, + maxRetries = DEFAULT_MAX_RETRIES, + identityId, + orgId +}: TPingGatewayAndVerifyDTO) => { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt += 1) { + try { + const socket = await createTLSConnection(relayHost, relayPort, tlsOptions); + socket.setTimeout(2000); + + const pingResult = await new Promise((resolve, reject) => { + socket.once("timeout", () => { + socket.destroy(); + reject(new Error("Timeout")); + }); + socket.once("close", () => { + socket.destroy(); + }); + + socket.once("end", () => { + socket.destroy(); + }); + socket.once("error", (err) => { + reject(err); + }); + + socket.write(Buffer.from("PING\n"), () => { + socket.once("data", (data) => { + const response = (data as string).toString(); + const certificate = socket.getPeerCertificate(); + + if (certificate.subject.CN !== identityId || certificate.subject.O !== orgId) { + throw new BadRequestError({ + message: `Invalid gateway. Certificate not found for ${identityId} in organisation ${orgId}` + }); + } + + if (response === "PONG") { + resolve(true); + } else { + reject(new Error(`Unexpected response: ${response}`)); + } + }); + }); + }); + + socket.end(); + return pingResult; + } catch (err) { + lastError = err as Error; + + if (attempt < maxRetries) { + await new Promise((resolve) => { + setTimeout(resolve, DEFAULT_RETRY_DELAY); + }); + } + } + } + + throw new Error(`Failed to ping gateway after ${maxRetries} attempts. Last error: ${lastError?.message}`); +}; + +interface TProxyServer { + server: net.Server; + port: number; + cleanup: () => void; +} + +const setupProxyServer = ({ + targetPort, + targetHost, + tlsOptions = {}, + relayHost, + relayPort +}: { + targetHost: string; + targetPort: number; + relayPort: number; + relayHost: string; + tlsOptions: tls.TlsOptions; +}): Promise => { + return new Promise((resolve, reject) => { + const server = net.createServer(); + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + server.on("connection", async (clientSocket) => { + try { + const targetSocket = await createTLSConnection(relayHost, relayPort, tlsOptions); + + targetSocket.write(Buffer.from(`FORWARD-TCP ${targetHost}:${targetPort}\n`), () => { + clientSocket.on("data", (data) => { + const flushed = targetSocket.write(data); + if (!flushed) { + clientSocket.pause(); + targetSocket.once("drain", () => { + clientSocket.resume(); + }); + } + }); + + targetSocket.on("data", (data) => { + const flushed = clientSocket.write(data as string); + if (!flushed) { + targetSocket.pause(); + clientSocket.once("drain", () => { + targetSocket.resume(); + }); + } + }); + }); + + const cleanup = () => { + clientSocket?.unpipe(); + clientSocket?.end(); + targetSocket?.unpipe(); + targetSocket?.end(); + }; + + clientSocket.on("error", (err) => { + logger.error(err, "Client socket error"); + cleanup(); + reject(err); + }); + + targetSocket.on("error", (err) => { + logger.error(err, "Target socket error"); + cleanup(); + reject(err); + }); + + clientSocket.on("end", cleanup); + targetSocket.on("end", cleanup); + } catch (err) { + logger.error(err, "Failed to establish target connection:"); + clientSocket.end(); + reject(err); + } + }); + + server.on("error", (err) => { + reject(err); + }); + + server.listen(0, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to get server port")); + return; + } + + logger.info("Gateway proxy started"); + resolve({ + server, + port: address.port, + cleanup: () => { + server.close(); + } + }); + }); + }); +}; + +interface ProxyOptions { + targetHost: string; + targetPort: number; + relayHost: string; + relayPort: number; + tlsOptions?: tls.TlsOptions; + maxRetries?: number; + identityId: string; + orgId: string; +} + +export const withGatewayProxy = async ( + callback: (port: number) => Promise, + options: ProxyOptions +): Promise => { + const { + relayHost, + relayPort, + targetHost, + targetPort, + tlsOptions = {}, + maxRetries = DEFAULT_MAX_RETRIES, + identityId, + orgId + } = options; + + // First, try to ping the gateway + await pingGatewayAndVerifyIdentity({ + relayHost, + relayPort, + tlsOptions, + maxRetries, + identityId, + orgId + }); + + // Setup the proxy server + const { port, cleanup } = await setupProxyServer({ targetHost, targetPort, relayPort, relayHost, tlsOptions }); + + try { + // Execute the callback with the allocated port + await callback(port); + } finally { + // Ensure cleanup happens regardless of success or failure + cleanup(); + } +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index ca6a16a6e..11fa5220e 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1306,7 +1306,18 @@ export const registerRoutes = async ( kmsService }); - const dynamicSecretProviders = buildDynamicSecretProviders(); + const gatewayService = gatewayServiceFactory({ + permissionService, + gatewayDAL, + kmsService, + licenseService, + orgGatewayConfigDAL, + keyStore + }); + + const dynamicSecretProviders = buildDynamicSecretProviders({ + gatewayService + }); const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({ queueService, dynamicSecretLeaseDAL, @@ -1324,8 +1335,11 @@ export const registerRoutes = async ( folderDAL, permissionService, licenseService, - kmsService + kmsService, + gatewayDAL, + orgGatewayConfigDAL }); + const dynamicSecretLeaseService = dynamicSecretLeaseServiceFactory({ projectDAL, permissionService,