diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 5d47ec9c5f2..f9f5d8520bc 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -19,6 +19,7 @@ import { TEmailDomainServiceFactory } from "@app/ee/services/email-domain/email- import { TEventBusService as TInternalEventBusService } from "@app/ee/services/event-bus"; import { TExternalKmsServiceFactory } from "@app/ee/services/external-kms/external-kms-service"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayPoolServiceFactory } from "@app/ee/services/gateway-pool/gateway-pool-service"; import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { TGithubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service"; import { TGroupServiceFactory } from "@app/ee/services/group/group-service"; @@ -369,6 +370,7 @@ declare module "fastify" { insights: TInsightsServiceFactory; relay: TRelayServiceFactory; gatewayV2: TGatewayV2ServiceFactory; + gatewayPool: TGatewayPoolServiceFactory; githubOrgSync: TGithubOrgSyncServiceFactory; folderCommit: TFolderCommitServiceFactory; pit: TPitServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 1ad9dfdc2c9..ae0b7a0e1d1 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -164,6 +164,12 @@ import { TGatewayEnrollmentTokens, TGatewayEnrollmentTokensInsert, TGatewayEnrollmentTokensUpdate, + TGatewayPoolMemberships, + TGatewayPoolMembershipsInsert, + TGatewayPoolMembershipsUpdate, + TGatewayPools, + TGatewayPoolsInsert, + TGatewayPoolsUpdate, TGateways, TGatewaysInsert, TGatewaysUpdate, @@ -1596,6 +1602,12 @@ declare module "knex/types/tables" { TGatewayEnrollmentTokensInsert, TGatewayEnrollmentTokensUpdate >; + [TableName.GatewayPool]: KnexOriginal.CompositeTableType; + [TableName.GatewayPoolMembership]: KnexOriginal.CompositeTableType< + TGatewayPoolMemberships, + TGatewayPoolMembershipsInsert, + TGatewayPoolMembershipsUpdate + >; [TableName.UserNotifications]: KnexOriginal.CompositeTableType< TUserNotifications, TUserNotificationsInsert, diff --git a/backend/src/db/migrations/20260414000001_add-gateway-pools.ts b/backend/src/db/migrations/20260414000001_add-gateway-pools.ts new file mode 100644 index 00000000000..ad9ced4acd6 --- /dev/null +++ b/backend/src/db/migrations/20260414000001_add-gateway-pools.ts @@ -0,0 +1,60 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + // Create gateway_pools table + if (!(await knex.schema.hasTable(TableName.GatewayPool))) { + await knex.schema.createTable(TableName.GatewayPool, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.string("name", 32).notNullable(); + t.timestamps(true, true, true); + t.unique(["orgId", "name"]); + }); + + await createOnUpdateTrigger(knex, TableName.GatewayPool); + } + + // Create gateway_pool_memberships join table + if (!(await knex.schema.hasTable(TableName.GatewayPoolMembership))) { + await knex.schema.createTable(TableName.GatewayPoolMembership, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("gatewayPoolId").notNullable(); + t.foreign("gatewayPoolId").references("id").inTable(TableName.GatewayPool).onDelete("CASCADE"); + t.uuid("gatewayId").notNullable(); + t.foreign("gatewayId").references("id").inTable(TableName.GatewayV2).onDelete("CASCADE"); + t.timestamps(true, true, true); + t.unique(["gatewayPoolId", "gatewayId"]); + }); + + await createOnUpdateTrigger(knex, TableName.GatewayPoolMembership); + } + + // Add gatewayPoolId to identity_kubernetes_auths + const hasGatewayPoolId = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "gatewayPoolId"); + if (!hasGatewayPoolId) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (t) => { + t.uuid("gatewayPoolId").nullable(); + t.foreign("gatewayPoolId").references("id").inTable(TableName.GatewayPool).onDelete("SET NULL"); + }); + } +} + +export async function down(knex: Knex): Promise { + // Remove gatewayPoolId from identity_kubernetes_auths + const hasGatewayPoolId = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "gatewayPoolId"); + if (hasGatewayPoolId) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (t) => { + t.dropColumn("gatewayPoolId"); + }); + } + + await dropOnUpdateTrigger(knex, TableName.GatewayPoolMembership); + await knex.schema.dropTableIfExists(TableName.GatewayPoolMembership); + + await dropOnUpdateTrigger(knex, TableName.GatewayPool); + await knex.schema.dropTableIfExists(TableName.GatewayPool); +} diff --git a/backend/src/db/schemas/gateway-pool-memberships.ts b/backend/src/db/schemas/gateway-pool-memberships.ts new file mode 100644 index 00000000000..53b636ca728 --- /dev/null +++ b/backend/src/db/schemas/gateway-pool-memberships.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const GatewayPoolMembershipsSchema = z.object({ + id: z.string().uuid(), + gatewayPoolId: z.string().uuid(), + gatewayId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TGatewayPoolMemberships = z.infer; +export type TGatewayPoolMembershipsInsert = Omit, TImmutableDBKeys>; +export type TGatewayPoolMembershipsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/gateway-pools.ts b/backend/src/db/schemas/gateway-pools.ts new file mode 100644 index 00000000000..8da7e271a5e --- /dev/null +++ b/backend/src/db/schemas/gateway-pools.ts @@ -0,0 +1,20 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const GatewayPoolsSchema = z.object({ + id: z.string().uuid(), + orgId: z.string().uuid(), + name: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TGatewayPools = z.infer; +export type TGatewayPoolsInsert = Omit, TImmutableDBKeys>; +export type TGatewayPoolsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-kubernetes-auths.ts b/backend/src/db/schemas/identity-kubernetes-auths.ts index 4789ef36593..e9cfe9c4b04 100644 --- a/backend/src/db/schemas/identity-kubernetes-auths.ts +++ b/backend/src/db/schemas/identity-kubernetes-auths.ts @@ -33,7 +33,8 @@ export const IdentityKubernetesAuthsSchema = z.object({ gatewayId: z.string().uuid().nullable().optional(), accessTokenPeriod: z.coerce.number().default(0), tokenReviewMode: z.string().default("api"), - gatewayV2Id: z.string().uuid().nullable().optional() + gatewayV2Id: z.string().uuid().nullable().optional(), + gatewayPoolId: z.string().uuid().nullable().optional() }); export type TIdentityKubernetesAuths = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index c2c8367e555..a7b8becba01 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -54,6 +54,8 @@ export * from "./folder-commits"; export * from "./folder-tree-checkpoint-resources"; export * from "./folder-tree-checkpoints"; export * from "./gateway-enrollment-tokens"; +export * from "./gateway-pool-memberships"; +export * from "./gateway-pools"; export * from "./gateways"; export * from "./gateways-v2"; export * from "./git-app-install-sessions"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 47758b2db83..cbcfedb8c43 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -221,6 +221,8 @@ export enum TableName { Relay = "relays", GatewayV2 = "gateways_v2", GatewayEnrollmentTokens = "gateway_enrollment_tokens", + GatewayPool = "gateway_pools", + GatewayPoolMembership = "gateway_pool_memberships", KeyValueStore = "key_value_store", diff --git a/backend/src/ee/routes/v2/gateway-pool-router.ts b/backend/src/ee/routes/v2/gateway-pool-router.ts new file mode 100644 index 00000000000..0382acf73fe --- /dev/null +++ b/backend/src/ee/routes/v2/gateway-pool-router.ts @@ -0,0 +1,310 @@ +import z from "zod"; + +import { GatewayPoolMembershipsSchema, GatewayPoolsSchema, GatewaysV2Schema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +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"; + +const SanitizedGatewayPoolSchema = GatewayPoolsSchema.pick({ + id: true, + orgId: true, + name: true, + createdAt: true, + updatedAt: true +}); + +const SanitizedPoolMemberSchema = GatewaysV2Schema.pick({ + id: true, + name: true, + heartbeat: true, + lastHealthCheckStatus: true +}); + +export const registerGatewayPoolRouter = async (server: FastifyZodProvider) => { + // Create a gateway pool + server.route({ + method: "POST", + url: "/", + schema: { + operationId: "createGatewayPool", + body: z.object({ + name: slugSchema({ min: 1, max: 32, field: "name" }).describe("Name for the gateway pool") + }), + response: { + 200: SanitizedGatewayPoolSchema + } + }, + config: { rateLimit: writeLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const pool = await server.services.gatewayPool.createGatewayPool({ + name: req.body.name, + ...req.permission + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GATEWAY_POOL_CREATE, + metadata: { + poolId: pool.id, + name: pool.name + } + } + }); + + return pool; + } + }); + + // List gateway pools + server.route({ + method: "GET", + url: "/", + schema: { + operationId: "listGatewayPools", + response: { + 200: z.array( + SanitizedGatewayPoolSchema.extend({ + memberCount: z.number(), + healthyMemberCount: z.number(), + memberGatewayIds: z.array(z.string().uuid()), + connectedResourcesCount: z.number() + }) + ) + } + }, + config: { rateLimit: readLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + return server.services.gatewayPool.listGatewayPools(req.permission); + } + }); + + // Get gateway pool by ID + server.route({ + method: "GET", + url: "/:poolId", + schema: { + operationId: "getGatewayPoolById", + params: z.object({ + poolId: z.string().uuid() + }), + response: { + 200: SanitizedGatewayPoolSchema.extend({ + gateways: z.array(SanitizedPoolMemberSchema) + }) + } + }, + config: { rateLimit: readLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + return server.services.gatewayPool.getGatewayPoolById({ + poolId: req.params.poolId, + ...req.permission + }); + } + }); + + // Update gateway pool + server.route({ + method: "PATCH", + url: "/:poolId", + schema: { + operationId: "updateGatewayPool", + params: z.object({ + poolId: z.string().uuid() + }), + body: z.object({ + name: slugSchema({ min: 1, max: 32, field: "name" }).optional().describe("New name for the pool") + }), + response: { + 200: SanitizedGatewayPoolSchema + } + }, + config: { rateLimit: writeLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const pool = await server.services.gatewayPool.updateGatewayPool({ + poolId: req.params.poolId, + name: req.body.name, + ...req.permission + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GATEWAY_POOL_UPDATE, + metadata: { + poolId: pool.id, + name: pool.name + } + } + }); + + return pool; + } + }); + + // Delete gateway pool + server.route({ + method: "DELETE", + url: "/:poolId", + schema: { + operationId: "deleteGatewayPool", + params: z.object({ + poolId: z.string().uuid() + }), + response: { + 200: SanitizedGatewayPoolSchema + } + }, + config: { rateLimit: writeLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const pool = await server.services.gatewayPool.deleteGatewayPool({ + poolId: req.params.poolId, + ...req.permission + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GATEWAY_POOL_DELETE, + metadata: { + poolId: pool.id, + name: pool.name + } + } + }); + + return pool; + } + }); + + // Add gateway to pool + server.route({ + method: "POST", + url: "/:poolId/memberships", + schema: { + operationId: "addGatewayToPool", + params: z.object({ + poolId: z.string().uuid() + }), + body: z.object({ + gatewayId: z.string().uuid().describe("ID of the gateway to add to the pool") + }), + response: { + 200: GatewayPoolMembershipsSchema.pick({ + id: true, + gatewayPoolId: true, + gatewayId: true, + createdAt: true + }) + } + }, + config: { rateLimit: writeLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const membership = await server.services.gatewayPool.addGatewayToPool({ + poolId: req.params.poolId, + gatewayId: req.body.gatewayId, + ...req.permission + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GATEWAY_POOL_ADD_MEMBER, + metadata: { + poolId: req.params.poolId, + gatewayId: req.body.gatewayId + } + } + }); + + return membership; + } + }); + + // Remove gateway from pool + server.route({ + method: "DELETE", + url: "/:poolId/memberships/:gatewayId", + schema: { + operationId: "removeGatewayFromPool", + params: z.object({ + poolId: z.string().uuid(), + gatewayId: z.string().uuid() + }), + response: { + 200: GatewayPoolMembershipsSchema.pick({ + id: true, + gatewayPoolId: true, + gatewayId: true, + createdAt: true + }) + } + }, + config: { rateLimit: writeLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const membership = await server.services.gatewayPool.removeGatewayFromPool({ + poolId: req.params.poolId, + gatewayId: req.params.gatewayId, + ...req.permission + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GATEWAY_POOL_REMOVE_MEMBER, + metadata: { + poolId: req.params.poolId, + gatewayId: req.params.gatewayId + } + } + }); + + return membership; + } + }); + + // Get connected resources for a pool + server.route({ + method: "GET", + url: "/:poolId/resources", + schema: { + operationId: "getGatewayPoolConnectedResources", + params: z.object({ + poolId: z.string().uuid() + }), + response: { + 200: z.object({ + kubernetesAuths: z.array( + z.object({ + id: z.string(), + identityId: z.string(), + identityName: z.string().nullable() + }) + ) + }) + } + }, + config: { rateLimit: readLimit }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + return server.services.gatewayPool.getConnectedResources({ + poolId: req.params.poolId, + ...req.permission + }); + } + }); +}; diff --git a/backend/src/ee/routes/v2/index.ts b/backend/src/ee/routes/v2/index.ts index 0119f3ec4ad..01a306e576a 100644 --- a/backend/src/ee/routes/v2/index.ts +++ b/backend/src/ee/routes/v2/index.ts @@ -8,6 +8,7 @@ import { } from "@app/ee/routes/v2/secret-scanning-v2-routers"; import { registerDeprecatedProjectRoleRouter } from "./deprecated-project-role-router"; +import { registerGatewayPoolRouter } from "./gateway-pool-router"; import { registerGatewayV2Router } from "./gateway-router"; import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router"; @@ -28,6 +29,8 @@ export const registerV2EERoutes = async (server: FastifyZodProvider) => { await server.register(registerGatewayV2Router, { prefix: "/gateways" }); + await server.register(registerGatewayPoolRouter, { prefix: "/gateway-pools" }); + await server.register(registerSecretApprovalPolicyRouter, { prefix: "/secret-approvals" }); await server.register( diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index d30a1e3a33b..f4cb65bc5fe 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -760,7 +760,14 @@ export enum EventType { // Gateway Enrollment Tokens GATEWAY_CREATE = "gateway-create", GATEWAY_ENROLLMENT_TOKEN_CREATE = "gateway-enrollment-token-create", - GATEWAY_ENROLL = "gateway-enroll" + GATEWAY_ENROLL = "gateway-enroll", + + // Gateway Pools + GATEWAY_POOL_CREATE = "gateway-pool-create", + GATEWAY_POOL_UPDATE = "gateway-pool-update", + GATEWAY_POOL_DELETE = "gateway-pool-delete", + GATEWAY_POOL_ADD_MEMBER = "gateway-pool-add-member", + GATEWAY_POOL_REMOVE_MEMBER = "gateway-pool-remove-member" } // Maps each actor type to the JSONB key that holds the actor's primary ID in actorMetadata. @@ -6005,6 +6012,46 @@ interface GatewayEnrollEvent { }; } +interface GatewayPoolCreateEvent { + type: EventType.GATEWAY_POOL_CREATE; + metadata: { + poolId: string; + name: string; + }; +} + +interface GatewayPoolUpdateEvent { + type: EventType.GATEWAY_POOL_UPDATE; + metadata: { + poolId: string; + name: string; + }; +} + +interface GatewayPoolDeleteEvent { + type: EventType.GATEWAY_POOL_DELETE; + metadata: { + poolId: string; + name: string; + }; +} + +interface GatewayPoolAddMemberEvent { + type: EventType.GATEWAY_POOL_ADD_MEMBER; + metadata: { + poolId: string; + gatewayId: string; + }; +} + +interface GatewayPoolRemoveMemberEvent { + type: EventType.GATEWAY_POOL_REMOVE_MEMBER; + metadata: { + poolId: string; + gatewayId: string; + }; +} + export type Event = | CreateSubOrganizationEvent | UpdateSubOrganizationEvent @@ -6549,4 +6596,9 @@ export type Event = | DeleteEmailDomainEvent | GatewayCreateEvent | GatewayEnrollmentTokenCreateEvent - | GatewayEnrollEvent; + | GatewayEnrollEvent + | GatewayPoolCreateEvent + | GatewayPoolUpdateEvent + | GatewayPoolDeleteEvent + | GatewayPoolAddMemberEvent + | GatewayPoolRemoveMemberEvent; diff --git a/backend/src/ee/services/gateway-pool/gateway-pool-dal.ts b/backend/src/ee/services/gateway-pool/gateway-pool-dal.ts new file mode 100644 index 00000000000..118b2942936 --- /dev/null +++ b/backend/src/ee/services/gateway-pool/gateway-pool-dal.ts @@ -0,0 +1,94 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +import { GATEWAY_HEARTBEAT_TIMEOUT_MS } from "../gateway-v2/gateway-v2-constants"; +import { GatewayHealthCheckStatus } from "../gateway-v2/gateway-v2-types"; + +export type TGatewayPoolDALFactory = ReturnType; + +export const gatewayPoolDalFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.GatewayPool); + + const findByOrgIdWithDetails = async (orgId: string) => { + try { + const oneHourAgo = new Date(Date.now() - GATEWAY_HEARTBEAT_TIMEOUT_MS); + + const pools = await db + .replicaNode()(TableName.GatewayPool) + .where(`${TableName.GatewayPool}.orgId`, orgId) + .leftJoin( + TableName.GatewayPoolMembership, + `${TableName.GatewayPool}.id`, + `${TableName.GatewayPoolMembership}.gatewayPoolId` + ) + .leftJoin(TableName.GatewayV2, `${TableName.GatewayPoolMembership}.gatewayId`, `${TableName.GatewayV2}.id`) + .select(selectAllTableCols(TableName.GatewayPool)) + .select( + db.raw(`COUNT(DISTINCT ${TableName.GatewayPoolMembership}."gatewayId") AS "memberCount"`), + db.raw( + `COUNT(DISTINCT CASE WHEN ${TableName.GatewayV2}."heartbeat" > ? AND (${TableName.GatewayV2}."lastHealthCheckStatus" IS NULL OR ${TableName.GatewayV2}."lastHealthCheckStatus" != ?) THEN ${TableName.GatewayPoolMembership}."gatewayId" END) AS "healthyMemberCount"`, + [oneHourAgo, GatewayHealthCheckStatus.Failed] + ), + db.raw( + `COALESCE(array_agg(DISTINCT ${TableName.GatewayPoolMembership}."gatewayId") FILTER (WHERE ${TableName.GatewayPoolMembership}."gatewayId" IS NOT NULL), '{}') AS "memberGatewayIds"` + ) + ) + .groupBy(`${TableName.GatewayPool}.id`) + .orderBy(`${TableName.GatewayPool}.name`, "asc"); + + return pools.map((p) => { + const raw = p as Record; + return { + ...p, + memberCount: Number(raw.memberCount ?? 0), + healthyMemberCount: Number(raw.healthyMemberCount ?? 0), + memberGatewayIds: (raw.memberGatewayIds as string[]) ?? [] + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.GatewayPool}: FindByOrgId` }); + } + }; + + const findByIdWithMembers = async (poolId: string, orgId: string) => { + try { + const pool = await db + .replicaNode()(TableName.GatewayPool) + .where(`${TableName.GatewayPool}.id`, poolId) + .where(`${TableName.GatewayPool}.orgId`, orgId) + .first(); + + if (!pool) return null; + + const members = await db + .replicaNode()(TableName.GatewayPoolMembership) + .where(`${TableName.GatewayPoolMembership}.gatewayPoolId`, poolId) + .join(TableName.GatewayV2, `${TableName.GatewayPoolMembership}.gatewayId`, `${TableName.GatewayV2}.id`) + .select( + `${TableName.GatewayV2}.id`, + `${TableName.GatewayV2}.name`, + `${TableName.GatewayV2}.heartbeat`, + `${TableName.GatewayV2}.lastHealthCheckStatus` + ); + + return { ...pool, gateways: members }; + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.GatewayPool}: FindByIdWithMembers` }); + } + }; + + const countByOrgId = async (orgId: string, tx?: Knex) => { + try { + const result = await (tx || db.replicaNode())(TableName.GatewayPool).where({ orgId }).count("id").first(); + return parseInt(String(result?.count || "0"), 10); + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.GatewayPool}: CountByOrgId` }); + } + }; + + return { ...orm, findByOrgIdWithDetails, findByIdWithMembers, countByOrgId }; +}; diff --git a/backend/src/ee/services/gateway-pool/gateway-pool-membership-dal.ts b/backend/src/ee/services/gateway-pool/gateway-pool-membership-dal.ts new file mode 100644 index 00000000000..d3a4b7b8f8e --- /dev/null +++ b/backend/src/ee/services/gateway-pool/gateway-pool-membership-dal.ts @@ -0,0 +1,37 @@ +import { TDbClient } from "@app/db"; +import { TableName, TGatewaysV2 } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { GATEWAY_HEARTBEAT_TIMEOUT_MS } from "../gateway-v2/gateway-v2-constants"; +import { GatewayHealthCheckStatus } from "../gateway-v2/gateway-v2-types"; + +export type TGatewayPoolMembershipDALFactory = ReturnType; + +export const gatewayPoolMembershipDalFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.GatewayPoolMembership); + + const findHealthyGatewaysByPoolId = async (poolId: string): Promise => { + try { + const oneHourAgo = new Date(Date.now() - GATEWAY_HEARTBEAT_TIMEOUT_MS); + + const gateways = await db + .replicaNode()(TableName.GatewayPoolMembership) + .where(`${TableName.GatewayPoolMembership}.gatewayPoolId`, poolId) + .join(TableName.GatewayV2, `${TableName.GatewayPoolMembership}.gatewayId`, `${TableName.GatewayV2}.id`) + .where(`${TableName.GatewayV2}.heartbeat`, ">", oneHourAgo) + .where((builder) => { + void builder + .whereNull(`${TableName.GatewayV2}.lastHealthCheckStatus`) + .orWhereNot(`${TableName.GatewayV2}.lastHealthCheckStatus`, GatewayHealthCheckStatus.Failed); + }) + .select(`${TableName.GatewayV2}.*`); + + return gateways as TGatewaysV2[]; + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.GatewayPoolMembership}: FindHealthyGateways` }); + } + }; + + return { ...orm, findHealthyGatewaysByPoolId }; +}; diff --git a/backend/src/ee/services/gateway-pool/gateway-pool-service.ts b/backend/src/ee/services/gateway-pool/gateway-pool-service.ts new file mode 100644 index 00000000000..51b89064cbe --- /dev/null +++ b/backend/src/ee/services/gateway-pool/gateway-pool-service.ts @@ -0,0 +1,298 @@ +import { ForbiddenError } from "@casl/ability"; + +import { OrganizationActionScope } from "@app/db/schemas"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; +import { TIdentityKubernetesAuthDALFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-dal"; + +import { TGatewayV2DALFactory } from "../gateway-v2/gateway-v2-dal"; +import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; +import { TGatewayV2ConnectionDetails } from "../gateway-v2/gateway-v2-types"; +import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionGatewayPoolActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service-types"; +import { TGatewayPoolDALFactory } from "./gateway-pool-dal"; +import { TGatewayPoolMembershipDALFactory } from "./gateway-pool-membership-dal"; +import { + TAddGatewayToPoolDTO, + TCreateGatewayPoolDTO, + TDeleteGatewayPoolDTO, + TGetGatewayPoolByIdDTO, + TGetPlatformConnectionDetailsByPoolIdDTO, + TListGatewayPoolsDTO, + TRemoveGatewayFromPoolDTO, + TUpdateGatewayPoolDTO +} from "./gateway-pool-types"; + +type TGatewayPoolServiceFactoryDep = { + gatewayPoolDAL: TGatewayPoolDALFactory; + gatewayPoolMembershipDAL: TGatewayPoolMembershipDALFactory; + gatewayV2DAL: Pick; + gatewayV2Service: Pick; + permissionService: TPermissionServiceFactory; + licenseService: Pick; + identityKubernetesAuthDAL: Pick; +}; + +export type TGatewayPoolServiceFactory = ReturnType; + +export const gatewayPoolServiceFactory = ({ + gatewayPoolDAL, + gatewayPoolMembershipDAL, + gatewayV2DAL, + gatewayV2Service, + permissionService, + licenseService, + identityKubernetesAuthDAL +}: TGatewayPoolServiceFactoryDep) => { + const $checkPermission = async (actor: OrgServiceActor, action: OrgPermissionGatewayPoolActions) => { + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(action, OrgPermissionSubjects.GatewayPool); + }; + + const $checkLicense = async (orgId: string) => { + const plan = await licenseService.getPlan(orgId); + if (!plan.gatewayPool) { + throw new BadRequestError({ + message: "Your current plan does not support gateway pools. Please upgrade to an Enterprise plan." + }); + } + }; + + const createGatewayPool = async ({ name, ...actor }: TCreateGatewayPoolDTO) => { + await $checkPermission(actor, OrgPermissionGatewayPoolActions.CreateGatewayPools); + await $checkLicense(actor.orgId); + + try { + const pool = await gatewayPoolDAL.create({ + orgId: actor.orgId, + name + }); + return pool; + } catch (error) { + if ( + error instanceof DatabaseError && + (error as DatabaseError & { code?: string }).code === DatabaseErrorCode.UniqueViolation + ) { + throw new BadRequestError({ + message: `A gateway pool named "${name}" already exists in this organization.` + }); + } + throw error; + } + }; + + const listGatewayPools = async (actor: TListGatewayPoolsDTO) => { + await $checkPermission(actor, OrgPermissionGatewayPoolActions.ListGatewayPools); + await $checkLicense(actor.orgId); + + const pools = await gatewayPoolDAL.findByOrgIdWithDetails(actor.orgId); + + if (pools.length === 0) return []; + + // Add more DAL counts here as pool support expands to other consumers + const [k8sAuthCounts] = await Promise.all([ + Promise.all( + pools.map((pool) => + identityKubernetesAuthDAL.countByGatewayPoolId(pool.id).then((count) => ({ id: pool.id, count })) + ) + ) + ]); + + const countMap = new Map(); + for (const { id, count } of k8sAuthCounts) { + countMap.set(id, (countMap.get(id) ?? 0) + count); + } + + return pools.map((pool) => ({ + ...pool, + connectedResourcesCount: countMap.get(pool.id) ?? 0 + })); + }; + + const getGatewayPoolById = async ({ poolId, ...actor }: TGetGatewayPoolByIdDTO) => { + await $checkPermission(actor, OrgPermissionGatewayPoolActions.ListGatewayPools); + await $checkLicense(actor.orgId); + + const pool = await gatewayPoolDAL.findByIdWithMembers(poolId, actor.orgId); + if (!pool) { + throw new NotFoundError({ message: `Gateway pool with ID ${poolId} not found` }); + } + + return pool; + }; + + const updateGatewayPool = async ({ poolId, name, ...actor }: TUpdateGatewayPoolDTO) => { + await $checkPermission(actor, OrgPermissionGatewayPoolActions.EditGatewayPools); + await $checkLicense(actor.orgId); + + const existingPool = await gatewayPoolDAL.findById(poolId); + if (!existingPool || existingPool.orgId !== actor.orgId) { + throw new NotFoundError({ message: `Gateway pool with ID ${poolId} not found` }); + } + + try { + const updated = await gatewayPoolDAL.updateById(poolId, { + ...(name !== undefined && { name }) + }); + return updated; + } catch (error) { + if ( + error instanceof DatabaseError && + (error as DatabaseError & { code?: string }).code === DatabaseErrorCode.UniqueViolation + ) { + throw new BadRequestError({ message: `A gateway pool named "${name}" already exists in this organization.` }); + } + throw error; + } + }; + + const deleteGatewayPool = async ({ poolId, ...actor }: TDeleteGatewayPoolDTO) => { + await $checkPermission(actor, OrgPermissionGatewayPoolActions.DeleteGatewayPools); + await $checkLicense(actor.orgId); + + const existingPool = await gatewayPoolDAL.findById(poolId); + if (!existingPool || existingPool.orgId !== actor.orgId) { + throw new NotFoundError({ message: `Gateway pool with ID ${poolId} not found` }); + } + + await gatewayPoolDAL.transaction(async (tx) => { + // Check for referencing consumer configs inside transaction to prevent race conditions + // Add more DAL counts here as pool support expands + const k8sAuthCount = await identityKubernetesAuthDAL.countByGatewayPoolId(poolId, tx); + const totalReferences = k8sAuthCount; + if (totalReferences > 0) { + throw new BadRequestError({ + message: `Cannot delete pool "${existingPool.name}" because it is referenced by ${totalReferences} consumer configuration(s). Remove the pool reference from those configs first.` + }); + } + + await gatewayPoolDAL.deleteById(poolId, tx); + }); + + return existingPool; + }; + + const addGatewayToPool = async ({ poolId, gatewayId, ...actor }: TAddGatewayToPoolDTO) => { + await $checkPermission(actor, OrgPermissionGatewayPoolActions.EditGatewayPools); + await $checkLicense(actor.orgId); + + const pool = await gatewayPoolDAL.findById(poolId); + if (!pool || pool.orgId !== actor.orgId) { + throw new NotFoundError({ message: `Gateway pool with ID ${poolId} not found` }); + } + + const gateway = await gatewayV2DAL.findById(gatewayId); + if (!gateway || gateway.orgId !== actor.orgId) { + throw new NotFoundError({ message: `Gateway with ID ${gatewayId} not found` }); + } + + try { + const membership = await gatewayPoolMembershipDAL.create({ gatewayPoolId: poolId, gatewayId }); + return membership; + } catch (error) { + if ( + error instanceof DatabaseError && + (error as DatabaseError & { code?: string }).code === DatabaseErrorCode.UniqueViolation + ) { + throw new BadRequestError({ message: "This gateway is already a member of the pool." }); + } + throw error; + } + }; + + const removeGatewayFromPool = async ({ poolId, gatewayId, ...actor }: TRemoveGatewayFromPoolDTO) => { + await $checkPermission(actor, OrgPermissionGatewayPoolActions.EditGatewayPools); + await $checkLicense(actor.orgId); + + const pool = await gatewayPoolDAL.findById(poolId); + if (!pool || pool.orgId !== actor.orgId) { + throw new NotFoundError({ message: `Gateway pool with ID ${poolId} not found` }); + } + + const [deleted] = await gatewayPoolMembershipDAL.delete({ gatewayPoolId: poolId, gatewayId }); + if (!deleted) { + throw new NotFoundError({ message: "Gateway is not a member of this pool." }); + } + + return deleted; + }; + + const pickRandomHealthyGateway = async (poolId: string) => { + const healthyGateways = await gatewayPoolMembershipDAL.findHealthyGatewaysByPoolId(poolId); + if (healthyGateways.length === 0) { + throw new BadRequestError({ + message: "Gateway pool has no healthy gateways." + }); + } + const selected = healthyGateways[Math.floor(Math.random() * healthyGateways.length)]; + logger.info( + { poolId, selectedGatewayId: selected.id }, + `Pool gateway selection: picked gateway [gatewayId=${selected.id}] from pool [poolId=${poolId}]` + ); + return selected; + }; + + const getPlatformConnectionDetailsByPoolId = async ({ + poolId, + targetHost, + targetPort + }: TGetPlatformConnectionDetailsByPoolIdDTO): Promise => { + const pool = await gatewayPoolDAL.findById(poolId); + if (!pool) { + throw new NotFoundError({ message: `Gateway pool with ID ${poolId} not found` }); + } + + const selectedGateway = await pickRandomHealthyGateway(poolId); + + return gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId: selectedGateway.id, + targetHost, + targetPort + }); + }; + + const getConnectedResources = async ({ poolId, ...actor }: TGetGatewayPoolByIdDTO) => { + await $checkPermission(actor, OrgPermissionGatewayPoolActions.ListGatewayPools); + await $checkLicense(actor.orgId); + + const pool = await gatewayPoolDAL.findById(poolId); + if (!pool || pool.orgId !== actor.orgId) { + throw new NotFoundError({ message: `Gateway pool with ID ${poolId} not found` }); + } + + // Add more DAL calls here as pool support expands to other consumers + const kubernetesAuths = await identityKubernetesAuthDAL.findByGatewayPoolId(poolId); + + return { kubernetesAuths }; + }; + + const getConnectedResourcesCount = async (poolId: string): Promise => { + // Add more DAL counts here as pool support expands to other consumers + const k8sAuthCount = await identityKubernetesAuthDAL.countByGatewayPoolId(poolId); + return k8sAuthCount; + }; + + return { + createGatewayPool, + listGatewayPools, + getGatewayPoolById, + updateGatewayPool, + deleteGatewayPool, + addGatewayToPool, + removeGatewayFromPool, + pickRandomHealthyGateway, + getPlatformConnectionDetailsByPoolId, + getConnectedResources, + getConnectedResourcesCount + }; +}; diff --git a/backend/src/ee/services/gateway-pool/gateway-pool-types.ts b/backend/src/ee/services/gateway-pool/gateway-pool-types.ts new file mode 100644 index 00000000000..d6fcf7dc7a5 --- /dev/null +++ b/backend/src/ee/services/gateway-pool/gateway-pool-types.ts @@ -0,0 +1,36 @@ +import { OrgServiceActor } from "@app/lib/types"; + +export type TCreateGatewayPoolDTO = { + name: string; +} & OrgServiceActor; + +export type TListGatewayPoolsDTO = OrgServiceActor; + +export type TGetGatewayPoolByIdDTO = { + poolId: string; +} & OrgServiceActor; + +export type TUpdateGatewayPoolDTO = { + poolId: string; + name?: string; +} & OrgServiceActor; + +export type TDeleteGatewayPoolDTO = { + poolId: string; +} & OrgServiceActor; + +export type TAddGatewayToPoolDTO = { + poolId: string; + gatewayId: string; +} & OrgServiceActor; + +export type TRemoveGatewayFromPoolDTO = { + poolId: string; + gatewayId: string; +} & OrgServiceActor; + +export type TGetPlatformConnectionDetailsByPoolIdDTO = { + poolId: string; + targetHost: string; + targetPort: number; +}; diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-constants.ts b/backend/src/ee/services/gateway-v2/gateway-v2-constants.ts index 7e41de91c61..971c58bea30 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-constants.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-constants.ts @@ -1,3 +1,5 @@ +export const GATEWAY_HEARTBEAT_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour + export const GATEWAY_ROUTING_INFO_OID = "1.3.6.1.4.1.12345.100.1"; export const GATEWAY_ACTOR_OID = "1.3.6.1.4.1.12345.100.2"; export const PAM_INFO_OID = "1.3.6.1.4.1.12345.100.3"; diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index 7ea2ab6c1b1..e0553daea05 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -36,7 +36,8 @@ export const getDefaultOnPremFeatures = () => { enterpriseAppConnections: true, machineIdentityAuthTemplates: false, pkiLegacyTemplates: false, - emailDomainVerification: true + emailDomainVerification: true, + gatewayPool: false }; }; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index b75ef22b5ee..15e350d2de2 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -105,6 +105,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ projectTemplates: false, kmip: false, gateway: false, + gatewayPool: false, sshHostGroups: false, secretScanning: false, enterpriseSecretSyncs: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index bd556a50658..6dd0214a988 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -84,6 +84,7 @@ export type TFeatureSet = { projectTemplates: false; kmip: false; gateway: false; + gatewayPool: false; sshHostGroups: false; secretScanning: false; enterpriseSecretSyncs: false; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index ceb2e9683fa..b28bc990c79 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -74,6 +74,14 @@ export enum OrgPermissionGatewayActions { AttachGateways = "attach-gateways" } +export enum OrgPermissionGatewayPoolActions { + CreateGatewayPools = "create-gateway-pools", + ListGatewayPools = "list-gateway-pools", + EditGatewayPools = "edit-gateway-pools", + DeleteGatewayPools = "delete-gateway-pools", + AttachGatewayPools = "attach-gateway-pools" +} + export enum OrgPermissionRelayActions { CreateRelays = "create-relays", ListRelays = "list-relays", @@ -141,6 +149,7 @@ export enum OrgPermissionSubjects { AppConnections = "app-connections", Kmip = "kmip", Gateway = "gateway", + GatewayPool = "gateway-pool", Relay = "relay", SecretShare = "secret-share", SubOrganization = "sub-organization", @@ -172,6 +181,7 @@ export type OrgPermissionSet = | [OrgPermissionAuditLogsActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionGatewayActions, OrgPermissionSubjects.Gateway] + | [OrgPermissionGatewayPoolActions, OrgPermissionSubjects.GatewayPool] | [OrgPermissionRelayActions, OrgPermissionSubjects.Relay] | [ OrgPermissionAppConnectionActions, @@ -330,6 +340,12 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ "Describe what action an entity can take." ) }), + z.object({ + subject: z.literal(OrgPermissionSubjects.GatewayPool).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionGatewayPoolActions).describe( + "Describe what action an entity can take." + ) + }), z.object({ subject: z.literal(OrgPermissionSubjects.Relay).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionRelayActions).describe( @@ -456,6 +472,12 @@ const buildAdminPermission = () => { can(OrgPermissionGatewayActions.DeleteGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway); + can(OrgPermissionGatewayPoolActions.ListGatewayPools, OrgPermissionSubjects.GatewayPool); + can(OrgPermissionGatewayPoolActions.CreateGatewayPools, OrgPermissionSubjects.GatewayPool); + can(OrgPermissionGatewayPoolActions.EditGatewayPools, OrgPermissionSubjects.GatewayPool); + can(OrgPermissionGatewayPoolActions.DeleteGatewayPools, OrgPermissionSubjects.GatewayPool); + can(OrgPermissionGatewayPoolActions.AttachGatewayPools, OrgPermissionSubjects.GatewayPool); + can(OrgPermissionRelayActions.ListRelays, OrgPermissionSubjects.Relay); can(OrgPermissionRelayActions.CreateRelays, OrgPermissionSubjects.Relay); can(OrgPermissionRelayActions.EditRelays, OrgPermissionSubjects.Relay); @@ -526,6 +548,10 @@ const buildMemberPermission = () => { can(OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway); + can(OrgPermissionGatewayPoolActions.ListGatewayPools, OrgPermissionSubjects.GatewayPool); + can(OrgPermissionGatewayPoolActions.CreateGatewayPools, OrgPermissionSubjects.GatewayPool); + can(OrgPermissionGatewayPoolActions.AttachGatewayPools, OrgPermissionSubjects.GatewayPool); + can(OrgPermissionRelayActions.ListRelays, OrgPermissionSubjects.Relay); can(OrgPermissionRelayActions.CreateRelays, OrgPermissionSubjects.Relay); can(OrgPermissionRelayActions.EditRelays, OrgPermissionSubjects.Relay); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 7b7eb0dc3a4..99eb7080463 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -59,6 +59,9 @@ 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 { gatewayPoolDalFactory } from "@app/ee/services/gateway-pool/gateway-pool-dal"; +import { gatewayPoolMembershipDalFactory } from "@app/ee/services/gateway-pool/gateway-pool-membership-dal"; +import { gatewayPoolServiceFactory } from "@app/ee/services/gateway-pool/gateway-pool-service"; import { gatewayEnrollmentTokenDALFactory } from "@app/ee/services/gateway-v2/gateway-enrollment-token-dal"; import { gatewayV2DalFactory } from "@app/ee/services/gateway-v2/gateway-v2-dal"; import { gatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; @@ -1294,6 +1297,8 @@ export const registerRoutes = async ( const relayDAL = relayDalFactory(db); const gatewayV2DAL = gatewayV2DalFactory(db); const gatewayEnrollmentTokenDAL = gatewayEnrollmentTokenDALFactory(db); + const gatewayPoolDAL = gatewayPoolDalFactory(db); + const gatewayPoolMembershipDAL = gatewayPoolMembershipDalFactory(db); const approvalPolicyDAL = approvalPolicyDALFactory(db); @@ -1467,6 +1472,16 @@ export const registerRoutes = async ( pkiDiscoveryConfigDAL }); + const gatewayPoolService = gatewayPoolServiceFactory({ + gatewayPoolDAL, + gatewayPoolMembershipDAL, + gatewayV2DAL, + gatewayV2Service, + permissionService, + licenseService, + identityKubernetesAuthDAL + }); + const secretSyncQueue = secretSyncQueueFactory({ queueService, secretSyncDAL, @@ -1957,7 +1972,9 @@ export const registerRoutes = async ( gatewayV2DAL, gatewayDAL, kmsService, - membershipIdentityDAL + membershipIdentityDAL, + gatewayPoolService, + gatewayPoolDAL }); const identityGcpAuthService = identityGcpAuthServiceFactory({ identityDAL, @@ -3216,6 +3233,7 @@ export const registerRoutes = async ( gateway: gatewayService, relay: relayService, gatewayV2: gatewayV2Service, + gatewayPool: gatewayPoolService, secretRotationV2: secretRotationV2Service, microsoftTeams: microsoftTeamsService, assumePrivileges: assumePrivilegeService, 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 46aa20732ce..d8f169213fc 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -30,7 +30,8 @@ const IdentityKubernetesAuthResponseSchema = IdentityKubernetesAuthsSchema.pick( allowedNamespaces: true, allowedNames: true, allowedAudience: true, - gatewayId: true + gatewayId: true, + gatewayPoolId: true }).extend({ caCert: z.string(), tokenReviewerJwt: z.string().optional().nullable() @@ -189,6 +190,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide 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), + gatewayPoolId: z.string().uuid().optional().nullable(), accessTokenTrustedIps: z .object({ ipAddress: z.string().trim() @@ -226,11 +228,22 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide message: "When token review mode is set to API, a Kubernetes host must be provided" }); } - if (data.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway && !data.gatewayId) { + if ( + data.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway && + !data.gatewayId && + !data.gatewayPoolId + ) { ctx.addIssue({ path: ["gatewayId"], code: z.ZodIssueCode.custom, - message: "When token review mode is set to Gateway, a gateway must be selected" + message: "When token review mode is set to Gateway, a gateway or gateway pool must be selected" + }); + } + if (data.gatewayId && data.gatewayPoolId) { + ctx.addIssue({ + path: ["gatewayPoolId"], + code: z.ZodIssueCode.custom, + message: "Cannot specify both a gateway and a gateway pool" }); } @@ -353,6 +366,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide 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), + gatewayPoolId: z.string().uuid().optional().nullable(), accessTokenTrustedIps: z .object({ ipAddress: z.string().trim() @@ -386,12 +400,20 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide if ( data.tokenReviewMode && data.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway && - !data.gatewayId + !data.gatewayId && + !data.gatewayPoolId ) { ctx.addIssue({ path: ["gatewayId"], code: z.ZodIssueCode.custom, - message: "When token review mode is set to Gateway, a gateway must be selected" + message: "When token review mode is set to Gateway, a gateway or gateway pool must be selected" + }); + } + if (data.gatewayId && data.gatewayPoolId) { + ctx.addIssue({ + path: ["gatewayPoolId"], + code: z.ZodIssueCode.custom, + message: "Cannot specify both a gateway and a gateway pool" }); } if (data.accessTokenMaxTTL && data.accessTokenTTL ? data.accessTokenTTL > data.accessTokenMaxTTL : false) { diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-dal.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-dal.ts index f1ba2e1dff0..ef1149a9096 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-dal.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-dal.ts @@ -31,5 +31,27 @@ export const identityKubernetesAuthDALFactory = (db: TDbClient) => { return parseInt(String(result?.count || "0"), 10); }; - return { ...kubernetesAuthOrm, findByGatewayId, countByGatewayId }; + const findByGatewayPoolId = async (gatewayPoolId: string, tx?: Knex) => { + const docs = await (tx || db.replicaNode())(TableName.IdentityKubernetesAuth) + .leftJoin(TableName.Identity, `${TableName.IdentityKubernetesAuth}.identityId`, `${TableName.Identity}.id`) + .where(`${TableName.IdentityKubernetesAuth}.gatewayPoolId`, gatewayPoolId) + .select( + db.ref("id").withSchema(TableName.IdentityKubernetesAuth), + db.ref("identityId").withSchema(TableName.IdentityKubernetesAuth), + db.ref("name").withSchema(TableName.Identity).as("identityName") + ); + + return docs; + }; + + const countByGatewayPoolId = async (gatewayPoolId: string, tx?: Knex) => { + const result = await (tx || db.replicaNode())(TableName.IdentityKubernetesAuth) + .where(`${TableName.IdentityKubernetesAuth}.gatewayPoolId`, gatewayPoolId) + .count("id") + .first(); + + return parseInt(String(result?.count || "0"), 10); + }; + + return { ...kubernetesAuthOrm, findByGatewayId, countByGatewayId, findByGatewayPoolId, countByGatewayPoolId }; }; 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 dac0274e09d..3d57e51624f 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 @@ -14,11 +14,14 @@ import { } from "@app/db/schemas"; import { TGatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayPoolDALFactory } from "@app/ee/services/gateway-pool/gateway-pool-dal"; +import { TGatewayPoolServiceFactory } from "@app/ee/services/gateway-pool/gateway-pool-service"; import { TGatewayV2DALFactory } from "@app/ee/services/gateway-v2/gateway-v2-dal"; import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionGatewayActions, + OrgPermissionGatewayPoolActions, OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; @@ -87,6 +90,11 @@ type TIdentityKubernetesAuthServiceFactoryDep = { gatewayV2Service: TGatewayV2ServiceFactory; gatewayDAL: Pick; gatewayV2DAL: Pick; + gatewayPoolService: Pick< + TGatewayPoolServiceFactory, + "getPlatformConnectionDetailsByPoolId" | "pickRandomHealthyGateway" + >; + gatewayPoolDAL: Pick; orgDAL: Pick; }; @@ -106,11 +114,14 @@ export const identityKubernetesAuthServiceFactory = ({ gatewayDAL, gatewayV2DAL, kmsService, + gatewayPoolService, + gatewayPoolDAL, orgDAL }: TIdentityKubernetesAuthServiceFactoryDep) => { const $gatewayProxyWrapper = async ( inputs: { - gatewayId: string; + gatewayId?: string; + gatewayPoolId?: string; targetHost?: string; targetPort?: number; caCert?: string; @@ -118,11 +129,17 @@ export const identityKubernetesAuthServiceFactory = ({ }, gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise ): Promise => { - const gatewayV2ConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ - gatewayId: inputs.gatewayId, - targetHost: inputs.targetHost ?? GATEWAY_AUTH_DEFAULT_HOST, - targetPort: inputs.targetPort ?? 443 - }); + const gatewayV2ConnectionDetails = inputs.gatewayPoolId + ? await gatewayPoolService.getPlatformConnectionDetailsByPoolId({ + poolId: inputs.gatewayPoolId, + targetHost: inputs.targetHost ?? GATEWAY_AUTH_DEFAULT_HOST, + targetPort: inputs.targetPort ?? 443 + }) + : await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId: inputs.gatewayId!, + targetHost: inputs.targetHost ?? GATEWAY_AUTH_DEFAULT_HOST, + targetPort: inputs.targetPort ?? 443 + }); if (gatewayV2ConnectionDetails) { let httpsAgent: https.Agent | undefined; @@ -154,7 +171,7 @@ export const identityKubernetesAuthServiceFactory = ({ return callbackResult; } - const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId); + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId!); const callbackResult = await withGatewayProxy( async (port, httpsAgent) => { @@ -437,15 +454,22 @@ export const identityKubernetesAuthServiceFactory = ({ let data: TCreateTokenReviewResponse | undefined; if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) { - if (!identityKubernetesAuth.gatewayId && !identityKubernetesAuth.gatewayV2Id) { + if ( + !identityKubernetesAuth.gatewayId && + !identityKubernetesAuth.gatewayV2Id && + !identityKubernetesAuth.gatewayPoolId + ) { throw new BadRequestError({ - message: "Gateway ID is required when token review mode is set to Gateway" + message: "Gateway or Gateway Pool is required when token review mode is set to Gateway" }); } data = await $gatewayProxyWrapper( { - gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string, + gatewayId: identityKubernetesAuth.gatewayPoolId + ? undefined + : ((identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string), + gatewayPoolId: identityKubernetesAuth.gatewayPoolId ?? undefined, reviewTokenThroughGateway: true }, tokenReviewCallbackThroughGateway @@ -464,18 +488,25 @@ export const identityKubernetesAuthServiceFactory = ({ const [k8sHost, k8sPort] = kubernetesHost.split(":"); - data = - identityKubernetesAuth.gatewayId || identityKubernetesAuth.gatewayV2Id - ? await $gatewayProxyWrapper( - { - gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string, - targetHost: k8sHost, - targetPort: k8sPort ? Number(k8sPort) : 443, - reviewTokenThroughGateway: false - }, - tokenReviewCallbackRaw - ) - : await tokenReviewCallbackRaw(); + const hasGateway = + identityKubernetesAuth.gatewayId || + identityKubernetesAuth.gatewayV2Id || + identityKubernetesAuth.gatewayPoolId; + + data = hasGateway + ? await $gatewayProxyWrapper( + { + gatewayId: identityKubernetesAuth.gatewayPoolId + ? undefined + : ((identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string), + gatewayPoolId: identityKubernetesAuth.gatewayPoolId ?? undefined, + targetHost: k8sHost, + targetPort: k8sPort ? Number(k8sPort) : 443, + reviewTokenThroughGateway: false + }, + tokenReviewCallbackRaw + ) + : await tokenReviewCallbackRaw(); } else { throw new BadRequestError({ message: `Invalid token review mode: ${identityKubernetesAuth.tokenReviewMode}` @@ -705,6 +736,7 @@ export const identityKubernetesAuthServiceFactory = ({ const attachKubernetesAuth = async ({ identityId, gatewayId, + gatewayPoolId, kubernetesHost, caCert, tokenReviewerJwt, @@ -843,6 +875,47 @@ export const identityKubernetesAuthServiceFactory = ({ await validateTokenReviewerPermissions({ gatewayExecutor, tokenReviewerJwt }); } } + } else if (gatewayPoolId) { + if (!plan.gatewayPool) { + throw new BadRequestError({ + message: "Your current plan does not support gateway pools. Please upgrade to an Enterprise plan." + }); + } + + const { permission: orgPermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionGatewayPoolActions.AttachGatewayPools, + OrgPermissionSubjects.GatewayPool + ); + + const pool = await gatewayPoolDAL.findById(gatewayPoolId); + if (!pool || pool.orgId !== identityMembershipOrg.scopeOrgId) { + throw new NotFoundError({ message: `Gateway pool with ID ${gatewayPoolId} not found` }); + } + + // Validate connectivity through a random healthy pool member + const validationGateway = await gatewayPoolService.pickRandomHealthyGateway(gatewayPoolId); + if (tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) { + const gatewayExecutor = $createGatewayValidationRequest(validationGateway.id); + await validateKubernetesHostConnectivity({ gatewayExecutor }); + await validateTokenReviewerPermissions({ gatewayExecutor }); + } else if (tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api && kubernetesHost) { + const gatewayExecutor = $createGatewayValidationRequest(validationGateway.id, { + kubernetesHost, + caCert: caCert || undefined + }); + await validateKubernetesHostConnectivity({ gatewayExecutor }); + if (tokenReviewerJwt) { + await validateTokenReviewerPermissions({ gatewayExecutor, tokenReviewerJwt }); + } + } } else if (tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api && kubernetesHost) { logger.info({ kubernetesHost }, "Validating Kubernetes host connectivity for new auth method"); await validateKubernetesHostConnectivity({ @@ -865,6 +938,16 @@ export const identityKubernetesAuthServiceFactory = ({ orgId: identityMembershipOrg.scopeOrgId }); + let resolvedGatewayId: string | null | undefined = null; + let resolvedGatewayV2Id: string | null | undefined = null; + if (!gatewayPoolId && gatewayId) { + if (isGatewayV1) { + resolvedGatewayId = gatewayId; + } else { + resolvedGatewayV2Id = gatewayId; + } + } + const identityKubernetesAuth = await identityKubernetesAuthDAL.transaction(async (tx) => { const doc = await identityKubernetesAuthDAL.create( { @@ -877,8 +960,9 @@ export const identityKubernetesAuthServiceFactory = ({ accessTokenMaxTTL, accessTokenTTL, accessTokenNumUsesLimit, - gatewayId: isGatewayV1 ? gatewayId : null, - gatewayV2Id: isGatewayV1 ? null : gatewayId, + gatewayId: resolvedGatewayId, + gatewayV2Id: resolvedGatewayV2Id, + gatewayPoolId: gatewayPoolId ?? null, accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), encryptedKubernetesTokenReviewerJwt: tokenReviewerJwt ? encryptor({ plainText: Buffer.from(tokenReviewerJwt) }).cipherTextBlob @@ -903,6 +987,7 @@ export const identityKubernetesAuthServiceFactory = ({ allowedNames, allowedAudience, gatewayId, + gatewayPoolId, accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, @@ -1019,16 +1104,64 @@ export const identityKubernetesAuthServiceFactory = ({ ); } + // Handle gateway pool permission check + if (gatewayPoolId) { + if (!plan.gatewayPool) { + throw new BadRequestError({ + message: "Your current plan does not support gateway pools. Please upgrade to an Enterprise plan." + }); + } + const { permission: orgPermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionGatewayPoolActions.AttachGatewayPools, + OrgPermissionSubjects.GatewayPool + ); + + const pool = await gatewayPoolDAL.findById(gatewayPoolId); + if (!pool || pool.orgId !== identityMembershipOrg.scopeOrgId) { + throw new NotFoundError({ message: `Gateway pool with ID ${gatewayPoolId} not found` }); + } + } + // Strict check to see if gateway ID is undefined. It should update the gateway ID to null if its strictly set to null. - const shouldUpdateGatewayId = Boolean(gatewayId !== undefined); - const gatewayIdValue = isGatewayV1 ? gatewayId : null; - const gatewayV2IdValue = isGatewayV1 ? null : gatewayId; + const shouldUpdateGatewayId = Boolean(gatewayId !== undefined || gatewayPoolId !== undefined); + let gatewayIdValue: string | null | undefined = null; + let gatewayV2IdValue: string | null | undefined = null; + if (!gatewayPoolId && gatewayId) { + if (isGatewayV1) { + gatewayIdValue = gatewayId; + } else { + gatewayV2IdValue = gatewayId; + } + } + let gatewayPoolIdValue: string | null | undefined; + if (gatewayPoolId !== undefined) { + gatewayPoolIdValue = gatewayPoolId; + } else if (gatewayId !== undefined) { + gatewayPoolIdValue = null; + } else { + gatewayPoolIdValue = undefined; + } const effectiveTokenReviewMode = tokenReviewMode ?? identityKubernetesAuth.tokenReviewMode; const effectiveKubernetesHost = kubernetesHost !== undefined ? kubernetesHost : identityKubernetesAuth.kubernetesHost; - const effectiveGatewayId = - gatewayId !== undefined ? gatewayId : (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId); + const effectiveGatewayPoolId = gatewayPoolId !== undefined ? gatewayPoolId : identityKubernetesAuth.gatewayPoolId; + let effectiveGatewayId: string | null | undefined = null; + if (effectiveGatewayPoolId) { + effectiveGatewayId = null; + } else if (gatewayId !== undefined) { + effectiveGatewayId = gatewayId; + } else { + effectiveGatewayId = identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId; + } const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, @@ -1046,23 +1179,41 @@ export const identityKubernetesAuthServiceFactory = ({ effectiveCaCert = undefined; } - if (effectiveGatewayId) { + // Resolve the gateway ID to validate through (either direct or from pool) + let validationGatewayId: string | null = effectiveGatewayId ?? null; + if (!validationGatewayId && effectiveGatewayPoolId) { + try { + const picked = await gatewayPoolService.pickRandomHealthyGateway(effectiveGatewayPoolId); + validationGatewayId = picked.id; + } catch { + logger.warn( + { gatewayPoolId: effectiveGatewayPoolId }, + "No healthy gateways in pool, skipping connectivity validation for k8s auth update" + ); + } + } + + if (validationGatewayId) { if (effectiveTokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) { - const gatewayExecutor = $createGatewayValidationRequest(effectiveGatewayId); + const gatewayExecutor = $createGatewayValidationRequest(validationGatewayId); logger.info( - { gatewayId: effectiveGatewayId }, + { gatewayId: validationGatewayId, gatewayPoolId: effectiveGatewayPoolId }, "Validating gateway connectivity to Kubernetes for auth method update" ); await validateKubernetesHostConnectivity({ gatewayExecutor }); await validateTokenReviewerPermissions({ gatewayExecutor }); } else if (effectiveTokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api && effectiveKubernetesHost) { - const gatewayExecutor = $createGatewayValidationRequest(effectiveGatewayId, { + const gatewayExecutor = $createGatewayValidationRequest(validationGatewayId, { kubernetesHost: effectiveKubernetesHost, caCert: effectiveCaCert }); logger.info( - { gatewayId: effectiveGatewayId, kubernetesHost: effectiveKubernetesHost }, + { + gatewayId: validationGatewayId, + gatewayPoolId: effectiveGatewayPoolId, + kubernetesHost: effectiveKubernetesHost + }, "Validating Kubernetes connectivity through gateway for auth method update" ); @@ -1101,6 +1252,7 @@ export const identityKubernetesAuthServiceFactory = ({ allowedAudience, gatewayId: shouldUpdateGatewayId ? gatewayIdValue : undefined, gatewayV2Id: shouldUpdateGatewayId ? gatewayV2IdValue : undefined, + gatewayPoolId: gatewayPoolIdValue, 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 8f06bfd5d33..a64018f6b90 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 @@ -21,6 +21,7 @@ export type TAttachKubernetesAuthDTO = { allowedNames: string; allowedAudience: string; gatewayId?: string | null; + gatewayPoolId?: string | null; accessTokenTTL: number; accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; @@ -38,6 +39,7 @@ export type TUpdateKubernetesAuthDTO = { allowedNames?: string; allowedAudience?: string; gatewayId?: string | null; + gatewayPoolId?: string | null; accessTokenTTL?: number; accessTokenMaxTTL?: number; accessTokenNumUsesLimit?: number; diff --git a/docs/docs.json b/docs/docs.json index f00db502ad8..668e8f708ac 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -193,6 +193,7 @@ "group": "Gateway", "pages": [ "documentation/platform/gateways/overview", + "documentation/platform/gateways/gateway-pools", "documentation/platform/gateways/gateway-deployment", { "group": "Relay Deployment", diff --git a/docs/documentation/platform/gateways/gateway-pools.mdx b/docs/documentation/platform/gateways/gateway-pools.mdx new file mode 100644 index 00000000000..cbcbaf65241 --- /dev/null +++ b/docs/documentation/platform/gateways/gateway-pools.mdx @@ -0,0 +1,92 @@ +--- +title: "Gateway Pools" +sidebarTitle: "Gateway Pools" +description: "High availability and automatic failover for gateways" +--- + +Gateway Pools provide high availability for your gateway infrastructure. A pool is a named collection of gateways that all have connectivity to the same private network. When the platform needs to reach a resource through a pool, it automatically routes through a healthy member, providing failover if any individual gateway goes down. + + + Gateway Pools is an enterprise feature. Self-hosted users can contact + [sales@infisical.com](mailto:sales@infisical.com) to purchase an enterprise + license. + + +## How It Works + +1. You create a Gateway Pool and add multiple gateways that share network access to the same resources. +2. When configuring a consumer (e.g., Kubernetes Auth), you select the pool instead of an individual gateway. +3. At request time, the platform picks a random healthy gateway from the pool and routes through it. +4. If a gateway goes down, subsequent requests automatically route through the remaining healthy members. + +A gateway is considered healthy if it has sent a heartbeat within the last hour and its last health check did not fail. + +## Creating a Gateway Pool + +1. Navigate to **Organization Settings > Networking > Gateways**. +2. Click the **Gateway Pools** tab. + +![Gateway Pools Switch](../../../images/platform/gateways/gateway-pools-switch.png) + +3. Click **Create Pool**. + +![Gateway Pools Tab](../../../images/platform/gateways/gateway-pools-tab.png) + +4. Enter a name for the pool and click **Create Pool**. + +![Create Gateway Pool](../../../images/platform/gateways/gateway-pools-create.png) + +## Adding Gateways to a Pool + +1. In the **Gateway Pools** tab, click on a pool to open its detail view. + +![Click on a pool](../../../images/platform/gateways/gateway-pools-click-pool.png) + +2. Click **Add Gateway** and select a gateway from the dropdown. + +![Add gateway to pool](../../../images/platform/gateways/gateway-pools-add-gateway.png) + +3. Repeat for each gateway you want to add. + +A gateway can belong to multiple pools if it has connectivity to resources served by each pool. Pool membership can be changed at any time without restarting the gateway. + +## Using a Pool in a Consumer Config + +Anywhere you configure a gateway, the gateway picker dropdown shows both individual gateways and gateway pools. Pools are listed under the "Gateway Pools" section with an **HA** (high availability) badge and health status. + +When you select a pool, the platform validates connectivity through one of its healthy members before saving. + +## Gateway Selection + +When a request is routed through a pool, the platform picks a gateway at random from the pool's healthy members. There is no round-robin or weighted selection. + +## Pool Health + +Each pool displays an aggregate health status based on its members: + +- **Green** (e.g., "3/3 healthy") - All members are healthy +- **Yellow** (e.g., "2/3 healthy") - Some members are unhealthy +- **Red** (e.g., "0/3 healthy") - All members are unhealthy + +If all gateways in a pool are unhealthy when a request is made, the request fails with a descriptive error. + +## FAQ + + + + Gateway pools can currently be selected in **Kubernetes Auth** configurations. Support for additional consumers such as dynamic secrets, PAM, and app connections is coming soon. + + + No. A pool cannot be deleted if it is referenced by any consumer configurations (e.g., Kubernetes Auth). You must first update those configurations to use a different gateway or pool before deleting. + + + Each pool shows a count of connected consumer configurations in the table. Click the count to see the full list with links to each resource. + + + The request fails with a descriptive error. You should ensure at least one gateway in the pool is online and has a recent heartbeat. + + + Yes. A gateway can belong to as many pools as needed, as long as it has connectivity to the resources served by each pool. + + + diff --git a/docs/documentation/platform/gateways/overview.mdx b/docs/documentation/platform/gateways/overview.mdx index 33945337b69..b3f77efe4ee 100644 --- a/docs/documentation/platform/gateways/overview.mdx +++ b/docs/documentation/platform/gateways/overview.mdx @@ -65,6 +65,10 @@ To monitor their operational status, both gateways and relays transmit hourly he Infisical automatically notifies all organization admins of unhealthy gateway or relay statuses through email and in-app notifications. +## High Availability with Gateway Pools + +For production workloads, you can group multiple gateways into a **Gateway Pool** to provide automatic failover. When a gateway in a pool goes down, the platform routes through a healthy member automatically. See [Gateway Pools](/documentation/platform/gateways/gateway-pools) for details. + ## Getting Started Ready to set up your gateway? Follow the guides below. @@ -73,11 +77,14 @@ Ready to set up your gateway? Follow the guides below. Deploy and configure your gateway within your network infrastructure. + + Set up high availability with gateway pools for automatic failover. + + + Set up relay servers if using self-deployed infrastructure. - - Learn about the security model and implementation best practices. diff --git a/docs/documentation/platform/identities/kubernetes-auth.mdx b/docs/documentation/platform/identities/kubernetes-auth.mdx index f9412b92b19..3438930926e 100644 --- a/docs/documentation/platform/identities/kubernetes-auth.mdx +++ b/docs/documentation/platform/identities/kubernetes-auth.mdx @@ -207,6 +207,8 @@ In the following steps, we explore how to create and use identities for your app To configure your Kubernetes Auth method to use the gateway as the token reviewer, set the `Review Method` to "Gateway as Reviewer", and select the gateway you want to use as the token reviewer. + You can select either an individual gateway or a **Gateway Pool** for automatic failover. When a pool is selected, the platform routes through a healthy member at request time. See [Gateway Pools](/documentation/platform/gateways/gateway-pools) for more details. + ![identities organization create kubernetes auth method](/images/platform/identities/identities-kubernetes-auth-gateway-as-reviewer.png) diff --git a/docs/images/platform/gateways/gateway-pools-add-gateway.png b/docs/images/platform/gateways/gateway-pools-add-gateway.png new file mode 100644 index 00000000000..e6e2fb8eeeb Binary files /dev/null and b/docs/images/platform/gateways/gateway-pools-add-gateway.png differ diff --git a/docs/images/platform/gateways/gateway-pools-click-pool.png b/docs/images/platform/gateways/gateway-pools-click-pool.png new file mode 100644 index 00000000000..8025414ba29 Binary files /dev/null and b/docs/images/platform/gateways/gateway-pools-click-pool.png differ diff --git a/docs/images/platform/gateways/gateway-pools-create.png b/docs/images/platform/gateways/gateway-pools-create.png new file mode 100644 index 00000000000..b07487d15fd Binary files /dev/null and b/docs/images/platform/gateways/gateway-pools-create.png differ diff --git a/docs/images/platform/gateways/gateway-pools-switch.png b/docs/images/platform/gateways/gateway-pools-switch.png new file mode 100644 index 00000000000..1d055219a87 Binary files /dev/null and b/docs/images/platform/gateways/gateway-pools-switch.png differ diff --git a/docs/images/platform/gateways/gateway-pools-tab.png b/docs/images/platform/gateways/gateway-pools-tab.png new file mode 100644 index 00000000000..4742bde4634 Binary files /dev/null and b/docs/images/platform/gateways/gateway-pools-tab.png differ diff --git a/frontend/src/components/v3/platform/GatewayPicker/GatewayPicker.tsx b/frontend/src/components/v3/platform/GatewayPicker/GatewayPicker.tsx new file mode 100644 index 00000000000..291cd45f446 --- /dev/null +++ b/frontend/src/components/v3/platform/GatewayPicker/GatewayPicker.tsx @@ -0,0 +1,140 @@ +import { faGlobe, faLayerGroup, faServer } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useQuery } from "@tanstack/react-query"; + +import { Select, SelectItem, Tooltip } from "@app/components/v2"; +import { useSubscription } from "@app/context"; +import { gatewayPoolsQueryKeys } from "@app/hooks/api/gateway-pools/queries"; +import { gatewaysQueryKeys } from "@app/hooks/api/gateways/queries"; +import { GatewayHealthCheckStatus } from "@app/hooks/api/gateways-v2/types"; +import { PoolHealthBadge } from "@app/pages/organization/NetworkingPage/components/GatewayTab/components/PoolHealthBadge"; + +type GatewayPickerValue = { + gatewayId: string | null; + gatewayPoolId: string | null; +}; + +type Props = { + value: GatewayPickerValue; + onChange: (value: GatewayPickerValue) => void; + isDisabled?: boolean; + className?: string; +}; + +const SectionLabel = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +const SectionDivider = () =>
; + +export const GatewayPicker = ({ value, onChange, isDisabled, className }: Props) => { + const { subscription } = useSubscription(); + const showPools = subscription?.gatewayPool; + + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); + const { data: pools, isPending: isPoolsLoading } = useQuery({ + ...gatewayPoolsQueryKeys.list(), + enabled: Boolean(showPools) + }); + + const isLoading = isGatewaysLoading || (showPools && isPoolsLoading); + + let selectValue = "internet"; + if (value.gatewayPoolId) { + selectValue = `pool:${value.gatewayPoolId}`; + } else if (value.gatewayId) { + selectValue = `gateway:${value.gatewayId}`; + } + + const handleChange = (v: string) => { + if (v === "internet") { + onChange({ gatewayId: null, gatewayPoolId: null }); + } else if (v.startsWith("pool:")) { + onChange({ gatewayId: null, gatewayPoolId: v.replace("pool:", "") }); + } else if (v.startsWith("gateway:")) { + onChange({ gatewayId: v.replace("gateway:", ""), gatewayPoolId: null }); + } + }; + + const v2Gateways = gateways?.filter((g) => !g.isV1) ?? []; + + const isOnline = (gw: (typeof v2Gateways)[number]) => + "heartbeat" in gw && + gw.heartbeat && + new Date(gw.heartbeat).getTime() > Date.now() - 60 * 60 * 1000 && + (!("lastHealthCheckStatus" in gw) || + gw.lastHealthCheckStatus !== GatewayHealthCheckStatus.Failed); + + return ( + + ); +}; diff --git a/frontend/src/components/v3/platform/GatewayPicker/index.ts b/frontend/src/components/v3/platform/GatewayPicker/index.ts new file mode 100644 index 00000000000..0bb0a7844d6 --- /dev/null +++ b/frontend/src/components/v3/platform/GatewayPicker/index.ts @@ -0,0 +1 @@ +export { GatewayPicker } from "./GatewayPicker"; diff --git a/frontend/src/components/v3/platform/index.ts b/frontend/src/components/v3/platform/index.ts index ab583961838..3866b25f22f 100644 --- a/frontend/src/components/v3/platform/index.ts +++ b/frontend/src/components/v3/platform/index.ts @@ -1,2 +1,3 @@ export * from "./DocumentationLinkBadge"; +export * from "./GatewayPicker"; export * from "./ScopeIcons"; diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 8700b817cac..a5862bd7e9c 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -29,6 +29,14 @@ export enum OrgGatewayPermissionActions { AttachGateways = "attach-gateways" } +export enum OrgGatewayPoolPermissionActions { + CreateGatewayPools = "create-gateway-pools", + ListGatewayPools = "list-gateway-pools", + EditGatewayPools = "edit-gateway-pools", + DeleteGatewayPools = "delete-gateway-pools", + AttachGatewayPools = "attach-gateway-pools" +} + export enum OrgRelayPermissionActions { CreateRelays = "create-relays", ListRelays = "list-relays", @@ -66,6 +74,7 @@ export enum OrgPermissionSubjects { AppConnections = "app-connections", Kmip = "kmip", Gateway = "gateway", + GatewayPool = "gateway-pool", Relay = "relay", SecretShare = "secret-share", GithubOrgSync = "github-org-sync", @@ -168,6 +177,7 @@ export type OrgPermissionSet = OrgPermissionSubjects.MachineIdentityAuthTemplate ] | [OrgGatewayPermissionActions, OrgPermissionSubjects.Gateway] + | [OrgGatewayPoolPermissionActions, OrgPermissionSubjects.GatewayPool] | [OrgRelayPermissionActions, OrgPermissionSubjects.Relay] | [OrgPermissionSecretShareAction, OrgPermissionSubjects.SecretShare] | [ diff --git a/frontend/src/hooks/api/gateway-pools/index.tsx b/frontend/src/hooks/api/gateway-pools/index.tsx new file mode 100644 index 00000000000..43063fa0ee1 --- /dev/null +++ b/frontend/src/hooks/api/gateway-pools/index.tsx @@ -0,0 +1,19 @@ +export { + useAddGatewayToPool, + useCreateGatewayPool, + useDeleteGatewayPool, + useRemoveGatewayFromPool, + useUpdateGatewayPool +} from "./mutations"; +export { + gatewayPoolsQueryKeys, + useGetGatewayPool, + useGetGatewayPoolConnectedResources, + useListGatewayPools +} from "./queries"; +export type { + TGatewayPool, + TGatewayPoolConnectedResources, + TGatewayPoolMember, + TGatewayPoolWithMembers +} from "./types"; diff --git a/frontend/src/hooks/api/gateway-pools/mutations.tsx b/frontend/src/hooks/api/gateway-pools/mutations.tsx new file mode 100644 index 00000000000..57f4e40f1ba --- /dev/null +++ b/frontend/src/hooks/api/gateway-pools/mutations.tsx @@ -0,0 +1,86 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { gatewaysQueryKeys } from "../gateways/queries"; +import { gatewayPoolsQueryKeys } from "./queries"; +import { + TAddGatewayToPoolDTO, + TCreateGatewayPoolDTO, + TGatewayPool, + TGatewayPoolMembership, + TRemoveGatewayFromPoolDTO, + TUpdateGatewayPoolDTO +} from "./types"; + +export const useCreateGatewayPool = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (dto: TCreateGatewayPoolDTO) => { + const { data } = await apiRequest.post("/api/v2/gateway-pools", dto); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: gatewayPoolsQueryKeys.allKey() }); + } + }); +}; + +export const useUpdateGatewayPool = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ poolId, ...dto }: TUpdateGatewayPoolDTO) => { + const { data } = await apiRequest.patch(`/api/v2/gateway-pools/${poolId}`, dto); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: gatewayPoolsQueryKeys.allKey() }); + } + }); +}; + +export const useDeleteGatewayPool = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (poolId: string) => { + const { data } = await apiRequest.delete(`/api/v2/gateway-pools/${poolId}`); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: gatewayPoolsQueryKeys.allKey() }); + } + }); +}; + +export const useAddGatewayToPool = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ poolId, gatewayId }: TAddGatewayToPoolDTO) => { + const { data } = await apiRequest.post( + `/api/v2/gateway-pools/${poolId}/memberships`, + { gatewayId } + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: gatewayPoolsQueryKeys.allKey() }); + queryClient.invalidateQueries({ queryKey: gatewaysQueryKeys.allKey() }); + } + }); +}; + +export const useRemoveGatewayFromPool = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ poolId, gatewayId }: TRemoveGatewayFromPoolDTO) => { + const { data } = await apiRequest.delete( + `/api/v2/gateway-pools/${poolId}/memberships/${gatewayId}` + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: gatewayPoolsQueryKeys.allKey() }); + queryClient.invalidateQueries({ queryKey: gatewaysQueryKeys.allKey() }); + } + }); +}; diff --git a/frontend/src/hooks/api/gateway-pools/queries.tsx b/frontend/src/hooks/api/gateway-pools/queries.tsx new file mode 100644 index 00000000000..52e649f6a8a --- /dev/null +++ b/frontend/src/hooks/api/gateway-pools/queries.tsx @@ -0,0 +1,52 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TGatewayPool, TGatewayPoolConnectedResources, TGatewayPoolWithMembers } from "./types"; + +export const gatewayPoolsQueryKeys = { + allKey: () => ["gateway-pools"], + listKey: () => [...gatewayPoolsQueryKeys.allKey(), "list"], + list: () => ({ + queryKey: gatewayPoolsQueryKeys.listKey(), + queryFn: async () => { + const { data } = await apiRequest.get("/api/v2/gateway-pools"); + return data; + } + }), + detailKey: (poolId: string) => [...gatewayPoolsQueryKeys.allKey(), "detail", poolId], + detail: (poolId: string) => ({ + queryKey: gatewayPoolsQueryKeys.detailKey(poolId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v2/gateway-pools/${poolId}` + ); + return data; + }, + enabled: Boolean(poolId) + }) +}; + +export const useListGatewayPools = (options?: { refetchInterval?: number }) => { + return useQuery({ + ...gatewayPoolsQueryKeys.list(), + refetchInterval: options?.refetchInterval + }); +}; + +export const useGetGatewayPool = (poolId: string) => { + return useQuery(gatewayPoolsQueryKeys.detail(poolId)); +}; + +export const useGetGatewayPoolConnectedResources = (poolId: string) => { + return useQuery({ + queryKey: [...gatewayPoolsQueryKeys.allKey(), "connected-resources", poolId], + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v2/gateway-pools/${poolId}/resources` + ); + return data; + }, + enabled: Boolean(poolId) + }); +}; diff --git a/frontend/src/hooks/api/gateway-pools/types.ts b/frontend/src/hooks/api/gateway-pools/types.ts new file mode 100644 index 00000000000..1a6675e9aae --- /dev/null +++ b/frontend/src/hooks/api/gateway-pools/types.ts @@ -0,0 +1,61 @@ +export type TGatewayPool = { + id: string; + orgId: string; + name: string; + createdAt: string; + updatedAt: string; + memberCount: number; + healthyMemberCount: number; + memberGatewayIds: string[]; + connectedResourcesCount: number; +}; + +export type TGatewayPoolConnectedResources = { + kubernetesAuths: { + id: string; + identityId: string; + identityName: string | null; + }[]; +}; + +export type TGatewayPoolMember = { + id: string; + name: string; + heartbeat: string | null; + lastHealthCheckStatus: string | null; +}; + +export type TGatewayPoolWithMembers = { + id: string; + orgId: string; + name: string; + createdAt: string; + updatedAt: string; + gateways: TGatewayPoolMember[]; +}; + +export type TGatewayPoolMembership = { + id: string; + gatewayPoolId: string; + gatewayId: string; + createdAt: string; +}; + +export type TCreateGatewayPoolDTO = { + name: string; +}; + +export type TUpdateGatewayPoolDTO = { + poolId: string; + name?: string; +}; + +export type TAddGatewayToPoolDTO = { + poolId: string; + gatewayId: string; +}; + +export type TRemoveGatewayFromPoolDTO = { + poolId: string; + gatewayId: string; +}; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index 8a39b2fb067..9f2163c4dad 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { projectIdentityQuery, projectKeys } from "@app/hooks/api"; +import { gatewayPoolsQueryKeys as gatewayPoolsKeys } from "../gateway-pools/queries"; import { organizationKeys } from "../organization/queries"; import { identitiesKeys } from "./queries"; import { @@ -1414,6 +1415,7 @@ export const useAddIdentityKubernetesAuth = () => { accessTokenNumUsesLimit, accessTokenTrustedIps, gatewayId, + gatewayPoolId, tokenReviewMode }) => { const { @@ -1432,6 +1434,7 @@ export const useAddIdentityKubernetesAuth = () => { accessTokenNumUsesLimit, accessTokenTrustedIps, gatewayId, + gatewayPoolId, tokenReviewMode } ); @@ -1453,7 +1456,10 @@ export const useAddIdentityKubernetesAuth = () => { }); } queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) }); - queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityAzureAuth(identityId) }); + queryClient.invalidateQueries({ + queryKey: identitiesKeys.getIdentityKubernetesAuth(identityId) + }); + queryClient.invalidateQueries({ queryKey: gatewayPoolsKeys.allKey() }); } }); }; @@ -1553,6 +1559,7 @@ export const useUpdateIdentityKubernetesAuth = () => { accessTokenNumUsesLimit, accessTokenTrustedIps, gatewayId, + gatewayPoolId, tokenReviewMode }) => { const { @@ -1571,6 +1578,7 @@ export const useUpdateIdentityKubernetesAuth = () => { accessTokenNumUsesLimit, accessTokenTrustedIps, gatewayId, + gatewayPoolId, tokenReviewMode } ); @@ -1595,6 +1603,7 @@ export const useUpdateIdentityKubernetesAuth = () => { queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityKubernetesAuth(identityId) }); + queryClient.invalidateQueries({ queryKey: gatewayPoolsKeys.allKey() }); } }); }; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 38d8f9b8ec6..cee4aa4cda3 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -516,6 +516,7 @@ export type IdentityKubernetesAuth = { accessTokenNumUsesLimit: number; accessTokenTrustedIps: IdentityTrustedIp[]; gatewayId?: string | null; + gatewayPoolId?: string | null; }; export type AddIdentityKubernetesAuthDTO = { @@ -529,6 +530,7 @@ export type AddIdentityKubernetesAuthDTO = { allowedNames: string; allowedAudience: string; gatewayId?: string | null; + gatewayPoolId?: string | null; caCert: string; accessTokenTTL: number; accessTokenMaxTTL: number; @@ -549,6 +551,7 @@ export type UpdateIdentityKubernetesAuthDTO = { allowedNames?: string; allowedAudience?: string; gatewayId?: string | null; + gatewayPoolId?: string | null; caCert?: string; accessTokenTTL?: number; accessTokenMaxTTL?: number; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 4221f193e26..9b05658586f 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -21,6 +21,7 @@ export * from "./certificateTemplates"; export * from "./dynamicSecret"; export * from "./dynamicSecretLease"; export * from "./emailDomains"; +export * from "./gateway-pools"; export * from "./gateways"; export * from "./githubOrgSyncConfig"; export * from "./groups"; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index a67a6e604e3..7009bfe9ad4 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -57,6 +57,7 @@ export type SubscriptionPlan = { caCrl: boolean; instanceUserManagement: boolean; gateway: boolean; + gatewayPool: boolean; externalKms: boolean; pkiEst: boolean; pkiAcme: boolean; 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 89ed93b6654..4640f7b555b 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,7 +3,6 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; import { faInfoCircle, 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 { useParams } from "@tanstack/react-router"; import { z } from "zod"; @@ -23,6 +22,7 @@ import { TextArea, Tooltip } from "@app/components/v2"; +import { GatewayPicker } from "@app/components/v3"; import { useOrganization, useOrgPermission, useSubscription } from "@app/context"; import { OrgGatewayPermissionActions, @@ -30,7 +30,6 @@ import { } from "@app/context/OrgPermissionContext/types"; import { OrgMembershipRole } from "@app/helpers/roles"; import { - gatewaysQueryKeys, useAddIdentityKubernetesAuth, useGetIdentityKubernetesAuth, useUpdateIdentityKubernetesAuth @@ -54,6 +53,7 @@ const schema = z kubernetesHost: z.string().optional().nullable(), tokenReviewerJwt: z.string().optional(), gatewayId: z.string().optional().nullable(), + gatewayPoolId: z.string().optional().nullable(), allowedNames: z.string(), allowedNamespaces: z.string(), allowedAudience: z.string(), @@ -85,11 +85,16 @@ const schema = z }); } - if (data.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway && !data.gatewayId) { + if ( + data.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway && + !data.gatewayId && + !data.gatewayPoolId + ) { ctx.addIssue({ path: ["gatewayId"], code: z.ZodIssueCode.custom, - message: "When token review mode is set to Gateway, a gateway must be selected" + message: + "When token review mode is set to Gateway, a gateway or gateway pool must be selected" }); } }); @@ -125,8 +130,6 @@ 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 }); @@ -181,7 +184,8 @@ export const IdentityKubernetesAuthForm = ({ allowedNamespaces: data.allowedNamespaces, allowedAudience: data.allowedAudience, caCert: data.caCert, - gatewayId: data.gatewayId || null, + gatewayId: data.gatewayPoolId ? null : data.gatewayId || null, + gatewayPoolId: data.gatewayPoolId || null, accessTokenTTL: String(data.accessTokenTTL), accessTokenMaxTTL: String(data.accessTokenMaxTTL), accessTokenNumUsesLimit: data.accessTokenNumUsesLimit @@ -308,6 +312,7 @@ export const IdentityKubernetesAuthForm = ({ accessTokenMaxTTL, accessTokenNumUsesLimit, gatewayId, + gatewayPoolId, tokenReviewMode, accessTokenTrustedIps }: FormData) => { @@ -329,7 +334,8 @@ export const IdentityKubernetesAuthForm = ({ allowedAudience, caCert, identityId, - gatewayId: gatewayId || null, + gatewayId: gatewayPoolId ? null : gatewayId || null, + gatewayPoolId: gatewayPoolId || null, tokenReviewMode, accessTokenTTL: Number(accessTokenTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL), @@ -351,7 +357,8 @@ export const IdentityKubernetesAuthForm = ({ allowedNames: allowedNames || "", allowedNamespaces: allowedNamespaces || "", allowedAudience: allowedAudience || "", - gatewayId: gatewayId || null, + gatewayId: gatewayPoolId ? null : gatewayId || null, + gatewayPoolId: gatewayPoolId || null, caCert: caCert || "", tokenReviewMode, accessTokenTTL: Number(accessTokenTTL), @@ -441,61 +448,46 @@ export const IdentityKubernetesAuthForm = ({ control={control} name="gatewayId" defaultValue="" - render={({ field: { value, onChange }, fieldState: { error } }) => ( - - { + const gatewayPoolIdVal = watch("gatewayPoolId"); + const gatewayIdVal = watch("gatewayId"); + + return ( + -
- -
-
-
- )} + isDisabled={!isAllowed} + className="w-full" + /> +
+ + + ); + }} /> )} diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/GatewayTab.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/GatewayTab.tsx index 27fb2e00bc9..e34def0196a 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/GatewayTab.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/GatewayTab.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { faArrowsRotate, - faClock, faCopy, faDoorClosed, faEdit, @@ -43,64 +42,24 @@ import { import { DocumentationLinkBadge } from "@app/components/v3"; import { OrgGatewayPermissionActions, + OrgGatewayPoolPermissionActions, OrgPermissionSubjects } from "@app/context/OrgPermissionContext/types"; +import { useSubscription } from "@app/context/SubscriptionContext"; import { withPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; +import { useListGatewayPools } from "@app/hooks/api/gateway-pools"; import { gatewaysQueryKeys, useDeleteGatewayById } from "@app/hooks/api/gateways"; import { useDeleteGatewayV2ById, useTriggerGatewayV2Heartbeat } from "@app/hooks/api/gateways-v2"; -import { GatewayHealthCheckStatus } from "@app/hooks/api/gateways-v2/types"; +import { CreateGatewayPoolModal } from "./components/CreateGatewayPoolModal"; import { EditGatewayDetailsModal } from "./components/EditGatewayDetailsModal"; import { GatewayConnectedResourcesDrawer } from "./components/GatewayConnectedResourcesDrawer"; import { GatewayDeployModal } from "./components/GatewayDeployModal"; +import { GatewayHealthStatus } from "./components/GatewayHealthStatus"; +import { GatewayPoolsContent } from "./components/GatewayPoolsContent"; import { ReEnrollGatewayModal } from "./components/ReEnrollGatewayModal"; -const GatewayHealthStatus = ({ - heartbeat, - lastHealthCheckStatus, - isPending -}: { - heartbeat?: string | null; - lastHealthCheckStatus?: GatewayHealthCheckStatus | null; - isPending?: boolean; -}) => { - if (isPending) { - return ( - - - - Pending - - - ); - } - - if (!heartbeat && !lastHealthCheckStatus) { - return ( - - Unregistered - - ); - } - - const heartbeatDate = heartbeat ? new Date(heartbeat) : null; - - const isHealthy = lastHealthCheckStatus === GatewayHealthCheckStatus.Healthy; - - const tooltipContent = heartbeatDate - ? `Last health check: ${heartbeatDate.toLocaleString()}` - : "No health check data available"; - - return ( - - - {isHealthy ? "Healthy" : "Unreachable"} - - - ); -}; - type GatewayConnectedCellProps = { isV1: boolean; connectedResourcesCount: number; @@ -131,6 +90,11 @@ const GatewayConnectedCell = ({ export const GatewayTab = withPermission( () => { + const [activeSubTab, setActiveSubTab] = useState<"all-gateways" | "gateway-pools">( + "all-gateways" + ); + const { subscription } = useSubscription(); + const showPoolsTab = subscription?.gatewayPool; const [search, setSearch] = useState(""); const [selectedGateway, setSelectedGateway] = useState<{ id: string; @@ -140,13 +104,27 @@ export const GatewayTab = withPermission( ...gatewaysQueryKeys.listWithTokens(), refetchInterval: 15_000 }); + const { data: pools } = useListGatewayPools(); + + // Build reverse map: gatewayId -> pool names + const gatewayPoolMap = new Map(); + if (showPoolsTab && pools) { + pools.forEach((pool) => { + pool.memberGatewayIds.forEach((gwId) => { + const existing = gatewayPoolMap.get(gwId) ?? []; + existing.push(pool.name); + gatewayPoolMap.set(gwId, existing); + }); + }); + } const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ "deployGateway", "deleteGateway", "editDetails", "connectedResources", - "reEnrollGateway" + "reEnrollGateway", + "createPool" ] as const); const deleteGatewayById = useDeleteGatewayById(); @@ -196,251 +174,330 @@ export const GatewayTab = withPermission(

Gateways

+
+ + +
- - {(isAllowed: boolean) => ( - + )} + + ) : ( + showPoolsTab && ( + - Create Gateway - - )} - + {(isAllowed: boolean) => ( + + )} + + ) + )}
-

- Create and configure gateway to access private network resources from Infisical -

-
-
- setSearch(e.target.value)} - leftIcon={} - placeholder="Search gateway..." - className="flex-1" - /> -
- - - - - - - - - - - {isGatewaysLoading && ( - - )} - {filteredGateway?.map((el) => ( - - + + ))} + +
NameConnected - Health Check - - - - -
-
- {el.name} - {(() => { - if (el.isPending) { - return ( + {activeSubTab === "gateway-pools" ? ( + + ) : ( + <> +

+ Create and configure gateway to access private network resources from Infisical +

+
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search gateway..." + className="flex-1" + /> +
+ + + + + + {showPoolsTab && } + + + + + + {isGatewaysLoading && ( + + )} + {filteredGateway?.map((el) => ( + + - - - + {showPoolsTab && ( + + )} + + + - - ))} - -
NamePoolsConnected + Health Check + + + + +
+
+ {el.name} + {(() => { + if ("isExpired" in el && el.isExpired) { + return ( + + Expired + + ); + } + if (el.isPending) { + return ( + + Pending + + ); + } + return ( + + Gateway v{el.isV1 ? "1" : "2"} + + ); + })()} + {"hasReEnrollToken" in el && el.hasReEnrollToken && ( - Pending + Re-enrolling - ); - } - return ( - - Gateway v{el.isV1 ? "1" : "2"} - - ); - })()} - {"hasReEnrollToken" in el && el.hasReEnrollToken && ( - - Re-enrolling - - )} -
-
- {el.isPending ? ( - - ) : ( - { - setSelectedGateway({ id: el.id, name: el.name }); - handlePopUpOpen("connectedResources"); - }} - /> - )} - - - - - - - - - - - - } - onClick={() => navigator.clipboard.writeText(el.id)} - > - Copy ID - - {!el.isV1 && !el.isPending && ( - } - onClick={() => handleTriggerHealthCheck(el.id)} - > - Trigger Health Check - )} - {el.isV1 && ( - - {(isAllowed: boolean) => ( + + + {(gatewayPoolMap.get(el.id) ?? []).length > 0 ? ( +
+ {(gatewayPoolMap.get(el.id) ?? []).map((poolName) => ( + + {poolName} + + ))} +
+ ) : ( + + )} +
+ {el.isPending ? ( + + ) : ( + { + setSelectedGateway({ id: el.id, name: el.name }); + handlePopUpOpen("connectedResources"); + }} + /> + )} + + + + + + + + + + + + } + onClick={() => navigator.clipboard.writeText(el.id)} + > + Copy ID + + {!el.isV1 && !el.isPending && ( } - onClick={() => handlePopUpOpen("editDetails", el)} + icon={} + onClick={() => handleTriggerHealthCheck(el.id)} > - Edit Details + Trigger Health Check )} - - )} - {!el.isV1 && - (el.isPending || ("identityId" in el && !el.identityId)) && ( + {el.isV1 && ( + + {(isAllowed: boolean) => ( + } + onClick={() => handlePopUpOpen("editDetails", el)} + > + Edit Details + + )} + + )} + {!el.isV1 && + (el.isPending || ("identityId" in el && !el.identityId)) && ( + + {(isAllowed: boolean) => ( + } + onClick={() => handlePopUpOpen("reEnrollGateway", el)} + > + Re-enroll + + )} + + )} {(isAllowed: boolean) => ( } - onClick={() => handlePopUpOpen("reEnrollGateway", el)} + icon={} + className="text-red" + onClick={() => handlePopUpOpen("deleteGateway", el)} > - Re-enroll + Delete Gateway )} - )} - - {(isAllowed: boolean) => ( - } - className="text-red" - onClick={() => handlePopUpOpen("deleteGateway", el)} - > - Delete Gateway - - )} - - - - -
- handlePopUpToggle("editDetails", isOpen)} - > - - handlePopUpToggle("editDetails")} + + + +
+ handlePopUpToggle("editDetails", isOpen)} + > + + handlePopUpToggle("editDetails")} + /> + + + {!isGatewaysLoading && !filteredGateway?.length && ( + + )} + handlePopUpToggle("deleteGateway", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => handleDeleteGateway()} /> - - - {!isGatewaysLoading && !filteredGateway?.length && ( - - )} - handlePopUpToggle("deleteGateway", isOpen)} - deleteKey="confirm" - onDeleteApproved={() => handleDeleteGateway()} - /> - handlePopUpToggle("deployGateway", isOpen)} - /> - handlePopUpToggle("reEnrollGateway", isOpen)} - gatewayData={ - popUp.reEnrollGateway.data as { - id: string; - name: string; - isPending: boolean; - } | null - } - /> - {selectedGateway && ( - { - handlePopUpToggle("connectedResources", isOpen); - if (!isOpen) setSelectedGateway(null); - }} - gatewayId={selectedGateway.id} - gatewayName={selectedGateway.name} - /> - )} -
-
+ handlePopUpToggle("deployGateway", isOpen)} + /> + handlePopUpToggle("reEnrollGateway", isOpen)} + gatewayData={ + popUp.reEnrollGateway.data as { + id: string; + name: string; + isPending: boolean; + } | null + } + /> + {selectedGateway && ( + { + handlePopUpToggle("connectedResources", isOpen); + if (!isOpen) setSelectedGateway(null); + }} + gatewayId={selectedGateway.id} + gatewayName={selectedGateway.name} + /> + )} + + + + )} + handlePopUpToggle("createPool", isOpen)} + /> ); }, diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/CreateGatewayPoolModal.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/CreateGatewayPoolModal.tsx new file mode 100644 index 00000000000..3de6356cfdc --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/CreateGatewayPoolModal.tsx @@ -0,0 +1,92 @@ +import { useMemo, useState } from "react"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { useCreateGatewayPool, useListGatewayPools } from "@app/hooks/api/gateway-pools"; +import { slugSchema } from "@app/lib/schemas"; + +const formSchema = z.object({ + name: slugSchema({ field: "name" }) +}); + +type Props = { + isOpen: boolean; + onToggle: (isOpen: boolean) => void; +}; + +export const CreateGatewayPoolModal = ({ isOpen, onToggle }: Props) => { + const createPool = useCreateGatewayPool(); + const { data: pools } = useListGatewayPools(); + const [name, setName] = useState(""); + const [formErrors, setFormErrors] = useState([]); + + const errors = useMemo(() => { + const errorMap: Record = {}; + formErrors.forEach((issue) => { + if (issue.path.length > 0) errorMap[String(issue.path[0])] = issue.message; + }); + return errorMap; + }, [formErrors]); + + const handleSubmit = async () => { + setFormErrors([]); + const validation = formSchema.safeParse({ name }); + if (!validation.success) { + setFormErrors(validation.error.issues); + return; + } + + const existingNames = pools?.map((p) => p.name) || []; + if (existingNames.includes(name.trim())) { + createNotification({ + type: "error", + text: "A gateway pool with this name already exists." + }); + return; + } + + try { + await createPool.mutateAsync({ name }); + createNotification({ type: "success", text: `Pool "${name}" created` }); + setName(""); + setFormErrors([]); + onToggle(false); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to create pool"; + createNotification({ type: "error", text: message }); + } + }; + + return ( + { + if (!open) { + setName(""); + setFormErrors([]); + } + onToggle(open); + }} + > + + + setName(e.target.value)} + placeholder="Enter gateway pool name" + autoFocus + /> + +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/EditGatewayPoolModal.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/EditGatewayPoolModal.tsx new file mode 100644 index 00000000000..f38be23be86 --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/EditGatewayPoolModal.tsx @@ -0,0 +1,86 @@ +import { useEffect, useMemo, useState } from "react"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { useListGatewayPools, useUpdateGatewayPool } from "@app/hooks/api/gateway-pools"; +import { TGatewayPool } from "@app/hooks/api/gateway-pools/types"; +import { slugSchema } from "@app/lib/schemas"; + +const formSchema = z.object({ + name: slugSchema({ field: "name" }) +}); + +type Props = { + isOpen: boolean; + onToggle: (isOpen: boolean) => void; + pool?: TGatewayPool; +}; + +export const EditGatewayPoolModal = ({ isOpen, onToggle, pool }: Props) => { + const updatePool = useUpdateGatewayPool(); + const { data: pools } = useListGatewayPools(); + const [name, setName] = useState(""); + const [formErrors, setFormErrors] = useState([]); + + const errors = useMemo(() => { + const errorMap: Record = {}; + formErrors.forEach((issue) => { + if (issue.path.length > 0) errorMap[String(issue.path[0])] = issue.message; + }); + return errorMap; + }, [formErrors]); + + useEffect(() => { + if (pool) { + setName(pool.name); + setFormErrors([]); + } + }, [pool]); + + const handleSubmit = async () => { + if (!pool) return; + setFormErrors([]); + const validation = formSchema.safeParse({ name }); + if (!validation.success) { + setFormErrors(validation.error.issues); + return; + } + + const existingNames = pools?.filter((p) => p.id !== pool.id).map((p) => p.name) || []; + if (existingNames.includes(name.trim())) { + createNotification({ + type: "error", + text: "A gateway pool with this name already exists." + }); + return; + } + + try { + await updatePool.mutateAsync({ poolId: pool.id, name }); + createNotification({ type: "success", text: "Pool updated" }); + onToggle(false); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to update pool"; + createNotification({ type: "error", text: message }); + } + }; + + return ( + + + + setName(e.target.value)} /> + +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayHealthStatus.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayHealthStatus.tsx new file mode 100644 index 00000000000..a4b74def49b --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayHealthStatus.tsx @@ -0,0 +1,62 @@ +import { faClock } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Tooltip } from "@app/components/v2"; +import { GatewayHealthCheckStatus } from "@app/hooks/api/gateways-v2/types"; + +export const GatewayHealthStatus = ({ + heartbeat, + lastHealthCheckStatus, + isPending, + isExpired +}: { + heartbeat?: string | null; + lastHealthCheckStatus?: GatewayHealthCheckStatus | null; + isPending?: boolean; + isExpired?: boolean; +}) => { + if (isExpired) { + return ( + + + Expired + + + ); + } + + if (isPending) { + return ( + + + + Pending + + + ); + } + + if (!heartbeat && !lastHealthCheckStatus) { + return ( + + Unregistered + + ); + } + + const heartbeatDate = heartbeat ? new Date(heartbeat) : null; + + const isHealthy = lastHealthCheckStatus === GatewayHealthCheckStatus.Healthy; + + const tooltipContent = heartbeatDate + ? `Last health check: ${heartbeatDate.toLocaleString()}` + : "No health check data available"; + + return ( + + + {isHealthy ? "Healthy" : "Unreachable"} + + + ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayPoolsContent.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayPoolsContent.tsx new file mode 100644 index 00000000000..f3c91a0eec2 --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayPoolsContent.tsx @@ -0,0 +1,221 @@ +import { useState } from "react"; +import { faEllipsisV, faMagnifyingGlass, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + IconButton, + Input, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useSubscription } from "@app/context/SubscriptionContext"; +import { usePopUp } from "@app/hooks"; +import { useDeleteGatewayPool, useListGatewayPools } from "@app/hooks/api/gateway-pools"; +import { TGatewayPool } from "@app/hooks/api/gateway-pools/types"; + +import { EditGatewayPoolModal } from "./EditGatewayPoolModal"; +import { PoolConnectedResourcesDrawer } from "./PoolConnectedResourcesDrawer"; +import { PoolDetailSheet } from "./PoolDetailSheet"; +import { PoolHealthBadge } from "./PoolHealthBadge"; + +export const GatewayPoolsContent = () => { + const { subscription } = useSubscription(); + const isEnterprise = subscription?.gatewayPool; + const [search, setSearch] = useState(""); + const [selectedPoolId, setSelectedPoolId] = useState(null); + const [resourcesPool, setResourcesPool] = useState<{ id: string; name: string } | null>(null); + const { data: pools, isPending: isPoolsLoading } = useListGatewayPools({ + refetchInterval: 15_000 + }); + const deletePool = useDeleteGatewayPool(); + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "editPool", + "deletePool", + "upgradePlan" + ] as const); + + const filteredPools = pools?.filter((p) => p.name.toLowerCase().includes(search.toLowerCase())); + const selectedPool = pools?.find((p) => p.id === selectedPoolId) ?? null; + + const handleDeletePool = async () => { + const pool = popUp.deletePool.data as TGatewayPool; + if (!pool) return; + + try { + await deletePool.mutateAsync(pool.id); + createNotification({ type: "success", text: `Pool "${pool.name}" deleted` }); + handlePopUpToggle("deletePool", false); + if (selectedPoolId === pool.id) setSelectedPoolId(null); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to delete pool"; + createNotification({ type: "error", text: message }); + } + }; + + if (!isEnterprise) { + return ( +
+

+ Pool gateways for high availability and automatic failover +

+
+
🛡️
+

Enterprise Feature

+

+ Gateway Pools provide high availability and automatic failover. When a gateway goes + down, the platform automatically routes through a healthy member of the pool. +

+ +
+ handlePopUpToggle("upgradePlan", isOpen)} + text="To use gateway pools, upgrade to Infisical's Enterprise plan." + /> +
+ ); + } + + return ( +
+

+ Pool gateways for high availability and automatic failover +

+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search pool..." + className="flex-1" + /> +
+ + + + + + + + + + + {isPoolsLoading && } + {filteredPools?.map((pool) => ( + setSelectedPoolId(pool.id)} + > + + + + + + ))} + {!isPoolsLoading && filteredPools?.length === 0 && ( + + + + )} + +
NameConnectedHealth +
{pool.name} + {pool.connectedResourcesCount > 0 ? ( + + ) : ( + + )} + + + +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > + + + + + + + + handlePopUpOpen("editPool", pool)}> + Edit + + } + onClick={() => handlePopUpOpen("deletePool", pool)} + className="text-red-500" + > + Delete + + + +
+
+ +
+
+ + { + if (!open) setSelectedPoolId(null); + }} + pool={selectedPool} + /> + {resourcesPool && ( + { + if (!open) setResourcesPool(null); + }} + poolId={resourcesPool.id} + poolName={resourcesPool.name} + /> + )} + handlePopUpToggle("editPool", isOpen)} + pool={popUp.editPool.data as TGatewayPool} + /> + handlePopUpToggle("deletePool", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleDeletePool} + /> +
+ ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/PoolConnectedResourcesDrawer.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/PoolConnectedResourcesDrawer.tsx new file mode 100644 index 00000000000..290acfb2c1a --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/PoolConnectedResourcesDrawer.tsx @@ -0,0 +1,129 @@ +import { Link } from "@tanstack/react-router"; +import { ExternalLinkIcon } from "lucide-react"; + +import { Spinner } from "@app/components/v2"; +import { + Badge, + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + UnstableAccordion, + UnstableAccordionContent, + UnstableAccordionItem, + UnstableAccordionTrigger +} from "@app/components/v3"; +import { useOrganization } from "@app/context"; +import { + TGatewayPoolConnectedResources, + useGetGatewayPoolConnectedResources +} from "@app/hooks/api/gateway-pools"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + poolId: string; + poolName: string; +}; + +const getTotalResourceCount = (resources: TGatewayPoolConnectedResources | undefined): number => { + if (!resources) return 0; + return resources.kubernetesAuths.length; +}; + +type ResourceRowProps = { + name: string; + subtitle: string; + to: string; + params: Record; + isLast?: boolean; +}; + +const ResourceRow = ({ name, subtitle, to, params, isLast }: ResourceRowProps) => { + return ( + +
+ {name} + {subtitle} +
+ + + ); +}; + +export const PoolConnectedResourcesDrawer = ({ isOpen, onOpenChange, poolId, poolName }: Props) => { + const { currentOrg } = useOrganization(); + const { data: resources, isPending } = useGetGatewayPoolConnectedResources(isOpen ? poolId : ""); + + const totalCount = getTotalResourceCount(resources); + + const defaultOpenSections = [resources?.kubernetesAuths.length ? "kubernetes-auth" : null].filter( + Boolean + ) as string[]; + + return ( + + + + Connected Resources + {poolName} + + +
+ {isPending ? ( +
+ +
+ ) : ( +
+

+ {totalCount > 0 ? ( + <> + {totalCount} resource{totalCount !== 1 ? "s" : ""} connected + + ) : ( + "No resources connected to this pool" + )} +

+ + {totalCount > 0 && ( + + {(resources?.kubernetesAuths.length ?? 0) > 0 && ( + + + Kubernetes Auth + {resources?.kubernetesAuths.length} + + + {resources?.kubernetesAuths.map((auth, idx) => ( + + ))} + + + )} + + )} +
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/PoolDetailSheet.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/PoolDetailSheet.tsx new file mode 100644 index 00000000000..d4f3d5607c8 --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/PoolDetailSheet.tsx @@ -0,0 +1,282 @@ +import { useMemo, useState } from "react"; +import { faEllipsisV, faHeartPulse, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useQuery } from "@tanstack/react-query"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal, IconButton } from "@app/components/v2"; +import { + Badge, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + Popover, + PopoverContent, + PopoverTrigger, + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + UnstableDropdownMenu, + UnstableDropdownMenuContent, + UnstableDropdownMenuItem, + UnstableDropdownMenuTrigger, + UnstableTable, + UnstableTableBody, + UnstableTableCell, + UnstableTableHead, + UnstableTableHeader, + UnstableTableRow +} from "@app/components/v3"; +import { + OrgGatewayPoolPermissionActions, + OrgPermissionSubjects +} from "@app/context/OrgPermissionContext/types"; +import { usePopUp } from "@app/hooks"; +import { useAddGatewayToPool, useRemoveGatewayFromPool } from "@app/hooks/api/gateway-pools"; +import { TGatewayPool } from "@app/hooks/api/gateway-pools/types"; +import { gatewaysQueryKeys } from "@app/hooks/api/gateways/queries"; +import { useTriggerGatewayV2Heartbeat } from "@app/hooks/api/gateways-v2"; +import { GatewayHealthCheckStatus } from "@app/hooks/api/gateways-v2/types"; + +import { PoolHealthBadge } from "./PoolHealthBadge"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + pool: TGatewayPool | null; +}; + +export const PoolDetailSheet = ({ isOpen, onOpenChange, pool }: Props) => { + const { data: allGateways } = useQuery({ + ...gatewaysQueryKeys.list(), + enabled: isOpen + }); + const addGateway = useAddGatewayToPool(); + const removeGateway = useRemoveGatewayFromPool(); + const triggerHealthCheck = useTriggerGatewayV2Heartbeat(); + const [isAddGatewayOpen, setIsAddGatewayOpen] = useState(false); + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["removeGateway"] as const); + + const memberGateways = useMemo( + () => allGateways?.filter((g) => pool?.memberGatewayIds.includes(g.id)) ?? [], + [allGateways, pool?.memberGatewayIds] + ); + + const availableGateways = useMemo( + () => allGateways?.filter((g) => !g.isV1 && !pool?.memberGatewayIds.includes(g.id)) ?? [], + [allGateways, pool?.memberGatewayIds] + ); + + const handleAdd = async (gatewayId: string) => { + if (!pool) return; + try { + await addGateway.mutateAsync({ poolId: pool.id, gatewayId }); + createNotification({ type: "success", text: "Gateway added to pool" }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to add gateway"; + createNotification({ type: "error", text: message }); + } + }; + + const handleRemove = async () => { + if (!pool) return; + const gwData = popUp.removeGateway.data as { id: string; name: string } | undefined; + if (!gwData) return; + try { + await removeGateway.mutateAsync({ poolId: pool.id, gatewayId: gwData.id }); + handlePopUpToggle("removeGateway", false); + createNotification({ type: "success", text: `Removed "${gwData.name}" from pool` }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to remove gateway"; + createNotification({ type: "error", text: message }); + } + }; + + const handleHealthCheck = async (gatewayId: string) => { + try { + await triggerHealthCheck.mutateAsync(gatewayId); + createNotification({ type: "success", text: "Health check successful" }); + } catch { + createNotification({ type: "error", text: "Health check failed - gateway is unreachable" }); + } + }; + + if (!pool) return null; + + const createdDate = new Date(pool.createdAt).toLocaleDateString("en-US", { + year: "numeric", + month: "numeric", + day: "numeric" + }); + + return ( + + + + {pool.name} + + +
+
+
Health
+
+ +
+
+
+
+
Total Gateways
+
{pool.memberCount}
+
+
+
+
Created
+
{createdDate}
+
+
+ +
+
+

Member Gateways

+ + {(isAllowed: boolean) => { + const isDisabled = !isAllowed || availableGateways.length === 0; + return ( + + + + + + + + + No gateways available. + + {availableGateways.map((gw) => ( + { + setIsAddGatewayOpen(false); + handleAdd(gw.id); + }} + > + {gw.name} + + ))} + + + + + + ); + }} + +
+ + + + + Name + Status + + + + + {memberGateways.length === 0 && ( + + + No gateways in this pool + + + )} + {memberGateways.map((gw) => { + const hasHeartbeat = + "heartbeat" in gw && + gw.heartbeat && + new Date(gw.heartbeat).getTime() > Date.now() - 60 * 60 * 1000; + const isNotFailed = + !("lastHealthCheckStatus" in gw) || + gw.lastHealthCheckStatus !== GatewayHealthCheckStatus.Failed; + const isOnline = hasHeartbeat && isNotFailed; + + return ( + + +
+ {gw.name} + Gateway v{gw.isV1 ? "1" : "2"} +
+
+ + + {isOnline ? "Healthy" : "Unreachable"} + + + + + + + + + + + {!gw.isV1 && ( + handleHealthCheck(gw.id)}> + + Trigger health check + + )} + + handlePopUpOpen("removeGateway", { id: gw.id, name: gw.name }) + } + > + + Remove from pool + + + + +
+ ); + })} +
+
+
+ + handlePopUpToggle("removeGateway", open)} + deleteKey="confirm" + buttonText="Remove" + onDeleteApproved={handleRemove} + /> + + + ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/PoolHealthBadge.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/PoolHealthBadge.tsx new file mode 100644 index 00000000000..792b600d6ca --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/PoolHealthBadge.tsx @@ -0,0 +1,18 @@ +import { TGatewayPool } from "@app/hooks/api/gateway-pools/types"; + +export const PoolHealthBadge = ({ pool }: { pool: TGatewayPool }) => { + if (pool.memberCount === 0) { + return No members; + } + let colorClass = "text-yellow-500"; + if (pool.healthyMemberCount === 0) { + colorClass = "text-red-400"; + } else if (pool.healthyMemberCount === pool.memberCount) { + colorClass = "text-green-500"; + } + return ( + + {pool.healthyMemberCount}/{pool.memberCount} healthy + + ); +};