diff --git a/backend/src/db/migrations/20250512103022_identity-kubernetes-auth-gateway.ts b/backend/src/db/migrations/20250512103022_identity-kubernetes-auth-gateway.ts new file mode 100644 index 000000000..fcd9bfc3e --- /dev/null +++ b/backend/src/db/migrations/20250512103022_identity-kubernetes-auth-gateway.ts @@ -0,0 +1,25 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasGatewayIdColumn = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "gatewayId"); + + if (!hasGatewayIdColumn) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (table) => { + table.uuid("gatewayId").nullable(); + table.foreign("gatewayId").references("id").inTable(TableName.Gateway).onDelete("SET NULL"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasGatewayIdColumn = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "gatewayId"); + + if (hasGatewayIdColumn) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (table) => { + table.dropForeign("gatewayId"); + table.dropColumn("gatewayId"); + }); + } +} diff --git a/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts b/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts new file mode 100644 index 000000000..3b3c5322e --- /dev/null +++ b/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts @@ -0,0 +1,110 @@ +import { Knex } from "knex"; + +import { inMemoryKeyStore } from "@app/keystore/memory"; +import { selectAllTableCols } from "@app/lib/knex"; +import { initLogger } from "@app/lib/logger"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TableName } from "../schemas"; +import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEncryptionServices } from "./utils/services"; + +// Note(daniel): We aren't dropping tables or columns in this migrations so we can easily rollback if needed. +// In the future we need to drop the projectGatewayId on the dynamic secrets table, and drop the project_gateways table entirely. + +const BATCH_SIZE = 500; + +export async function up(knex: Knex): Promise { + // eslint-disable-next-line no-param-reassign + knex.replicaNode = () => { + return knex; + }; + + if (!(await knex.schema.hasColumn(TableName.DynamicSecret, "gatewayId"))) { + await knex.schema.alterTable(TableName.DynamicSecret, (table) => { + table.uuid("gatewayId").nullable(); + table.foreign("gatewayId").references("id").inTable(TableName.Gateway).onDelete("SET NULL"); + + table.index("gatewayId"); + }); + + const existingDynamicSecretsWithProjectGatewayId = await knex(TableName.DynamicSecret) + .select(selectAllTableCols(TableName.DynamicSecret)) + .whereNotNull(`${TableName.DynamicSecret}.projectGatewayId`) + .join(TableName.ProjectGateway, `${TableName.ProjectGateway}.id`, `${TableName.DynamicSecret}.projectGatewayId`) + .whereNotNull(`${TableName.ProjectGateway}.gatewayId`) + .select( + knex.ref("projectId").withSchema(TableName.ProjectGateway).as("projectId"), + knex.ref("gatewayId").withSchema(TableName.ProjectGateway).as("projectGatewayGatewayId") + ); + + initLogger(); + const envConfig = getMigrationEnvConfig(); + const keyStore = inMemoryKeyStore(); + const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); + + const updatedDynamicSecrets = await Promise.all( + existingDynamicSecretsWithProjectGatewayId.map(async (existingDynamicSecret) => { + if (!existingDynamicSecret.projectGatewayGatewayId) { + const result = { + ...existingDynamicSecret, + gatewayId: null + }; + + const { projectId, projectGatewayGatewayId, ...rest } = result; + return rest; + } + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: existingDynamicSecret.projectId + }); + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: existingDynamicSecret.projectId + }); + + let decryptedStoredInput = JSON.parse( + secretManagerDecryptor({ cipherTextBlob: Buffer.from(existingDynamicSecret.encryptedInput) }).toString() + ) as object; + + // We're not removing the existing projectGatewayId from the input so we can easily rollback without having to re-encrypt the input + decryptedStoredInput = { + ...decryptedStoredInput, + gatewayId: existingDynamicSecret.projectGatewayGatewayId + }; + + const encryptedInput = secretManagerEncryptor({ + plainText: Buffer.from(JSON.stringify(decryptedStoredInput)) + }).cipherTextBlob; + + const result = { + ...existingDynamicSecret, + encryptedInput, + gatewayId: existingDynamicSecret.projectGatewayGatewayId + }; + + const { projectId, projectGatewayGatewayId, ...rest } = result; + return rest; + }) + ); + + for (let i = 0; i < updatedDynamicSecrets.length; i += BATCH_SIZE) { + // eslint-disable-next-line no-await-in-loop + await knex(TableName.DynamicSecret) + .insert(updatedDynamicSecrets.slice(i, i + BATCH_SIZE)) + .onConflict("id") + .merge(); + } + } +} + +export async function down(knex: Knex): Promise { + // no re-encryption needed as we keep the old projectGatewayId in the input + if (await knex.schema.hasColumn(TableName.DynamicSecret, "gatewayId")) { + await knex.schema.alterTable(TableName.DynamicSecret, (table) => { + table.dropForeign("gatewayId"); + table.dropColumn("gatewayId"); + }); + } +} diff --git a/backend/src/db/schemas/dynamic-secrets.ts b/backend/src/db/schemas/dynamic-secrets.ts index 913a6d475..350a32b7a 100644 --- a/backend/src/db/schemas/dynamic-secrets.ts +++ b/backend/src/db/schemas/dynamic-secrets.ts @@ -27,7 +27,8 @@ export const DynamicSecretsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), encryptedInput: zodBuffer, - projectGatewayId: z.string().uuid().nullable().optional() + projectGatewayId: z.string().uuid().nullable().optional(), + gatewayId: z.string().uuid().nullable().optional() }); export type TDynamicSecrets = z.infer; diff --git a/backend/src/db/schemas/identity-kubernetes-auths.ts b/backend/src/db/schemas/identity-kubernetes-auths.ts index 448cec386..3c9dd400c 100644 --- a/backend/src/db/schemas/identity-kubernetes-auths.ts +++ b/backend/src/db/schemas/identity-kubernetes-auths.ts @@ -29,7 +29,8 @@ export const IdentityKubernetesAuthsSchema = z.object({ allowedNames: z.string(), allowedAudience: z.string(), encryptedKubernetesTokenReviewerJwt: zodBuffer.nullable().optional(), - encryptedKubernetesCaCertificate: zodBuffer.nullable().optional() + encryptedKubernetesCaCertificate: zodBuffer.nullable().optional(), + gatewayId: z.string().uuid().nullable().optional() }); export type TIdentityKubernetesAuths = z.infer; diff --git a/backend/src/ee/routes/v1/gateway-router.ts b/backend/src/ee/routes/v1/gateway-router.ts index c916e229e..40e9c1580 100644 --- a/backend/src/ee/routes/v1/gateway-router.ts +++ b/backend/src/ee/routes/v1/gateway-router.ts @@ -121,14 +121,7 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => { identity: z.object({ name: z.string(), id: z.string() - }), - projects: z - .object({ - name: z.string(), - id: z.string(), - slug: z.string() - }) - .array() + }) }).array() }) } @@ -158,17 +151,15 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => { identity: z.object({ name: z.string(), id: z.string() - }), - projectGatewayId: z.string() + }) }).array() }) } }, onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN, AuthMode.JWT]), handler: async (req) => { - const gateways = await server.services.gateway.getProjectGateways({ - projectId: req.params.projectId, - projectPermission: req.permission + const gateways = await server.services.gateway.listGateways({ + orgPermission: req.permission }); return { gateways }; } @@ -216,8 +207,7 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => { id: z.string() }), body: z.object({ - name: slugSchema({ field: "name" }).optional(), - projectIds: z.string().array().optional() + name: slugSchema({ field: "name" }).optional() }), response: { 200: z.object({ @@ -230,8 +220,7 @@ export const registerGatewayRouter = async (server: FastifyZodProvider) => { const gateway = await server.services.gateway.updateGatewayById({ orgPermission: req.permission, id: req.params.id, - name: req.body.name, - projectIds: req.body.projectIds + name: req.body.name }); return { gateway }; } 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 44c18b001..c39f07b5c 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -17,7 +17,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 { TProjectGatewayDALFactory } from "../gateway/project-gateway-dal"; +import { TGatewayDALFactory } from "../gateway/gateway-dal"; +import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TDynamicSecretDALFactory } from "./dynamic-secret-dal"; import { DynamicSecretStatus, @@ -44,9 +45,9 @@ type TDynamicSecretServiceFactoryDep = { licenseService: Pick; folderDAL: Pick; projectDAL: Pick; - permissionService: Pick; + permissionService: Pick; kmsService: Pick; - projectGatewayDAL: Pick; + gatewayDAL: Pick; resourceMetadataDAL: Pick; }; @@ -62,7 +63,7 @@ export const dynamicSecretServiceFactory = ({ dynamicSecretQueueService, projectDAL, kmsService, - projectGatewayDAL, + gatewayDAL, resourceMetadataDAL }: TDynamicSecretServiceFactoryDep) => { const create = async ({ @@ -117,15 +118,31 @@ export const dynamicSecretServiceFactory = ({ const inputs = await selectedProvider.validateProviderInputs(provider.inputs); let selectedGatewayId: string | null = null; - if (inputs && typeof inputs === "object" && "projectGatewayId" in inputs && inputs.projectGatewayId) { - const projectGatewayId = inputs.projectGatewayId as string; + if (inputs && typeof inputs === "object" && "gatewayId" in inputs && inputs.gatewayId) { + const gatewayId = inputs.gatewayId as string; - const projectGateway = await projectGatewayDAL.findOne({ id: projectGatewayId, projectId }); - if (!projectGateway) + const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: actorOrgId }); + + if (!gateway) { throw new NotFoundError({ - message: `Project gateway with ${projectGatewayId} not found` + message: `Gateway with ID ${gatewayId} not found` }); - selectedGatewayId = projectGateway.id; + } + + const { permission: orgPermission } = await permissionService.getOrgPermission( + actor, + actorId, + gateway.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionGatewayActions.AttachGateways, + OrgPermissionSubjects.Gateway + ); + + selectedGatewayId = gateway.id; } const isConnected = await selectedProvider.validateConnection(provider.inputs); @@ -146,7 +163,7 @@ export const dynamicSecretServiceFactory = ({ defaultTTL, folderId: folder.id, name, - projectGatewayId: selectedGatewayId + gatewayId: selectedGatewayId }, tx ); @@ -255,20 +272,30 @@ export const dynamicSecretServiceFactory = ({ const updatedInput = await selectedProvider.validateProviderInputs(newInput); let selectedGatewayId: string | null = null; - if ( - updatedInput && - typeof updatedInput === "object" && - "projectGatewayId" in updatedInput && - updatedInput?.projectGatewayId - ) { - const projectGatewayId = updatedInput.projectGatewayId as string; + if (updatedInput && typeof updatedInput === "object" && "gatewayId" in updatedInput && updatedInput?.gatewayId) { + const gatewayId = updatedInput.gatewayId as string; - const projectGateway = await projectGatewayDAL.findOne({ id: projectGatewayId, projectId }); - if (!projectGateway) + const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: actorOrgId }); + if (!gateway) { throw new NotFoundError({ - message: `Project gateway with ${projectGatewayId} not found` + message: `Gateway with ID ${gatewayId} not found` }); - selectedGatewayId = projectGateway.id; + } + + const { permission: orgPermission } = await permissionService.getOrgPermission( + actor, + actorId, + gateway.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionGatewayActions.AttachGateways, + OrgPermissionSubjects.Gateway + ); + + selectedGatewayId = gateway.id; } const isConnected = await selectedProvider.validateConnection(newInput); @@ -284,7 +311,7 @@ export const dynamicSecretServiceFactory = ({ defaultTTL, name: newName ?? name, status: null, - projectGatewayId: selectedGatewayId + gatewayId: selectedGatewayId }, tx ); diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index faa671980..737aaadea 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -18,7 +18,7 @@ import { SqlDatabaseProvider } from "./sql-database"; import { TotpProvider } from "./totp"; type TBuildDynamicSecretProviderDTO = { - gatewayService: Pick; + gatewayService: Pick; }; export const buildDynamicSecretProviders = ({ diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 449f6d8f6..0c6eaf151 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -137,7 +137,7 @@ export const DynamicSecretSqlDBSchema = z.object({ revocationStatement: z.string().trim(), renewStatement: z.string().trim().optional(), ca: z.string().optional(), - projectGatewayId: z.string().nullable().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 178ca4ef9..3ae85ed7b 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -112,14 +112,14 @@ const generateUsername = (provider: SqlProviders) => { }; type TSqlDatabaseProviderDTO = { - gatewayService: Pick; + gatewayService: Pick; }; export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretSqlDBSchema.parseAsync(inputs); - const [hostIp] = await verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.projectGatewayId)); + const [hostIp] = await verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.gatewayId)); validateHandlebarTemplate("SQL creation", providerInputs.creationStatement, { allowedExpressions: (val) => ["username", "password", "expiration", "database"].includes(val) }); @@ -168,7 +168,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) providerInputs: z.infer, gatewayCallback: (host: string, port: number) => Promise ) => { - const relayDetails = await gatewayService.fnGetGatewayClientTls(providerInputs.projectGatewayId as string); + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(providerInputs.gatewayId as string); const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); await withGatewayProxy( async (port) => { @@ -202,7 +202,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) await db.destroy(); }; - if (providerInputs.projectGatewayId) { + if (providerInputs.gatewayId) { await gatewayProxyWrapper(providerInputs, gatewayCallback); } else { await gatewayCallback(); @@ -238,7 +238,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) await db.destroy(); } }; - if (providerInputs.projectGatewayId) { + if (providerInputs.gatewayId) { await gatewayProxyWrapper(providerInputs, gatewayCallback); } else { await gatewayCallback(); @@ -265,7 +265,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) await db.destroy(); } }; - if (providerInputs.projectGatewayId) { + if (providerInputs.gatewayId) { await gatewayProxyWrapper(providerInputs, gatewayCallback); } else { await gatewayCallback(); @@ -301,7 +301,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) await db.destroy(); } }; - if (providerInputs.projectGatewayId) { + if (providerInputs.gatewayId) { await gatewayProxyWrapper(providerInputs, gatewayCallback); } else { await gatewayCallback(); diff --git a/backend/src/ee/services/gateway/gateway-dal.ts b/backend/src/ee/services/gateway/gateway-dal.ts index fbf5558e4..31b4b727b 100644 --- a/backend/src/ee/services/gateway/gateway-dal.ts +++ b/backend/src/ee/services/gateway/gateway-dal.ts @@ -1,37 +1,34 @@ -import { Knex } from "knex"; - import { TDbClient } from "@app/db"; import { GatewaysSchema, TableName, TGateways } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { - buildFindFilter, - ormify, - selectAllTableCols, - sqlNestRelationships, - TFindFilter, - TFindOpt -} from "@app/lib/knex"; +import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; export type TGatewayDALFactory = ReturnType; export const gatewayDALFactory = (db: TDbClient) => { const orm = ormify(db, TableName.Gateway); - const find = async (filter: TFindFilter, { offset, limit, sort, tx }: TFindOpt = {}) => { + const find = async ( + filter: TFindFilter & { orgId?: string }, + { offset, limit, sort, tx }: TFindOpt = {} + ) => { try { const query = (tx || db)(TableName.Gateway) // eslint-disable-next-line @typescript-eslint/no-misused-promises - .where(buildFindFilter(filter)) + .where(buildFindFilter(filter, TableName.Gateway, ["orgId"])) .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Gateway}.identityId`) - .leftJoin(TableName.ProjectGateway, `${TableName.ProjectGateway}.gatewayId`, `${TableName.Gateway}.id`) - .leftJoin(TableName.Project, `${TableName.Project}.id`, `${TableName.ProjectGateway}.projectId`) + .join( + TableName.IdentityOrgMembership, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Gateway}.identityId` + ) .select(selectAllTableCols(TableName.Gateway)) - .select( - db.ref("name").withSchema(TableName.Identity).as("identityName"), - db.ref("name").withSchema(TableName.Project).as("projectName"), - db.ref("slug").withSchema(TableName.Project).as("projectSlug"), - db.ref("id").withSchema(TableName.Project).as("projectId") - ); + .select(db.ref("orgId").withSchema(TableName.IdentityOrgMembership).as("identityOrgId")) + .select(db.ref("name").withSchema(TableName.Identity).as("identityName")); + + if (filter.orgId) { + void query.where(`${TableName.IdentityOrgMembership}.orgId`, filter.orgId); + } if (limit) void query.limit(limit); if (offset) void query.offset(offset); if (sort) { @@ -39,48 +36,16 @@ export const gatewayDALFactory = (db: TDbClient) => { } const docs = await query; - return sqlNestRelationships({ - data: docs, - key: "id", - parentMapper: (data) => ({ - ...GatewaysSchema.parse(data), - identity: { id: data.identityId, name: data.identityName } - }), - childrenMapper: [ - { - key: "projectId", - label: "projects" as const, - mapper: ({ projectId, projectName, projectSlug }) => ({ - id: projectId, - name: projectName, - slug: projectSlug - }) - } - ] - }); + + return docs.map((el) => ({ + ...GatewaysSchema.parse(el), + orgId: el.identityOrgId as string, // todo(daniel): figure out why typescript is not inferring this as a string + identity: { id: el.identityId, name: el.identityName } + })); } catch (error) { throw new DatabaseError({ error, name: `${TableName.Gateway}: 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.ProjectGateway, `${TableName.ProjectGateway}.gatewayId`, `${TableName.Gateway}.id`) - .select(selectAllTableCols(TableName.Gateway)) - .select( - db.ref("name").withSchema(TableName.Identity).as("identityName"), - db.ref("id").withSchema(TableName.ProjectGateway).as("projectGatewayId") - ) - .where({ [`${TableName.ProjectGateway}.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 }; + return { ...orm, find }; }; diff --git a/backend/src/ee/services/gateway/gateway-service.ts b/backend/src/ee/services/gateway/gateway-service.ts index 5a17bc028..25f0b384a 100644 --- a/backend/src/ee/services/gateway/gateway-service.ts +++ b/backend/src/ee/services/gateway/gateway-service.ts @@ -4,7 +4,6 @@ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import { z } from "zod"; -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"; @@ -27,17 +26,14 @@ import { TGatewayDALFactory } from "./gateway-dal"; import { TExchangeAllocatedRelayAddressDTO, TGetGatewayByIdDTO, - TGetProjectGatewayByIdDTO, THeartBeatDTO, TListGatewaysDTO, TUpdateGatewayByIdDTO } from "./gateway-types"; import { TOrgGatewayConfigDALFactory } from "./org-gateway-config-dal"; -import { TProjectGatewayDALFactory } from "./project-gateway-dal"; type TGatewayServiceFactoryDep = { gatewayDAL: TGatewayDALFactory; - projectGatewayDAL: TProjectGatewayDALFactory; orgGatewayConfigDAL: Pick; licenseService: Pick; kmsService: Pick; @@ -57,8 +53,7 @@ export const gatewayServiceFactory = ({ kmsService, permissionService, orgGatewayConfigDAL, - keyStore, - projectGatewayDAL + keyStore }: TGatewayServiceFactoryDep) => { const $validateOrgAccessToGateway = async (orgId: string, actorId: string, actorAuthMethod: ActorAuthMethod) => { // if (!licenseService.onPremFeatures.gateway) { @@ -526,7 +521,7 @@ export const gatewayServiceFactory = ({ return gateway; }; - const updateGatewayById = async ({ orgPermission, id, name, projectIds }: TUpdateGatewayByIdDTO) => { + const updateGatewayById = async ({ orgPermission, id, name }: TUpdateGatewayByIdDTO) => { const { permission } = await permissionService.getOrgPermission( orgPermission.type, orgPermission.id, @@ -543,15 +538,6 @@ export const gatewayServiceFactory = ({ const [gateway] = await gatewayDAL.update({ id, orgGatewayRootCaId: orgGatewayConfig.id }, { name }); if (!gateway) throw new NotFoundError({ message: `Gateway with ID ${id} not found.` }); - if (projectIds) { - await projectGatewayDAL.transaction(async (tx) => { - await projectGatewayDAL.delete({ gatewayId: gateway.id }, tx); - await projectGatewayDAL.insertMany( - projectIds.map((el) => ({ gatewayId: gateway.id, projectId: el })), - tx - ); - }); - } return gateway; }; @@ -576,27 +562,7 @@ export const gatewayServiceFactory = ({ return gateway; }; - const getProjectGateways = async ({ projectId, projectPermission }: TGetProjectGatewayByIdDTO) => { - 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); - return gateways; - }; - - // this has no permission check and used for dynamic secrets directly - // assumes permission check is already done - const fnGetGatewayClientTls = async (projectGatewayId: string) => { - const projectGateway = await projectGatewayDAL.findById(projectGatewayId); - if (!projectGateway) throw new NotFoundError({ message: `Project gateway with ID ${projectGatewayId} not found.` }); - - const { gatewayId } = projectGateway; + const fnGetGatewayClientTlsByGatewayId = async (gatewayId: string) => { const gateway = await gatewayDAL.findById(gatewayId); if (!gateway) throw new NotFoundError({ message: `Gateway with ID ${gatewayId} not found.` }); @@ -645,8 +611,7 @@ export const gatewayServiceFactory = ({ getGatewayById, updateGatewayById, deleteGatewayById, - getProjectGateways, - fnGetGatewayClientTls, + fnGetGatewayClientTlsByGatewayId, heartbeat }; }; diff --git a/backend/src/ee/services/gateway/gateway-types.ts b/backend/src/ee/services/gateway/gateway-types.ts index 220dc7147..823028154 100644 --- a/backend/src/ee/services/gateway/gateway-types.ts +++ b/backend/src/ee/services/gateway/gateway-types.ts @@ -20,7 +20,6 @@ export type TGetGatewayByIdDTO = { export type TUpdateGatewayByIdDTO = { id: string; name?: string; - projectIds?: string[]; orgPermission: OrgServiceActor; }; diff --git a/backend/src/ee/services/gateway/project-gateway-dal.ts b/backend/src/ee/services/gateway/project-gateway-dal.ts deleted file mode 100644 index 44c36f5f6..000000000 --- a/backend/src/ee/services/gateway/project-gateway-dal.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TProjectGatewayDALFactory = ReturnType; - -export const projectGatewayDALFactory = (db: TDbClient) => { - const orm = ormify(db, TableName.ProjectGateway); - return orm; -}; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 7026899c7..612914bcc 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -41,7 +41,8 @@ export enum OrgPermissionGatewayActions { CreateGateways = "create-gateways", ListGateways = "list-gateways", EditGateways = "edit-gateways", - DeleteGateways = "delete-gateways" + DeleteGateways = "delete-gateways", + AttachGateways = "attach-gateways" } export enum OrgPermissionIdentityActions { @@ -337,6 +338,7 @@ const buildAdminPermission = () => { can(OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionGatewayActions.EditGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionGatewayActions.DeleteGateways, OrgPermissionSubjects.Gateway); + can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); @@ -378,6 +380,7 @@ const buildMemberPermission = () => { can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections); can(OrgPermissionGatewayActions.ListGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway); + can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway); return rules; }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 0bcee6405..51f70010b 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -393,6 +393,7 @@ export const KUBERNETES_AUTH = { allowedNames: "The comma-separated list of trusted service account names that can authenticate with Infisical.", allowedAudience: "The optional audience claim that the service account JWT token must have to authenticate with Infisical.", + gatewayId: "The ID of the gateway to use when performing kubernetes API requests.", accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The lifetime for an access token in seconds.", accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", @@ -409,6 +410,7 @@ export const KUBERNETES_AUTH = { allowedNames: "The new comma-separated list of trusted service account names that can authenticate with Infisical.", allowedAudience: "The new optional audience claim that the service account JWT token must have to authenticate with Infisical.", + gatewayId: "The ID of the gateway to use when performing kubernetes API requests.", accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The new lifetime for an acccess token in seconds.", accessTokenMaxTTL: "The new maximum lifetime for an acccess token in seconds.", diff --git a/backend/src/lib/gateway/index.ts b/backend/src/lib/gateway/index.ts index 84d801dda..7a94c6384 100644 --- a/backend/src/lib/gateway/index.ts +++ b/backend/src/lib/gateway/index.ts @@ -174,6 +174,8 @@ const setupProxyServer = async ({ return new Promise((resolve, reject) => { const server = net.createServer(); + let streamClosed = false; + // eslint-disable-next-line @typescript-eslint/no-misused-promises server.on("connection", async (clientConn) => { try { @@ -202,9 +204,15 @@ const setupProxyServer = async ({ // Handle client connection close clientConn.on("end", () => { - writer.close().catch((err) => { - logger.error(err); - }); + if (!streamClosed) { + try { + writer.close().catch((err) => { + logger.debug(err, "Error closing writer (already closed)"); + }); + } catch (error) { + logger.debug(error, "Error in writer close"); + } + } }); clientConn.on("error", (clientConnErr) => { @@ -249,14 +257,29 @@ const setupProxyServer = async ({ setupCopy(); // Handle connection closure clientConn.on("close", () => { - stream.destroy().catch((err) => { - proxyErrorMsg.push((err as Error)?.message); - }); + if (!streamClosed) { + streamClosed = true; + stream.destroy().catch((err) => { + logger.debug(err, "Stream already destroyed during close event"); + }); + } }); const cleanup = async () => { - clientConn?.destroy(); - await stream.destroy(); + try { + clientConn?.destroy(); + } catch (err) { + logger.debug(err, "Error destroying client connection"); + } + + if (!streamClosed) { + streamClosed = true; + try { + await stream.destroy(); + } catch (err) { + logger.debug(err, "Error destroying stream (might be already closed)"); + } + } }; clientConn.on("error", (clientConnErr) => { @@ -301,8 +324,17 @@ const setupProxyServer = async ({ server, port: address.port, cleanup: async () => { - server.close(); - await quicClient?.destroy(); + try { + server.close(); + } catch (err) { + logger.debug(err, "Error closing server"); + } + + try { + await quicClient?.destroy(); + } catch (err) { + logger.debug(err, "Error destroying QUIC client"); + } }, getProxyError: () => proxyErrorMsg.join(",") }); @@ -320,10 +352,10 @@ interface ProxyOptions { orgId: string; } -export const withGatewayProxy = async ( - callback: (port: number) => Promise, +export const withGatewayProxy = async ( + callback: (port: number) => Promise, options: ProxyOptions -): Promise => { +): Promise => { const { relayHost, relayPort, targetHost, targetPort, tlsOptions, identityId, orgId } = options; // Setup the proxy server @@ -339,7 +371,7 @@ export const withGatewayProxy = async ( try { // Execute the callback with the allocated port - await callback(port); + return await callback(port); } catch (err) { const proxyErrorMessage = getProxyError(); if (proxyErrorMessage) { diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index b1e011709..2e17bff20 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -32,13 +32,13 @@ export const buildFindFilter = ( { $in, $notNull, $search, $complex, ...filter }: TFindFilter, tableName?: TableName, - excludeKeys?: Array + excludeKeys?: string[] ) => (bd: Knex.QueryBuilder) => { const processedFilter = tableName ? Object.fromEntries( Object.entries(filter) - .filter(([key]) => !excludeKeys || !excludeKeys.includes(key as keyof R)) + .filter(([key]) => !excludeKeys || !excludeKeys.includes(key)) .map(([key, value]) => [`${tableName}.${key}`, value]) ) : filter; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index b05b253d6..5196971f9 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -32,7 +32,6 @@ import { externalKmsServiceFactory } from "@app/ee/services/external-kms/externa import { gatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; import { gatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { orgGatewayConfigDALFactory } from "@app/ee/services/gateway/org-gateway-config-dal"; -import { projectGatewayDALFactory } from "@app/ee/services/gateway/project-gateway-dal"; import { githubOrgSyncDALFactory } from "@app/ee/services/github-org-sync/github-org-sync-dal"; import { githubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service"; import { groupDALFactory } from "@app/ee/services/group/group-dal"; @@ -439,7 +438,6 @@ export const registerRoutes = async ( const orgGatewayConfigDAL = orgGatewayConfigDALFactory(db); const gatewayDAL = gatewayDALFactory(db); - const projectGatewayDAL = projectGatewayDALFactory(db); const secretReminderRecipientsDAL = secretReminderRecipientsDALFactory(db); const githubOrgSyncDAL = githubOrgSyncDALFactory(db); @@ -1422,12 +1420,24 @@ export const registerRoutes = async ( identityUaDAL, licenseService }); + + const gatewayService = gatewayServiceFactory({ + permissionService, + gatewayDAL, + kmsService, + licenseService, + orgGatewayConfigDAL, + keyStore + }); + const identityKubernetesAuthService = identityKubernetesAuthServiceFactory({ identityKubernetesAuthDAL, identityOrgMembershipDAL, identityAccessTokenDAL, permissionService, licenseService, + gatewayService, + gatewayDAL, kmsService }); const identityGcpAuthService = identityGcpAuthServiceFactory({ @@ -1490,16 +1500,6 @@ export const registerRoutes = async ( identityDAL }); - const gatewayService = gatewayServiceFactory({ - permissionService, - gatewayDAL, - kmsService, - licenseService, - orgGatewayConfigDAL, - keyStore, - projectGatewayDAL - }); - const dynamicSecretProviders = buildDynamicSecretProviders({ gatewayService }); @@ -1521,7 +1521,7 @@ export const registerRoutes = async ( permissionService, licenseService, kmsService, - projectGatewayDAL, + gatewayDAL, resourceMetadataDAL }); diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts index 21759e0cd..de7927573 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { IdentityKubernetesAuthsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, KUBERNETES_AUTH } from "@app/lib/api-docs"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -21,7 +22,8 @@ const IdentityKubernetesAuthResponseSchema = IdentityKubernetesAuthsSchema.pick( kubernetesHost: true, allowedNamespaces: true, allowedNames: true, - allowedAudience: true + allowedAudience: true, + gatewayId: true }).extend({ caCert: z.string(), tokenReviewerJwt: z.string().optional().nullable() @@ -100,12 +102,30 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide }), body: z .object({ - kubernetesHost: z.string().trim().min(1).describe(KUBERNETES_AUTH.ATTACH.kubernetesHost), + kubernetesHost: z + .string() + .trim() + .min(1) + .describe(KUBERNETES_AUTH.ATTACH.kubernetesHost) + .refine( + (val) => + characterValidator([ + CharacterType.Alphabets, + CharacterType.Numbers, + CharacterType.Colon, + CharacterType.Period, + CharacterType.ForwardSlash + ])(val), + { + message: "Kubernetes host must only contain alphabets, numbers, colons, periods, and forward slashes." + } + ), caCert: z.string().trim().default("").describe(KUBERNETES_AUTH.ATTACH.caCert), tokenReviewerJwt: z.string().trim().optional().describe(KUBERNETES_AUTH.ATTACH.tokenReviewerJwt), allowedNamespaces: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedNamespaces), // TODO: validation allowedNames: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedNames), allowedAudience: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedAudience), + gatewayId: z.string().uuid().optional().nullable().describe(KUBERNETES_AUTH.ATTACH.gatewayId), accessTokenTrustedIps: z .object({ ipAddress: z.string().trim() @@ -199,12 +219,34 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide }), body: z .object({ - kubernetesHost: z.string().trim().min(1).optional().describe(KUBERNETES_AUTH.UPDATE.kubernetesHost), + kubernetesHost: z + .string() + .trim() + .min(1) + .optional() + .describe(KUBERNETES_AUTH.UPDATE.kubernetesHost) + .refine( + (val) => { + if (!val) return true; + + return characterValidator([ + CharacterType.Alphabets, + CharacterType.Numbers, + CharacterType.Colon, + CharacterType.Period, + CharacterType.ForwardSlash + ])(val); + }, + { + message: "Kubernetes host must only contain alphabets, numbers, colons, periods, and forward slashes." + } + ), caCert: z.string().trim().optional().describe(KUBERNETES_AUTH.UPDATE.caCert), tokenReviewerJwt: z.string().trim().nullable().optional().describe(KUBERNETES_AUTH.UPDATE.tokenReviewerJwt), allowedNamespaces: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedNamespaces), // TODO: validation allowedNames: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedNames), allowedAudience: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedAudience), + gatewayId: z.string().uuid().optional().nullable().describe(KUBERNETES_AUTH.UPDATE.gatewayId), accessTokenTrustedIps: z .object({ ipAddress: z.string().trim() diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 9c0e8d2dd..2da7c3881 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -4,8 +4,14 @@ import https from "https"; import jwt from "jsonwebtoken"; import { IdentityAuthMethod, TIdentityKubernetesAuthsUpdate } from "@app/db/schemas"; +import { TGatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + OrgPermissionGatewayActions, + OrgPermissionIdentityActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; import { constructPermissionErrorMessage, validatePrivilegeChangeOperation @@ -13,6 +19,7 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { withGatewayProxy } from "@app/lib/gateway"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; @@ -43,6 +50,8 @@ type TIdentityKubernetesAuthServiceFactoryDep = { permissionService: Pick; licenseService: Pick; kmsService: Pick; + gatewayService: TGatewayServiceFactory; + gatewayDAL: Pick; }; export type TIdentityKubernetesAuthServiceFactory = ReturnType; @@ -53,8 +62,44 @@ export const identityKubernetesAuthServiceFactory = ({ identityAccessTokenDAL, permissionService, licenseService, + gatewayService, + gatewayDAL, kmsService }: TIdentityKubernetesAuthServiceFactoryDep) => { + const $gatewayProxyWrapper = async ( + inputs: { + gatewayId: string; + targetHost: string; + targetPort: number; + }, + gatewayCallback: (host: string, port: number) => Promise + ): Promise => { + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + + const callbackResult = await withGatewayProxy( + async (port) => { + const res = await gatewayCallback("localhost", port); + return res; + }, + { + targetHost: inputs.targetHost, + targetPort: inputs.targetPort, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); + + return callbackResult; + }; + const login = async ({ identityId, jwt: serviceAccountJwt }: TLoginKubernetesAuthDTO) => { const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); if (!identityKubernetesAuth) { @@ -92,46 +137,69 @@ export const identityKubernetesAuthServiceFactory = ({ tokenReviewerJwt = serviceAccountJwt; } - const { data } = await axios - .post( - `${identityKubernetesAuth.kubernetesHost}/apis/authentication.k8s.io/v1/tokenreviews`, - { - apiVersion: "authentication.k8s.io/v1", - kind: "TokenReview", - spec: { - token: serviceAccountJwt, - ...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {}) - } - }, - { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${tokenReviewerJwt}` - }, - signal: AbortSignal.timeout(10000), - timeout: 10000, - // if ca cert, rejectUnauthorized: true - httpsAgent: new https.Agent({ - ca: caCert, - rejectUnauthorized: !!caCert - }) - } - ) - .catch((err) => { - if (err instanceof AxiosError) { - if (err.response) { - const { message } = err?.response?.data as unknown as { message?: string }; + const tokenReviewCallback = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => { + let baseUrl = `https://${host}`; - if (message) { - throw new UnauthorizedError({ - message, - name: "KubernetesTokenReviewRequestError" - }); + if (port) { + baseUrl += `:${port}`; + } + + const res = await axios + .post( + `${baseUrl}/apis/authentication.k8s.io/v1/tokenreviews`, + { + apiVersion: "authentication.k8s.io/v1", + kind: "TokenReview", + spec: { + token: serviceAccountJwt, + ...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {}) + } + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokenReviewerJwt}` + }, + signal: AbortSignal.timeout(10000), + timeout: 10000, + // if ca cert, rejectUnauthorized: true + httpsAgent: new https.Agent({ + ca: caCert, + rejectUnauthorized: !!caCert + }) + } + ) + .catch((err) => { + if (err instanceof AxiosError) { + if (err.response) { + const { message } = err?.response?.data as unknown as { message?: string }; + + if (message) { + throw new UnauthorizedError({ + message, + name: "KubernetesTokenReviewRequestError" + }); + } } } - } - throw err; - }); + throw err; + }); + + return res.data; + }; + + const [k8sHost, k8sPort] = identityKubernetesAuth.kubernetesHost.split(":"); + + const data = identityKubernetesAuth.gatewayId + ? await $gatewayProxyWrapper( + { + gatewayId: identityKubernetesAuth.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort ? Number(k8sPort) : 443 + }, + tokenReviewCallback + ) + : await tokenReviewCallback(); if ("error" in data.status) throw new UnauthorizedError({ message: data.status.error, name: "KubernetesTokenReviewError" }); @@ -222,6 +290,7 @@ export const identityKubernetesAuthServiceFactory = ({ const attachKubernetesAuth = async ({ identityId, + gatewayId, kubernetesHost, caCert, tokenReviewerJwt, @@ -280,6 +349,27 @@ export const identityKubernetesAuthServiceFactory = ({ return extractIPDetails(accessTokenTrustedIp.ipAddress); }); + if (gatewayId) { + const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: identityMembershipOrg.orgId }); + if (!gateway) { + throw new NotFoundError({ + message: `Gateway with ID ${gatewayId} not found` + }); + } + + const { permission: orgPermission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionGatewayActions.AttachGateways, + OrgPermissionSubjects.Gateway + ); + } + const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: identityMembershipOrg.orgId @@ -296,6 +386,7 @@ export const identityKubernetesAuthServiceFactory = ({ accessTokenMaxTTL, accessTokenTTL, accessTokenNumUsesLimit, + gatewayId, accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), encryptedKubernetesTokenReviewerJwt: tokenReviewerJwt ? encryptor({ plainText: Buffer.from(tokenReviewerJwt) }).cipherTextBlob @@ -318,6 +409,7 @@ export const identityKubernetesAuthServiceFactory = ({ allowedNamespaces, allowedNames, allowedAudience, + gatewayId, accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, @@ -373,11 +465,33 @@ export const identityKubernetesAuthServiceFactory = ({ return extractIPDetails(accessTokenTrustedIp.ipAddress); }); + if (gatewayId) { + const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: identityMembershipOrg.orgId }); + if (!gateway) { + throw new NotFoundError({ + message: `Gateway with ID ${gatewayId} not found` + }); + } + + const { permission: orgPermission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionGatewayActions.AttachGateways, + OrgPermissionSubjects.Gateway + ); + } + const updateQuery: TIdentityKubernetesAuthsUpdate = { kubernetesHost, allowedNamespaces, allowedNames, allowedAudience, + gatewayId, accessTokenMaxTTL, accessTokenTTL, accessTokenNumUsesLimit, diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts index b3bbcb49e..7a9cb88b5 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts @@ -13,6 +13,7 @@ export type TAttachKubernetesAuthDTO = { allowedNamespaces: string; allowedNames: string; allowedAudience: string; + gatewayId?: string | null; accessTokenTTL: number; accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; @@ -28,6 +29,7 @@ export type TUpdateKubernetesAuthDTO = { allowedNamespaces?: string; allowedNames?: string; allowedAudience?: string; + gatewayId?: string | null; accessTokenTTL?: number; accessTokenMaxTTL?: number; accessTokenNumUsesLimit?: number; diff --git a/docs/documentation/platform/gateways/overview.mdx b/docs/documentation/platform/gateways/overview.mdx index 7ccc098cd..ae4a3c7ad 100644 --- a/docs/documentation/platform/gateways/overview.mdx +++ b/docs/documentation/platform/gateways/overview.mdx @@ -158,14 +158,4 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t To confirm your Gateway is working, check the deployment status by looking for the message **"Gateway started successfully"** in the Gateway logs. This indicates the Gateway is running properly. Next, verify its registration by opening your Infisical dashboard, navigating to **Organization Access Control**, and selecting the **Gateways** tab. Your newly deployed Gateway should appear in the list. ![Gateway List](../../../images/platform/gateways/gateway-list.png) - - - To enable Infisical features like dynamic secrets or secret rotation to access private resources through the Gateway, you need to link the Gateway to the relevant projects. - - Start by accessing the **Gateway settings** then locate the Gateway in the list, click the options menu (**:**), and select **Edit Details**. - ![Edit Gateway Option](../../../images/platform/gateways/edit-gateway.png) - In the edit modal that appears, choose the projects you want the Gateway to access and click **Save** to confirm your selections. - ![Project Assignment Modal](../../../images/platform/gateways/assign-project.png) - Once added to a project, the Gateway becomes available for use by any feature that supports Gateways within that project. - diff --git a/docs/internals/permissions/organization-permissions.mdx b/docs/internals/permissions/organization-permissions.mdx index c68d845e2..6de3bd6fe 100644 --- a/docs/internals/permissions/organization-permissions.mdx +++ b/docs/internals/permissions/organization-permissions.mdx @@ -218,3 +218,4 @@ Supports conditions and permission inversion | `create-gateways` | Add new gateways to organization | | `edit-gateways` | Modify existing gateway settings | | `delete-gateways` | Remove gateways from organization | +| `attach-gateways` | Attach gateways to resources | diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 2dbfaacb7..a4bd202bf 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -12,7 +12,8 @@ export enum OrgGatewayPermissionActions { CreateGateways = "create-gateways", ListGateways = "list-gateways", EditGateways = "edit-gateways", - DeleteGateways = "delete-gateways" + DeleteGateways = "delete-gateways", + AttachGateways = "attach-gateways" } export enum OrgPermissionSubjects { diff --git a/frontend/src/hooks/api/gateways/mutation.tsx b/frontend/src/hooks/api/gateways/mutation.tsx index e93197fdd..ef292cb39 100644 --- a/frontend/src/hooks/api/gateways/mutation.tsx +++ b/frontend/src/hooks/api/gateways/mutation.tsx @@ -20,8 +20,8 @@ export const useDeleteGatewayById = () => { export const useUpdateGatewayById = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, name, projectIds }: TUpdateGatewayDTO) => { - return apiRequest.patch(`/api/v1/gateways/${id}`, { name, projectIds }); + mutationFn: ({ id, name }: TUpdateGatewayDTO) => { + return apiRequest.patch(`/api/v1/gateways/${id}`, { name }); }, onSuccess: () => { queryClient.invalidateQueries(gatewaysQueryKeys.list()); diff --git a/frontend/src/hooks/api/gateways/queries.tsx b/frontend/src/hooks/api/gateways/queries.tsx index 6ec374a6c..bb05b17a4 100644 --- a/frontend/src/hooks/api/gateways/queries.tsx +++ b/frontend/src/hooks/api/gateways/queries.tsx @@ -2,7 +2,7 @@ import { queryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TGateway, TListProjectGatewayDTO, TProjectGateway } from "./types"; +import { TGateway } from "./types"; export const gatewaysQueryKeys = { allKey: () => ["gateways"], @@ -14,20 +14,5 @@ export const gatewaysQueryKeys = { const { data } = await apiRequest.get<{ gateways: TGateway[] }>("/api/v1/gateways"); return data.gateways; } - }), - listProjectGatewayKey: ({ projectId }: TListProjectGatewayDTO) => [ - ...gatewaysQueryKeys.allKey(), - "list", - { projectId } - ], - listProjectGateways: ({ projectId }: TListProjectGatewayDTO) => - queryOptions({ - queryKey: gatewaysQueryKeys.listProjectGatewayKey({ projectId }), - queryFn: async () => { - const { data } = await apiRequest.get<{ gateways: TProjectGateway[] }>( - `/api/v1/gateways/projects/${projectId}` - ); - return data.gateways; - } }) }; diff --git a/frontend/src/hooks/api/gateways/types.ts b/frontend/src/hooks/api/gateways/types.ts index a522b6c48..6a3f2d673 100644 --- a/frontend/src/hooks/api/gateways/types.ts +++ b/frontend/src/hooks/api/gateways/types.ts @@ -11,39 +11,13 @@ export type TGateway = { name: string; id: string; }; - projects: { - name: string; - id: string; - slug: string; - }[]; -}; - -export type TProjectGateway = { - id: string; - identityId: string; - name: string; - createdAt: string; - updatedAt: string; - issuedAt: string; - serialNumber: string; - heartbeat: string; - projectGatewayId: string; - identity: { - name: string; - id: string; - }; }; export type TUpdateGatewayDTO = { id: string; name?: string; - projectIds?: string[]; }; export type TDeleteGatewayDTO = { id: string; }; - -export type TListProjectGatewayDTO = { - projectId: string; -}; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index a76b7ecac..748745986 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -840,7 +840,8 @@ export const useAddIdentityKubernetesAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + gatewayId }) => { const { data: { identityKubernetesAuth } @@ -856,7 +857,8 @@ export const useAddIdentityKubernetesAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + gatewayId } ); @@ -945,7 +947,8 @@ export const useUpdateIdentityKubernetesAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + gatewayId }) => { const { data: { identityKubernetesAuth } @@ -961,7 +964,8 @@ export const useUpdateIdentityKubernetesAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + gatewayId } ); diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index df6a01499..e31b39cbe 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -388,6 +388,7 @@ export type IdentityKubernetesAuth = { accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; accessTokenTrustedIps: IdentityTrustedIp[]; + gatewayId?: string | null; }; export type AddIdentityKubernetesAuthDTO = { @@ -398,6 +399,7 @@ export type AddIdentityKubernetesAuthDTO = { allowedNamespaces: string; allowedNames: string; allowedAudience: string; + gatewayId?: string | null; caCert: string; accessTokenTTL: number; accessTokenMaxTTL: number; @@ -415,6 +417,7 @@ export type UpdateIdentityKubernetesAuthDTO = { allowedNamespaces?: string; allowedNames?: string; allowedAudience?: string; + gatewayId?: string | null; caCert?: string; accessTokenTTL?: number; accessTokenMaxTTL?: number; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx index 87dc7dbfd..369977a04 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx @@ -3,22 +3,32 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; import { Button, FormControl, IconButton, Input, + Select, + SelectItem, Tab, TabList, TabPanel, Tabs, - TextArea + TextArea, + Tooltip } from "@app/components/v2"; import { useOrganization, useSubscription } from "@app/context"; import { + OrgGatewayPermissionActions, + OrgPermissionSubjects +} from "@app/context/OrgPermissionContext/types"; +import { + gatewaysQueryKeys, useAddIdentityKubernetesAuth, useGetIdentityKubernetesAuth, useUpdateIdentityKubernetesAuth @@ -32,6 +42,7 @@ const schema = z .object({ kubernetesHost: z.string().min(1), tokenReviewerJwt: z.string().optional(), + gatewayId: z.string().optional().nullable(), allowedNames: z.string(), allowedNamespaces: z.string(), allowedAudience: z.string(), @@ -79,6 +90,8 @@ export const IdentityKubernetesAuthForm = ({ const { mutateAsync: updateMutateAsync } = useUpdateIdentityKubernetesAuth(); const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); + const { data: gateways, isPending: isGatewayLoading } = useQuery(gatewaysQueryKeys.list()); + const { data } = useGetIdentityKubernetesAuth(identityId ?? "", { enabled: isUpdate }); @@ -96,6 +109,7 @@ export const IdentityKubernetesAuthForm = ({ tokenReviewerJwt: "", allowedNames: "", allowedNamespaces: "", + gatewayId: "", allowedAudience: "", caCert: "", accessTokenTTL: "2592000", @@ -120,6 +134,7 @@ export const IdentityKubernetesAuthForm = ({ allowedNamespaces: data.allowedNamespaces, allowedAudience: data.allowedAudience, caCert: data.caCert, + gatewayId: data.gatewayId || null, accessTokenTTL: String(data.accessTokenTTL), accessTokenMaxTTL: String(data.accessTokenMaxTTL), accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit), @@ -157,6 +172,7 @@ export const IdentityKubernetesAuthForm = ({ accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, + gatewayId, accessTokenTrustedIps }: FormData) => { try { @@ -172,6 +188,7 @@ export const IdentityKubernetesAuthForm = ({ allowedAudience, caCert, identityId, + gatewayId: gatewayId || null, accessTokenTTL: Number(accessTokenTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL), accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), @@ -186,6 +203,7 @@ export const IdentityKubernetesAuthForm = ({ allowedNames: allowedNames || "", allowedNamespaces: allowedNamespaces || "", allowedAudience: allowedAudience || "", + gatewayId: gatewayId || null, caCert: caCert || "", accessTokenTTL: Number(accessTokenTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL), @@ -217,6 +235,7 @@ export const IdentityKubernetesAuthForm = ({ [ "kubernetesHost", "tokenReviewerJwt", + "gatewayId", "accessTokenTTL", "accessTokenMaxTTL", "accessTokenNumUsesLimit", @@ -280,6 +299,62 @@ export const IdentityKubernetesAuthForm = ({ )} /> + + + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
+ Name Cert Issued At - Projects Identity Health Check @@ -151,13 +149,6 @@ export const GatewayListPage = withPermission( {el.name} {format(new Date(el.issuedAt), "yyyy-MM-dd hh:mm:ss aaa")} - - {el.projects.map((projectDetails) => ( - - {projectDetails.name} - - ))} - {el.identity.name} {el.heartbeat diff --git a/frontend/src/pages/organization/Gateways/GatewayListPage/components/EditGatewayDetailsModal.tsx b/frontend/src/pages/organization/Gateways/GatewayListPage/components/EditGatewayDetailsModal.tsx index d41fd5489..1a0d41b4c 100644 --- a/frontend/src/pages/organization/Gateways/GatewayListPage/components/EditGatewayDetailsModal.tsx +++ b/frontend/src/pages/organization/Gateways/GatewayListPage/components/EditGatewayDetailsModal.tsx @@ -3,10 +3,10 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2"; -import { useGetUserWorkspaces, useUpdateGatewayById } from "@app/hooks/api"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2"; +import { useUpdateGatewayById } from "@app/hooks/api"; import { TGateway } from "@app/hooks/api/gateways/types"; -import { ProjectType } from "@app/hooks/api/workspace/types"; type Props = { gatewayDetails: TGateway; @@ -14,13 +14,7 @@ type Props = { }; const schema = z.object({ - name: z.string(), - projects: z - .object({ - id: z.string(), - name: z.string() - }) - .array() + name: z.string() }); export type FormData = z.infer; @@ -38,20 +32,13 @@ export const EditGatewayDetailsModal = ({ gatewayDetails, onClose }: Props) => { }); const updateGatewayById = useUpdateGatewayById(); - // when gateway goes to other products switch to all - const { data: secretManagerWorkspaces, isLoading: isSecretManagerLoading } = useGetUserWorkspaces( - { - type: ProjectType.SecretManager - } - ); - const onFormSubmit = ({ name, projects }: FormData) => { + const onFormSubmit = ({ name }: FormData) => { if (isSubmitting) return; updateGatewayById.mutate( { id: gatewayDetails.id, - name, - projectIds: projects.map((el) => el.id) + name }, { onSuccess: () => { @@ -67,6 +54,16 @@ export const EditGatewayDetailsModal = ({ gatewayDetails, onClose }: Props) => { return (
+ +

+ Since the 15th May 2025, all gateways are automatically available for use in all projects + and you no longer need to link them. +
+ Organization members with the "Attach Gateways" permission can use gateways + anywhere within the organization. +

+
+ { )} /> - ( - - option.id} - getOptionLabel={(option) => option.name} - /> - - )} - />