diff --git a/backend/src/db/migrations/20260521025807_relay-resource-auth.ts b/backend/src/db/migrations/20260521025807_relay-resource-auth.ts new file mode 100644 index 00000000000..653ce72d68a --- /dev/null +++ b/backend/src/db/migrations/20260521025807_relay-resource-auth.ts @@ -0,0 +1,55 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + // 1. Add relayId FK to resource_auth_methods — same nullable-FK-per-resource-type + // pattern used for gatewayId (see 20260430143000_resource-auth-methods.ts). + if (await knex.schema.hasTable(TableName.ResourceAuthMethod)) { + const hasRelayId = await knex.schema.hasColumn(TableName.ResourceAuthMethod, "relayId"); + if (!hasRelayId) { + await knex.schema.alterTable(TableName.ResourceAuthMethod, (t) => { + t.uuid("relayId").nullable(); + t.foreign("relayId").references("id").inTable(TableName.Relay).onDelete("CASCADE"); + }); + + await knex.schema.raw(` + CREATE UNIQUE INDEX one_method_per_relay + ON ${TableName.ResourceAuthMethod} ("relayId") + WHERE "relayId" IS NOT NULL + `); + } + } + + // 2. Add tokenVersion to relays — used for stateless JWT revocation, + // same pattern as gateways_v2.tokenVersion. + if (await knex.schema.hasTable(TableName.Relay)) { + const hasTokenVersion = await knex.schema.hasColumn(TableName.Relay, "tokenVersion"); + if (!hasTokenVersion) { + await knex.schema.alterTable(TableName.Relay, (t) => { + t.integer("tokenVersion").notNullable().defaultTo(0); + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Relay)) { + const hasTokenVersion = await knex.schema.hasColumn(TableName.Relay, "tokenVersion"); + if (hasTokenVersion) { + await knex.schema.alterTable(TableName.Relay, (t) => { + t.dropColumn("tokenVersion"); + }); + } + } + + if (await knex.schema.hasTable(TableName.ResourceAuthMethod)) { + const hasRelayId = await knex.schema.hasColumn(TableName.ResourceAuthMethod, "relayId"); + if (hasRelayId) { + await knex.schema.raw(`DROP INDEX IF EXISTS one_method_per_relay`); + await knex.schema.alterTable(TableName.ResourceAuthMethod, (t) => { + t.dropColumn("relayId"); + }); + } + } +} diff --git a/backend/src/db/schemas/relays.ts b/backend/src/db/schemas/relays.ts index 82a5fa8303b..531ecd5f82c 100644 --- a/backend/src/db/schemas/relays.ts +++ b/backend/src/db/schemas/relays.ts @@ -16,7 +16,8 @@ export const RelaysSchema = z.object({ name: z.string(), host: z.string(), heartbeat: z.date().nullable().optional(), - healthAlertedAt: z.date().nullable().optional() + healthAlertedAt: z.date().nullable().optional(), + tokenVersion: z.number().default(0) }); export type TRelays = z.infer; diff --git a/backend/src/db/schemas/resource-auth-methods.ts b/backend/src/db/schemas/resource-auth-methods.ts index 728192dd69a..187956282a2 100644 --- a/backend/src/db/schemas/resource-auth-methods.ts +++ b/backend/src/db/schemas/resource-auth-methods.ts @@ -12,7 +12,8 @@ export const ResourceAuthMethodsSchema = z.object({ gatewayId: z.string().uuid().nullable().optional(), method: z.string(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + relayId: z.string().uuid().nullable().optional() }); export type TResourceAuthMethods = z.infer; diff --git a/backend/src/ee/routes/v1/relay-router.ts b/backend/src/ee/routes/v1/relay-router.ts index 66e6640e45c..982f32dcf7c 100644 --- a/backend/src/ee/routes/v1/relay-router.ts +++ b/backend/src/ee/routes/v1/relay-router.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { RelaysSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { UnauthorizedError } from "@app/lib/errors"; @@ -70,6 +71,7 @@ export const registerRelayRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { + hide: true, operationId: "registerOrgRelay", body: z.object({ host: z.string(), @@ -142,13 +144,24 @@ export const registerRelayRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - return server.services.relay.deleteRelay({ + const relay = await server.services.relay.deleteRelay({ id: req.params.id, actorId: req.permission.id, actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.RELAY_DELETE, + metadata: { relayId: relay.id, name: relay.name } + } + }); + + return relay; } }); @@ -212,6 +225,7 @@ export const registerRelayRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { + hide: true, operationId: "heartbeatOrgRelay", body: z.object({ name: slugSchema({ min: 1, max: 32, field: "name" }) diff --git a/backend/src/ee/routes/v2/index.ts b/backend/src/ee/routes/v2/index.ts index 0119f3ec4ad..6d2e56ace68 100644 --- a/backend/src/ee/routes/v2/index.ts +++ b/backend/src/ee/routes/v2/index.ts @@ -10,6 +10,7 @@ import { import { registerDeprecatedProjectRoleRouter } from "./deprecated-project-role-router"; import { registerGatewayV2Router } from "./gateway-router"; import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; +import { registerRelayV2Router } from "./relay-router"; import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router"; import { registerSecretVersionRouter } from "./secret-version-router"; @@ -57,4 +58,6 @@ export const registerV2EERoutes = async (server: FastifyZodProvider) => { ); await server.register(registerSecretVersionRouter, { prefix: "/secret-versions" }); + + await server.register(registerRelayV2Router, { prefix: "/relays" }); }; diff --git a/backend/src/ee/routes/v2/relay-router.ts b/backend/src/ee/routes/v2/relay-router.ts new file mode 100644 index 00000000000..cf5d3d9518c --- /dev/null +++ b/backend/src/ee/routes/v2/relay-router.ts @@ -0,0 +1,515 @@ +import z from "zod"; + +import { RelaysSchema } from "@app/db/schemas"; +import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; +import { validateAccountIds, validatePrincipalArns } from "@app/ee/services/resource-auth-method/aws-auth-validators"; +import { ResourceAuthMethodType } from "@app/ee/services/resource-auth-method/resource-auth-method-fns"; +import { UnauthorizedError } from "@app/lib/errors"; +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 { ActorType, AuthMode } from "@app/services/auth/auth-type"; + +const loginRateLimit = { windowMs: 60 * 1000, max: 10 }; + +const SanitizedRelaySchema = RelaysSchema.pick({ + id: true, + identityId: true, + name: true, + host: true, + createdAt: true, + updatedAt: true, + heartbeat: true +}).extend({ + canRevoke: z.boolean() +}); + +const AwsAuthMethodConfigSchema = z.object({ + id: z.string().uuid(), + stsEndpoint: z.string(), + allowedPrincipalArns: z.string(), + allowedAccountIds: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); + +const TokenAuthMethodConfigSchema = z.object({}); + +const IdentityAuthMethodConfigSchema = z.object({ + identityId: z.string(), + identityName: z.string().nullable() +}); + +const AuthMethodViewSchema = z.discriminatedUnion("method", [ + z.object({ method: z.literal(ResourceAuthMethodType.Aws), config: AwsAuthMethodConfigSchema }), + z.object({ method: z.literal(ResourceAuthMethodType.Token), config: TokenAuthMethodConfigSchema }), + z.object({ method: z.literal(ResourceAuthMethodType.Identity), config: IdentityAuthMethodConfigSchema }) +]); + +const RelayWithAuthMethodSchema = SanitizedRelaySchema.extend({ + authMethod: AuthMethodViewSchema +}); + +const AwsAuthMethodInputSchema = z + .object({ + method: z.literal(ResourceAuthMethodType.Aws), + stsEndpoint: z.string().trim().min(1).default("https://sts.amazonaws.com/"), + allowedPrincipalArns: validatePrincipalArns, + allowedAccountIds: validateAccountIds + }) + .refine((data) => data.allowedPrincipalArns.trim().length > 0 || data.allowedAccountIds.trim().length > 0, { + message: "At least one of allowedPrincipalArns or allowedAccountIds must be set", + path: ["allowedPrincipalArns"] + }); + +const TokenAuthMethodInputSchema = z.object({ + method: z.literal(ResourceAuthMethodType.Token) +}); + +const SettableAuthMethodInputSchema = z.union([AwsAuthMethodInputSchema, TokenAuthMethodInputSchema]); + +export const registerRelayV2Router = async (server: FastifyZodProvider) => { + // ─── POST / (Create Relay) ──────────────────────────────────────────────── + server.route({ + method: "POST", + url: "/", + config: { rateLimit: writeLimit }, + schema: { + description: "Create a new relay with an initial auth method.", + body: z.object({ + name: slugSchema({ min: 1, max: 32, field: "name" }), + host: z.string().trim().min(1), + authMethod: SettableAuthMethodInputSchema + }), + response: { + 200: RelayWithAuthMethodSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { authMethod: authMethodInput, ...rest } = req.body; + const authMethodArg = + authMethodInput.method === ResourceAuthMethodType.Aws + ? { + method: "aws" as const, + config: { + stsEndpoint: authMethodInput.stsEndpoint, + allowedPrincipalArns: authMethodInput.allowedPrincipalArns, + allowedAccountIds: authMethodInput.allowedAccountIds + } + } + : { method: "token" as const }; + + const relay = await server.services.relay.createRelay({ + ...rest, + authMethod: authMethodArg, + actor: { + type: req.permission.type, + id: req.permission.id, + orgId: req.permission.orgId, + authMethod: req.permission.authMethod + } + }); + + const view = await server.services.resourceAuthMethod.loadView({ type: "relay", id: relay.id }); + if (!view) throw new UnauthorizedError({ message: "Auth method missing after create" }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.RELAY_CREATE, + metadata: { relayId: relay.id, name: relay.name } + } + }); + + const canRevoke = await server.services.resourceAuthMethod.canRevoke(relay, "relay"); + + return { ...relay, canRevoke, authMethod: view }; + } + }); + + // ─── GET /:relayId ──────────────────────────────────────────────────────── + server.route({ + method: "GET", + url: "/:relayId", + config: { rateLimit: readLimit }, + schema: { + params: z.object({ relayId: z.string().uuid() }), + response: { + 200: RelayWithAuthMethodSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const relay = await server.services.relay.getOrgRelay({ + relayId: req.params.relayId, + orgId: req.permission.orgId + }); + + const view = await server.services.resourceAuthMethod.getByRelayId({ + resource: { type: "relay", id: req.params.relayId }, + actor: req.permission + }); + + const canRevoke = await server.services.resourceAuthMethod.canRevoke(relay, "relay"); + + return { ...relay, canRevoke, authMethod: view }; + } + }); + + // ─── GET /:relayId/gateways ───────────────────────────────────────────── + server.route({ + method: "GET", + url: "/:relayId/gateways", + config: { rateLimit: readLimit }, + schema: { + params: z.object({ relayId: z.string().uuid() }), + response: { + 200: z.array( + z.object({ + id: z.string(), + name: z.string(), + createdAt: z.date(), + heartbeat: z.date().nullable().optional() + }) + ) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + return server.services.relay.getConnectedGateways({ + relayId: req.params.relayId, + orgPermission: req.permission + }); + } + }); + + // ─── PATCH /:relayId ────────────────────────────────────────────────────── + server.route({ + method: "PATCH", + url: "/:relayId", + config: { rateLimit: writeLimit }, + schema: { + params: z.object({ relayId: z.string().uuid() }), + body: z.object({ + host: z.string().trim().min(1).optional(), + authMethod: SettableAuthMethodInputSchema.optional() + }), + response: { + 200: RelayWithAuthMethodSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + let relay; + + if (req.body.host) { + relay = await server.services.relay.updateRelay({ + relayId: req.params.relayId, + host: req.body.host, + actor: req.permission + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.RELAY_UPDATE, + metadata: { relayId: req.params.relayId, name: relay.name, host: req.body.host } + } + }); + } else { + relay = await server.services.relay.getOrgRelay({ + relayId: req.params.relayId, + orgId: req.permission.orgId + }); + } + + if (req.body.authMethod) { + const authMethodInput = req.body.authMethod; + const authMethodArg = + authMethodInput.method === ResourceAuthMethodType.Aws + ? { + method: "aws" as const, + stsEndpoint: authMethodInput.stsEndpoint, + allowedPrincipalArns: authMethodInput.allowedPrincipalArns, + allowedAccountIds: authMethodInput.allowedAccountIds + } + : { method: "token" as const }; + + const view = await server.services.resourceAuthMethod.setMethod({ + resource: { type: "relay", id: req.params.relayId }, + authMethod: authMethodArg, + actor: req.permission + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.RESOURCE_AUTH_METHOD_UPDATE, + metadata: { + resourceType: "relay", + resourceId: req.params.relayId, + method: view.method as "aws" | "token", + methodConfigId: "config" in view && "id" in view.config ? view.config.id : req.params.relayId + } + } + }); + + const canRevoke = await server.services.resourceAuthMethod.canRevoke(relay, "relay"); + return { ...relay, canRevoke, authMethod: view }; + } + + const view = await server.services.resourceAuthMethod.getByRelayId({ + resource: { type: "relay", id: req.params.relayId }, + actor: req.permission + }); + const canRevoke = await server.services.resourceAuthMethod.canRevoke(relay, "relay"); + return { ...relay, canRevoke, authMethod: view }; + } + }); + + // ─── POST /:relayId/token-auth/generate-enrollment-token ────────────────── + server.route({ + method: "POST", + url: "/:relayId/token-auth/generate-enrollment-token", + config: { rateLimit: writeLimit }, + schema: { + params: z.object({ relayId: z.string().uuid() }), + response: { + 200: z.object({ token: z.string(), expiresAt: z.date() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const result = await server.services.resourceAuthMethod.mintToken({ + resource: { type: "relay", id: req.params.relayId }, + actor: req.permission + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.RELAY_ENROLLMENT_TOKEN_CREATE, + metadata: { tokenId: result.id, name: result.resourceName } + } + }); + + return { token: result.token, expiresAt: result.expiresAt }; + } + }); + + // ─── POST /:relayId/revoke ──────────────────────────────────────────────── + server.route({ + method: "POST", + url: "/:relayId/revoke", + config: { rateLimit: writeLimit }, + schema: { + params: z.object({ relayId: z.string().uuid() }), + response: { + 200: z.object({ method: z.string(), deletedTokenCount: z.number() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const result = await server.services.resourceAuthMethod.revokeAccess({ + resource: { type: "relay", id: req.params.relayId }, + actor: req.permission + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.RESOURCE_AUTH_METHOD_REVOKE, + metadata: { + resourceType: "relay", + resourceId: req.params.relayId, + method: result.method, + resourceName: result.resourceName, + deletedTokenCount: result.deletedTokenCount + } + } + }); + + return { method: result.method, deletedTokenCount: result.deletedTokenCount }; + } + }); + + // ─── POST /login ────────────────────────────────────────────────────────── + server.route({ + method: "POST", + url: "/login", + config: { rateLimit: loginRateLimit }, + schema: { + body: z.discriminatedUnion("method", [ + z.object({ + method: z.literal(ResourceAuthMethodType.Aws), + relayId: z.string().uuid(), + iamHttpRequestMethod: z.string().default("POST"), + iamRequestBody: z.string(), + iamRequestHeaders: z.string() + }), + z.object({ + method: z.literal(ResourceAuthMethodType.Token), + token: z.string().min(1) + }) + ]), + response: { + 200: z.object({ + accessToken: z.string(), + relayId: z.string(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + if (req.body.method === ResourceAuthMethodType.Aws) { + try { + const result = await server.services.resourceAuthMethod.loginWithAws({ + resource: { type: "relay", id: req.body.relayId }, + iamHttpRequestMethod: req.body.iamHttpRequestMethod, + iamRequestBody: req.body.iamRequestBody, + iamRequestHeaders: req.body.iamRequestHeaders + }); + + await server.services.auditLog + .createAuditLog({ + orgId: result.orgId, + actor: { type: ActorType.RELAY, metadata: { relayId: result.resourceId } }, + event: { + type: EventType.RESOURCE_AUTH_METHOD_LOGIN, + metadata: { + resourceType: "relay", + resourceId: result.resourceId, + method: "aws", + methodConfigId: result.config.id, + principalArn: result.principalArn, + accountId: result.accountId + } + }, + ipAddress: req.ip, + userAgent: req.headers["user-agent"] ?? "", + userAgentType: UserAgentType.OTHER + }) + .catch(() => {}); + + return { + accessToken: result.accessToken, + relayId: result.resourceId, + tokenType: "Bearer" as const + }; + } catch (error) { + if (error instanceof UnauthorizedError && error.detail?.resourceId) { + await server.services.auditLog + .createAuditLog({ + orgId: error.detail.orgId as string, + actor: { type: ActorType.RELAY, metadata: { relayId: error.detail.resourceId as string } }, + event: { + type: EventType.RESOURCE_AUTH_METHOD_LOGIN_FAILED, + metadata: { + resourceType: "relay", + resourceId: error.detail.resourceId as string, + method: "aws", + reasonCode: error.detail.reasonCode as string, + message: error.message, + principalArn: error.detail.principalArn as string | undefined, + accountId: error.detail.accountId as string | undefined + } + }, + ipAddress: req.ip, + userAgent: req.headers["user-agent"] ?? "", + userAgentType: UserAgentType.OTHER + }) + .catch(() => {}); + } + throw error; + } + } + + const result = await server.services.resourceAuthMethod.loginWithToken({ + token: req.body.token, + expectedResourceType: "relay" + }); + + await server.services.auditLog + .createAuditLog({ + orgId: result.orgId, + actor: { type: ActorType.RELAY, metadata: { relayId: result.resourceId } }, + event: { + type: EventType.RESOURCE_AUTH_METHOD_LOGIN, + metadata: { + resourceType: "relay", + resourceId: result.resourceId, + method: "token", + methodConfigId: result.resourceId, + enrollmentTokenId: result.enrollmentTokenId + } + }, + ipAddress: req.ip, + userAgent: req.headers["user-agent"] ?? "", + userAgentType: UserAgentType.CLI + }) + .catch(() => {}); + + return { + accessToken: result.accessToken, + relayId: result.resourceId, + tokenType: "Bearer" as const + }; + } + }); + + // ─── POST /connect ──────────────────────────────────────────────────────── + server.route({ + method: "POST", + url: "/connect", + config: { rateLimit: writeLimit }, + schema: { + response: { + 200: z.object({ + relayId: z.string(), + pki: z.object({ + serverCertificate: z.string(), + serverPrivateKey: z.string(), + clientCertificateChain: z.string() + }), + ssh: z.object({ + serverCertificate: z.string(), + serverPrivateKey: z.string(), + clientCAPublicKey: z.string() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.RELAY_ACCESS_TOKEN]), + handler: async (req) => { + const certs = await server.services.relay.connectRelay({ relayId: req.permission.id }); + + return { + relayId: req.permission.id, + ...certs + }; + } + }); + + // ─── POST /heartbeat ────────────────────────────────────────────────────── + server.route({ + method: "POST", + url: "/heartbeat", + config: { rateLimit: writeLimit }, + schema: { + response: { + 200: z.object({ message: z.string() }) + } + }, + onRequest: verifyAuth([AuthMode.RELAY_ACCESS_TOKEN]), + handler: async (req) => { + await server.services.relay.heartbeatRelay({ relayId: req.permission.id }); + return { message: "Successfully triggered heartbeat" }; + } + }); +}; diff --git a/backend/src/ee/routes/v3/gateway-router.ts b/backend/src/ee/routes/v3/gateway-router.ts index 4694ce6a459..bd99a237565 100644 --- a/backend/src/ee/routes/v3/gateway-router.ts +++ b/backend/src/ee/routes/v3/gateway-router.ts @@ -118,7 +118,7 @@ export const registerGatewayV3Router = async (server: FastifyZodProvider) => { authMethod: authMethodArg }); - const view = await server.services.resourceAuthMethod.loadView(gateway.id); + const view = await server.services.resourceAuthMethod.loadView({ type: "gateway", id: gateway.id }); if (!view) throw new UnauthorizedError({ message: "Auth method missing after create" }); await server.services.auditLog.createAuditLog({ @@ -268,7 +268,7 @@ export const registerGatewayV3Router = async (server: FastifyZodProvider) => { orgId: req.permission.orgId, event: { type: EventType.GATEWAY_ENROLLMENT_TOKEN_CREATE, - metadata: { tokenId: result.id, name: result.gatewayName } + metadata: { tokenId: result.id, name: result.resourceName } } }); @@ -309,7 +309,7 @@ export const registerGatewayV3Router = async (server: FastifyZodProvider) => { resourceType: "gateway", resourceId: req.params.gatewayId, method: result.method, - gatewayName: result.gatewayName, + resourceName: result.resourceName, deletedTokenCount: result.deletedTokenCount } } @@ -362,13 +362,13 @@ export const registerGatewayV3Router = async (server: FastifyZodProvider) => { await server.services.auditLog .createAuditLog({ - orgId: result.gateway.orgId, - actor: { type: ActorType.GATEWAY, metadata: { gatewayId: result.gateway.id } }, + orgId: result.orgId, + actor: { type: ActorType.GATEWAY, metadata: { gatewayId: result.resourceId } }, event: { type: EventType.RESOURCE_AUTH_METHOD_LOGIN, metadata: { resourceType: "gateway", - resourceId: result.gateway.id, + resourceId: result.resourceId, method: "aws", methodConfigId: result.config.id, principalArn: result.principalArn, @@ -384,35 +384,35 @@ export const registerGatewayV3Router = async (server: FastifyZodProvider) => { void server.services.telemetry .sendPostHogEvents({ event: PostHogEventTypes.ResourceAuthMethodLogin, - distinctId: `gateway-${result.gateway.id}`, - organizationId: result.gateway.orgId, + distinctId: `gateway-${result.resourceId}`, + organizationId: result.orgId, properties: { resourceType: "gateway", - resourceId: result.gateway.id, - orgId: result.gateway.orgId, + resourceId: result.resourceId, + orgId: result.orgId, method: "aws" } }) .catch((err) => { - logger.error(err, `Failed to send telemetry [gatewayId=${result.gateway.id}]`); + logger.error(err, `Failed to send telemetry [gatewayId=${result.resourceId}]`); }); return { accessToken: result.accessToken, - gatewayId: result.gateway.id, + gatewayId: result.resourceId, tokenType: "Bearer" as const }; } catch (error) { - if (error instanceof UnauthorizedError && error.detail?.gatewayId) { + if (error instanceof UnauthorizedError && error.detail?.resourceId) { await server.services.auditLog .createAuditLog({ orgId: error.detail.orgId as string, - actor: { type: ActorType.GATEWAY, metadata: { gatewayId: error.detail.gatewayId as string } }, + actor: { type: ActorType.GATEWAY, metadata: { gatewayId: error.detail.resourceId as string } }, event: { type: EventType.RESOURCE_AUTH_METHOD_LOGIN_FAILED, metadata: { resourceType: "gateway", - resourceId: error.detail.gatewayId as string, + resourceId: error.detail.resourceId as string, method: "aws", reasonCode: error.detail.reasonCode as string, message: error.message, @@ -430,19 +430,22 @@ export const registerGatewayV3Router = async (server: FastifyZodProvider) => { } } - const result = await server.services.resourceAuthMethod.loginWithToken({ token: req.body.token }); + const result = await server.services.resourceAuthMethod.loginWithToken({ + token: req.body.token, + expectedResourceType: "gateway" + }); await server.services.auditLog .createAuditLog({ orgId: result.orgId, - actor: { type: ActorType.GATEWAY, metadata: { gatewayId: result.gatewayId } }, + actor: { type: ActorType.GATEWAY, metadata: { gatewayId: result.resourceId } }, event: { type: EventType.RESOURCE_AUTH_METHOD_LOGIN, metadata: { resourceType: "gateway", - resourceId: result.gatewayId, + resourceId: result.resourceId, method: "token", - methodConfigId: result.gatewayId, + methodConfigId: result.resourceId, enrollmentTokenId: result.enrollmentTokenId } }, @@ -455,22 +458,22 @@ export const registerGatewayV3Router = async (server: FastifyZodProvider) => { void server.services.telemetry .sendPostHogEvents({ event: PostHogEventTypes.ResourceAuthMethodLogin, - distinctId: `gateway-${result.gatewayId}`, + distinctId: `gateway-${result.resourceId}`, organizationId: result.orgId, properties: { resourceType: "gateway", - resourceId: result.gatewayId, + resourceId: result.resourceId, orgId: result.orgId, method: "token" } }) .catch((err) => { - logger.error(err, `Failed to send telemetry [gatewayId=${result.gatewayId}]`); + logger.error(err, `Failed to send telemetry [gatewayId=${result.resourceId}]`); }); return { accessToken: result.accessToken, - gatewayId: result.gatewayId, + gatewayId: result.resourceId, tokenType: "Bearer" as const }; } @@ -492,19 +495,22 @@ export const registerGatewayV3Router = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const result = await server.services.resourceAuthMethod.loginWithToken({ token: req.body.token }); + const result = await server.services.resourceAuthMethod.loginWithToken({ + token: req.body.token, + expectedResourceType: "gateway" + }); await server.services.auditLog .createAuditLog({ orgId: result.orgId, - actor: { type: ActorType.GATEWAY, metadata: { gatewayId: result.gatewayId } }, + actor: { type: ActorType.GATEWAY, metadata: { gatewayId: result.resourceId } }, event: { type: EventType.RESOURCE_AUTH_METHOD_LOGIN, metadata: { resourceType: "gateway", - resourceId: result.gatewayId, + resourceId: result.resourceId, method: "token", - methodConfigId: result.gatewayId, + methodConfigId: result.resourceId, enrollmentTokenId: result.enrollmentTokenId } }, @@ -514,7 +520,7 @@ export const registerGatewayV3Router = async (server: FastifyZodProvider) => { }) .catch(() => {}); - return { accessToken: result.accessToken, gatewayId: result.gatewayId }; + return { accessToken: result.accessToken, gatewayId: result.resourceId }; } }); 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 4d9579f8062..b4ac124bc90 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -92,7 +92,8 @@ export type TCreateAuditLogDTO = { | AcmeAccountActor | EstAccountActor | ScepAccountActor - | GatewayActor; + | GatewayActor + | RelayActor; orgId?: string; projectId?: string; } & BaseAuthData; @@ -852,6 +853,10 @@ export enum EventType { RESOURCE_AUTH_METHOD_LOGIN_FAILED = "resource-auth-method-login-failed", RESOURCE_AUTH_METHOD_UPDATE = "resource-auth-method-update", RESOURCE_AUTH_METHOD_REVOKE = "resource-auth-method-revoke", + RELAY_CREATE = "relay-create", + RELAY_UPDATE = "relay-update", + RELAY_DELETE = "relay-delete", + RELAY_ENROLLMENT_TOKEN_CREATE = "relay-enrollment-token-create", // Gateway Pools GATEWAY_POOL_CREATE = "gateway-pool-create", @@ -880,7 +885,8 @@ export const ACTOR_TYPE_TO_METADATA_ID_KEY: Partial> = [ActorType.ACME_ACCOUNT]: "accountId", [ActorType.EST_ACCOUNT]: "profileId", [ActorType.SCEP_ACCOUNT]: "profileId", - [ActorType.GATEWAY]: "gatewayId" + [ActorType.GATEWAY]: "gatewayId", + [ActorType.RELAY]: "relayId" }; export const filterableSecretEvents: EventType[] = [ @@ -948,6 +954,10 @@ interface GatewayActorMetadata { gatewayId: string; } +interface RelayActorMetadata { + relayId: string; +} + export interface UserActor { type: ActorType.USER; metadata: UserActorMetadata; @@ -1007,6 +1017,11 @@ export interface GatewayActor { metadata: GatewayActorMetadata; } +export interface RelayActor { + type: ActorType.RELAY; + metadata: RelayActorMetadata; +} + export type Actor = | UserActor | ServiceActor @@ -1018,7 +1033,8 @@ export type Actor = | AcmeAccountActor | EstAccountActor | ScepAccountActor - | GatewayActor; + | GatewayActor + | RelayActor; interface GetSecretsEvent { type: EventType.GET_SECRETS; @@ -6869,11 +6885,12 @@ interface GatewayEnrollEvent { } type ResourceAuthMethodKind = "aws" | "token"; +type ResourceAuthMethodResourceType = "gateway" | "relay"; interface ResourceAuthMethodLoginEvent { type: EventType.RESOURCE_AUTH_METHOD_LOGIN; metadata: { - resourceType: "gateway"; + resourceType: ResourceAuthMethodResourceType; resourceId: string; method: ResourceAuthMethodKind; methodConfigId: string; @@ -6886,7 +6903,7 @@ interface ResourceAuthMethodLoginEvent { interface ResourceAuthMethodLoginFailedEvent { type: EventType.RESOURCE_AUTH_METHOD_LOGIN_FAILED; metadata: { - resourceType: "gateway"; + resourceType: ResourceAuthMethodResourceType; resourceId: string; method: ResourceAuthMethodKind; reasonCode: string; @@ -6899,7 +6916,7 @@ interface ResourceAuthMethodLoginFailedEvent { interface ResourceAuthMethodUpdateEvent { type: EventType.RESOURCE_AUTH_METHOD_UPDATE; metadata: { - resourceType: "gateway"; + resourceType: ResourceAuthMethodResourceType; resourceId: string; method: ResourceAuthMethodKind; methodConfigId: string; @@ -6912,14 +6929,47 @@ interface ResourceAuthMethodUpdateEvent { interface ResourceAuthMethodRevokeEvent { type: EventType.RESOURCE_AUTH_METHOD_REVOKE; metadata: { - resourceType: "gateway"; + resourceType: ResourceAuthMethodResourceType; resourceId: string; method: ResourceAuthMethodKind; - gatewayName: string; + resourceName: string; deletedTokenCount: number; }; } +interface RelayCreateEvent { + type: EventType.RELAY_CREATE; + metadata: { + relayId: string; + name: string; + }; +} + +interface RelayUpdateEvent { + type: EventType.RELAY_UPDATE; + metadata: { + relayId: string; + name: string; + host: string; + }; +} + +interface RelayDeleteEvent { + type: EventType.RELAY_DELETE; + metadata: { + relayId: string; + name: string; + }; +} + +interface RelayEnrollmentTokenCreateEvent { + type: EventType.RELAY_ENROLLMENT_TOKEN_CREATE; + metadata: { + tokenId: string; + name: string; + }; +} + interface GatewayPoolCreateEvent { type: EventType.GATEWAY_POOL_CREATE; metadata: { @@ -7626,6 +7676,10 @@ export type Event = | ResourceAuthMethodLoginFailedEvent | ResourceAuthMethodUpdateEvent | ResourceAuthMethodRevokeEvent + | RelayCreateEvent + | RelayUpdateEvent + | RelayDeleteEvent + | RelayEnrollmentTokenCreateEvent | GatewayPoolCreateEvent | GatewayPoolUpdateEvent | GatewayPoolDeleteEvent diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts index 6eeb0f8a455..849f48f2e02 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -1259,7 +1259,7 @@ export const gatewayV2ServiceFactory = ({ throw err; } - await resourceAuthMethodService.initAtCreate({ gatewayId: created.id, authMethod }, tx); + await resourceAuthMethodService.initAtCreate({ resource: { type: "gateway", id: created.id }, authMethod }, tx); return created; }); @@ -1309,7 +1309,7 @@ export const gatewayV2ServiceFactory = ({ }; const enrollGateway = async ({ token }: { token: string }) => { - return resourceAuthMethodService.loginWithToken({ token }); + return resourceAuthMethodService.loginWithToken({ token, expectedResourceType: "gateway" }); }; return { diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 9af1a3d82e1..1f84baceb35 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -87,7 +87,8 @@ export enum OrgPermissionRelayActions { CreateRelays = "create-relays", ListRelays = "list-relays", EditRelays = "edit-relays", - DeleteRelays = "delete-relays" + DeleteRelays = "delete-relays", + RevokeRelayAccess = "revoke-relay-access" } export enum OrgPermissionIdentityActions { @@ -518,6 +519,7 @@ const buildAdminPermission = () => { can(OrgPermissionRelayActions.CreateRelays, OrgPermissionSubjects.Relay); can(OrgPermissionRelayActions.EditRelays, OrgPermissionSubjects.Relay); can(OrgPermissionRelayActions.DeleteRelays, OrgPermissionSubjects.Relay); + can(OrgPermissionRelayActions.RevokeRelayAccess, OrgPermissionSubjects.Relay); can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); diff --git a/backend/src/ee/services/relay/relay-service.ts b/backend/src/ee/services/relay/relay-service.ts index 4f0b9c34392..37cccb0ac03 100644 --- a/backend/src/ee/services/relay/relay-service.ts +++ b/backend/src/ee/services/relay/relay-service.ts @@ -6,7 +6,8 @@ import * as x509 from "@peculiar/x509"; import { OrganizationActionScope, OrgMembershipRole, OrgMembershipStatus, TRelays } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { createRelayConnection } from "@app/lib/gateway-v2/gateway-v2"; import { logger } from "@app/lib/logger"; @@ -26,8 +27,10 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { verifyHostInputValidity } from "../dynamic-secret/dynamic-secret-fns"; +import { TGatewayV2DALFactory } from "../gateway-v2/gateway-v2-dal"; import { OrgPermissionRelayActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; +import { TResourceAuthMethodServiceFactory } from "../resource-auth-method/resource-auth-method-service"; import { createSshCert, createSshKeyPair } from "../ssh/ssh-certificate-authority-fns"; import { SshCertType } from "../ssh/ssh-certificate-authority-types"; import { SshCertKeyAlgorithm } from "../ssh-certificate/ssh-certificate-types"; @@ -49,7 +52,9 @@ export const relayServiceFactory = ({ orgDAL, notificationService, smtpService, - userDAL + userDAL, + resourceAuthMethodService, + gatewayV2DAL }: { instanceRelayConfigDAL: TInstanceRelayConfigDALFactory; orgRelayConfigDAL: TOrgRelayConfigDALFactory; @@ -60,6 +65,8 @@ export const relayServiceFactory = ({ notificationService: Pick; smtpService: Pick; userDAL: Pick; + resourceAuthMethodService: Pick; + gatewayV2DAL: Pick; }) => { const $getInstanceCAs = async () => { const instanceConfig = await instanceRelayConfigDAL.transaction(async (tx) => { @@ -1133,7 +1140,8 @@ export const relayServiceFactory = ({ relayHost: relayClientCredentials.relayHost, clientCertificate: relayClientCredentials.clientCertificate, clientPrivateKey: relayClientCredentials.clientPrivateKey, - serverCertificateChain: relayClientCredentials.serverCertificateChain + serverCertificateChain: relayClientCredentials.serverCertificateChain, + timeoutMs: 15000 }); await relayDAL.updateById(relay.id, { heartbeat: new Date() }); @@ -1289,11 +1297,197 @@ export const relayServiceFactory = ({ } }; + const createRelay = async ({ + name, + host, + authMethod, + actor + }: { + name: string; + host: string; + authMethod: + | { method: "aws"; config: { stsEndpoint: string; allowedPrincipalArns: string; allowedAccountIds: string } } + | { method: "token" }; + actor: { + type: ActorType; + id: string; + orgId: string; + authMethod: ActorAuthMethod; + }; + }) => { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionRelayActions.CreateRelays, OrgPermissionSubjects.Relay); + + await verifyHostInputValidity({ host, isDynamicSecret: false }); + + let relay; + try { + relay = await relayDAL.transaction(async (tx) => { + const created = await relayDAL.create({ name, host, orgId: actor.orgId }, tx); + await resourceAuthMethodService.initAtCreate({ resource: { type: "relay", id: created.id }, authMethod }, tx); + return created; + }); + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ message: `A relay named "${name}" already exists` }); + } + throw err; + } + + return relay; + }; + + const connectRelay = async ({ relayId }: { relayId: string }) => { + const relay = await relayDAL.findById(relayId); + if (!relay || !relay.orgId) { + throw new NotFoundError({ message: `Relay ${relayId} not found` }); + } + + const orgCAs = await $getOrgCAs(relay.orgId); + return $generateRelayServerCredentials({ + host: relay.host, + orgId: relay.orgId, + relayPkiServerCaCertificate: orgCAs.relayPkiServerCaCertificate, + relayPkiServerCaPrivateKey: orgCAs.relayPkiServerCaPrivateKey, + relayPkiClientCaCertificate: orgCAs.relayPkiClientCaCertificate, + relayPkiClientCaCertificateChain: orgCAs.relayPkiClientCaCertificateChain, + relaySshServerCaPrivateKey: orgCAs.relaySshServerCaPrivateKey, + relaySshClientCaPublicKey: orgCAs.relaySshClientCaPublicKey + }); + }; + + const heartbeatRelay = async ({ relayId }: { relayId: string }) => { + const relay = await relayDAL.findById(relayId); + if (!relay || !relay.orgId) { + throw new NotFoundError({ message: `Relay ${relayId} not found` }); + } + + const relayClientCredentials = await getCredentialsForClient({ + relayId: relay.id, + orgId: relay.orgId, + orgName: relay.orgId, + gatewayId: "00000000-0000-0000-0000-000000000000", + gatewayName: "heartbeat", + duration: 60 * 1000 + }); + + try { + await createRelayConnection({ + relayHost: relayClientCredentials.relayHost, + clientCertificate: relayClientCredentials.clientCertificate, + clientPrivateKey: relayClientCredentials.clientPrivateKey, + serverCertificateChain: relayClientCredentials.serverCertificateChain, + timeoutMs: 15000 + }); + + await relayDAL.updateById(relay.id, { heartbeat: new Date() }); + } catch (err) { + const error = err as Error; + throw new BadRequestError({ message: `Relay ${relay.name} is not reachable: ${error.message}` }); + } + }; + + const updateRelay = async ({ + relayId, + host, + actor + }: { + relayId: string; + host: string; + actor: { + type: ActorType; + id: string; + orgId: string; + authMethod: ActorAuthMethod; + }; + }) => { + const relay = await relayDAL.findOne({ id: relayId, orgId: actor.orgId }); + if (!relay) { + throw new NotFoundError({ message: `Relay ${relayId} not found` }); + } + + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionRelayActions.EditRelays, OrgPermissionSubjects.Relay); + + await verifyHostInputValidity({ host, isDynamicSecret: false }); + + return relayDAL.updateById(relayId, { host }); + }; + + const getOrgRelay = async ({ relayId, orgId }: { relayId: string; orgId: string }) => { + const relay = await relayDAL.findOne({ id: relayId, orgId }); + if (!relay) { + throw new NotFoundError({ message: `Relay ${relayId} not found` }); + } + return relay; + }; + + const getRelayById = async ({ relayId }: { relayId: string }) => { + const relay = await relayDAL.findById(relayId); + if (!relay) { + throw new NotFoundError({ message: `Relay with ID ${relayId} not found` }); + } + return relay; + }; + + const getConnectedGateways = async ({ + relayId, + orgPermission + }: { + relayId: string; + orgPermission: { type: ActorType; id: string; orgId: string; authMethod: ActorAuthMethod }; + }) => { + const relay = await relayDAL.findOne({ id: relayId, orgId: orgPermission.orgId }); + if (!relay) { + throw new NotFoundError({ message: `Relay ${relayId} not found` }); + } + + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: relay.orgId!, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.Any + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionRelayActions.ListRelays, OrgPermissionSubjects.Relay); + + const gateways = await gatewayV2DAL.find({ relayId }); + return gateways.map((g) => ({ + id: g.id, + name: g.name, + createdAt: g.createdAt, + heartbeat: g.heartbeat + })); + }; + return { registerRelay, getCredentialsForGateway, getCredentialsForClient, getRelays, + getOrgRelay, + getRelayById, + getConnectedGateways, + createRelay, + updateRelay, + connectRelay, + heartbeatRelay, deleteRelay, heartbeat, healthcheckNotify diff --git a/backend/src/ee/services/resource-auth-method/aws-auth-fns.ts b/backend/src/ee/services/resource-auth-method/aws-auth-fns.ts index f7a0865c2bb..7ac22dd6dd5 100644 --- a/backend/src/ee/services/resource-auth-method/aws-auth-fns.ts +++ b/backend/src/ee/services/resource-auth-method/aws-auth-fns.ts @@ -33,7 +33,7 @@ type TVerifyStsCallerInput = { iamRequestBody: string; iamRequestHeaders: string; defaultStsEndpoint: string; - errorContext: { gatewayId: string; orgId: string; gatewayName: string }; + errorContext: Record; }; /** @@ -80,7 +80,10 @@ export const verifyStsAndExtractCaller = async ({ data: body }); } catch (err) { - logger.error(err, `Resource AWS Auth Login: STS verification failed [gateway-id=${errorContext.gatewayId}]`); + logger.error( + err, + `Resource AWS Auth Login: STS verification failed [resourceId=${String(errorContext.resourceId)}]` + ); throw new UnauthorizedError({ message: "STS verification failed", detail: { reasonCode: "sts_request_failed", ...errorContext } @@ -101,7 +104,7 @@ type TValidateAllowlistsInput = { Arn: string; allowedAccountIds: string; allowedPrincipalArns: string; - errorContext: { gatewayId: string; orgId: string; gatewayName: string }; + errorContext: Record; }; export const validateAllowlists = ({ @@ -151,7 +154,7 @@ export const validateAllowlists = ({ if (!isArnAllowed) { logger.error( - `Resource AWS Auth Login: AWS principal ARN not allowed [principal-arn=${formattedArn}] [raw-arn=${Arn}] [gateway-id=${errorContext.gatewayId}]` + `Resource AWS Auth Login: AWS principal ARN not allowed [principal-arn=${formattedArn}] [raw-arn=${Arn}] [resourceId=${String(errorContext.resourceId)}]` ); throw new UnauthorizedError({ message: `Access denied: AWS principal ARN not allowed. [principal-arn=${formattedArn}]`, diff --git a/backend/src/ee/services/resource-auth-method/resource-auth-method-fns.ts b/backend/src/ee/services/resource-auth-method/resource-auth-method-fns.ts index ade02f63fae..a235c0403ac 100644 --- a/backend/src/ee/services/resource-auth-method/resource-auth-method-fns.ts +++ b/backend/src/ee/services/resource-auth-method/resource-auth-method-fns.ts @@ -32,16 +32,35 @@ export const mintGatewayJwt = ({ ); }; -// ResourceRef.type is "gateway" only today; the abstraction is in place so relay can be -// added additively in a follow-up PR without renaming routes, audit events -// ("RESOURCE_AUTH_METHOD_*"), or DB tables (resource_auth_methods, resource_aws_auths). -export type ResourceRef = { type: "gateway"; id: string }; +export const mintRelayJwt = ({ + relayId, + orgId, + tokenVersion, + accessTokenTTL +}: { + relayId: string; + orgId: string; + tokenVersion: number; + accessTokenTTL: number; +}) => { + const appCfg = getConfig(); + return crypto.jwt().sign( + { + relayId, + orgId, + authTokenType: AuthTokenType.RELAY_ACCESS_TOKEN, + tokenVersion + }, + appCfg.AUTH_SECRET, + accessTokenTTL === 0 ? undefined : { expiresIn: accessTokenTTL } + ); +}; + +export type ResourceRef = { type: "gateway"; id: string } | { type: "relay"; id: string }; export const RESOURCE_TYPE_GATEWAY = "gateway" as const; +export const RESOURCE_TYPE_RELAY = "relay" as const; -// Accepts a wider input than ResourceRef so the runtime guard remains a meaningful check -// (today TS narrows ResourceRef.type to "gateway" at compile time, but this function exists -// so the runtime layer is ready when more resource types are added). export const assertGatewayResource = (resource: { type: string }, methodName: string) => { if (resource.type !== RESOURCE_TYPE_GATEWAY) { throw new BadRequestError({ @@ -50,6 +69,14 @@ export const assertGatewayResource = (resource: { type: string }, methodName: st } }; +export const assertRelayResource = (resource: { type: string }, methodName: string) => { + if (resource.type !== RESOURCE_TYPE_RELAY) { + throw new BadRequestError({ + message: `Resource type "${resource.type}" not supported for ${methodName} auth` + }); + } +}; + // All auth method values surfaced anywhere in the system. // // - 'aws' / 'token' — stored in resource_auth_methods.method, settable via the API. diff --git a/backend/src/ee/services/resource-auth-method/resource-auth-method-service.ts b/backend/src/ee/services/resource-auth-method/resource-auth-method-service.ts index 6c6dbd9900a..161be990de6 100644 --- a/backend/src/ee/services/resource-auth-method/resource-auth-method-service.ts +++ b/backend/src/ee/services/resource-auth-method/resource-auth-method-service.ts @@ -7,12 +7,26 @@ import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/erro import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TGatewayV2DALFactory } from "../gateway-v2/gateway-v2-dal"; -import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { + OrgPermissionGatewayActions, + OrgPermissionRelayActions, + OrgPermissionSubjects +} from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; +import { TRelayDALFactory } from "../relay/relay-dal"; import { TResourceAwsAuthDALFactory } from "./aws-auth-dal"; import { validateAllowlists, verifyStsAndExtractCaller } from "./aws-auth-fns"; import { TResourceAuthMethodDALFactory } from "./resource-auth-method-dal"; -import { assertGatewayResource, mintGatewayJwt, ResourceAuthMethodType } from "./resource-auth-method-fns"; +import { + assertGatewayResource, + assertRelayResource, + mintGatewayJwt, + mintRelayJwt, + RESOURCE_TYPE_GATEWAY, + RESOURCE_TYPE_RELAY, + ResourceAuthMethodType, + type ResourceRef +} from "./resource-auth-method-fns"; import { TAuthMethodView, TAwsAuthMethodConfig, @@ -39,21 +53,40 @@ type TResourceAuthMethodServiceFactoryDep = { resourceAwsAuthDAL: TResourceAwsAuthDALFactory; resourceTokenAuthDAL: TResourceTokenAuthDALFactory; gatewayV2DAL: Pick; + relayDAL: Pick; identityDAL: Pick; permissionService: Pick; }; export type TResourceAuthMethodServiceFactory = ReturnType; +// Maps resource-type-agnostic permission intents to per-resource CASL actions. +const GATEWAY_PERMISSION_MAP = { + list: OrgPermissionGatewayActions.ListGateways, + edit: OrgPermissionGatewayActions.EditGateways, + revoke: OrgPermissionGatewayActions.RevokeGatewayAccess +} as const; + +const RELAY_PERMISSION_MAP = { + list: OrgPermissionRelayActions.ListRelays, + edit: OrgPermissionRelayActions.EditRelays, + revoke: OrgPermissionRelayActions.RevokeRelayAccess +} as const; + export const resourceAuthMethodServiceFactory = ({ resourceAuthMethodDAL, resourceAwsAuthDAL, resourceTokenAuthDAL, gatewayV2DAL, + relayDAL, identityDAL, permissionService }: TResourceAuthMethodServiceFactoryDep) => { - const $checkPermission = async (actor: TSetAuthMethodDTO["actor"], action: OrgPermissionGatewayActions) => { + const $checkPermission = async ( + actor: TSetAuthMethodDTO["actor"], + intent: "list" | "edit" | "revoke", + resourceType: ResourceRef["type"] + ) => { const { permission } = await permissionService.getOrgPermission({ scope: OrganizationActionScope.Any, actor: actor.type, @@ -62,33 +95,55 @@ export const resourceAuthMethodServiceFactory = ({ actorAuthMethod: actor.authMethod, actorOrgId: actor.orgId }); - ForbiddenError.from(permission).throwUnlessCan(action, OrgPermissionSubjects.Gateway); + if (resourceType === RESOURCE_TYPE_GATEWAY) { + ForbiddenError.from(permission).throwUnlessCan(GATEWAY_PERMISSION_MAP[intent], OrgPermissionSubjects.Gateway); + } else { + ForbiddenError.from(permission).throwUnlessCan(RELAY_PERMISSION_MAP[intent], OrgPermissionSubjects.Relay); + } }; - // Identity is checked first via gateways_v2.identityId — it's the authoritative - // legacy-state signal and overrides any registry row. - const $loadAuthMethodView = async (gatewayId: string): Promise => { - const gateway = await gatewayV2DAL.findById(gatewayId); - if (!gateway) return null; - - if (gateway.identityId) { - const identity = await identityDAL.findById(gateway.identityId); - return { - method: ResourceAuthMethodType.Identity, - config: { - identityId: gateway.identityId, - identityName: identity?.name ?? null - } - }; + const $loadAuthMethodView = async (resource: ResourceRef): Promise => { + // Identity is checked first — it's the authoritative legacy-state signal + // and overrides any registry row. + if (resource.type === RESOURCE_TYPE_GATEWAY) { + const gateway = await gatewayV2DAL.findById(resource.id); + if (!gateway) return null; + + if (gateway.identityId) { + const identity = await identityDAL.findById(gateway.identityId); + return { + method: ResourceAuthMethodType.Identity, + config: { + identityId: gateway.identityId, + identityName: identity?.name ?? null + } + }; + } + } else { + const relay = await relayDAL.findById(resource.id); + if (!relay) return null; + + if (relay.identityId) { + const identity = await identityDAL.findById(relay.identityId); + return { + method: ResourceAuthMethodType.Identity, + config: { + identityId: relay.identityId, + identityName: identity?.name ?? null + } + }; + } } - const registry = await resourceAuthMethodDAL.findOne({ gatewayId }); + const registryFilter = + resource.type === RESOURCE_TYPE_GATEWAY ? { gatewayId: resource.id } : { relayId: resource.id }; + const registry = await resourceAuthMethodDAL.findOne(registryFilter); if (!registry) return null; if (registry.method === ResourceAuthMethodType.Aws) { const config = await resourceAwsAuthDAL.findOne({ authMethodId: registry.id }); if (!config) { - throw new NotFoundError({ message: "AWS auth config missing for gateway" }); + throw new NotFoundError({ message: `AWS auth config missing for ${resource.type}` }); } return { method: ResourceAuthMethodType.Aws, @@ -115,28 +170,49 @@ export const resourceAuthMethodServiceFactory = ({ const getByGatewayId = async ({ resource, actor }: TGetAuthMethodDTO) => { assertGatewayResource(resource, "auth-method"); - await $checkPermission(actor, OrgPermissionGatewayActions.ListGateways); + await $checkPermission(actor, "list", RESOURCE_TYPE_GATEWAY); const gateway = await gatewayV2DAL.findById(resource.id); if (!gateway || gateway.orgId !== actor.orgId) { throw new NotFoundError({ message: `Gateway ${resource.id} not found` }); } - const view = await $loadAuthMethodView(resource.id); + const view = await $loadAuthMethodView(resource); if (!view) { throw new NotFoundError({ message: "Gateway has no auth method configured" }); } return view; }; - const loadView = async (gatewayId: string): Promise => $loadAuthMethodView(gatewayId); + const getByRelayId = async ({ resource, actor }: TGetAuthMethodDTO) => { + assertRelayResource(resource, "auth-method"); + await $checkPermission(actor, "list", RESOURCE_TYPE_RELAY); + + const relay = await relayDAL.findById(resource.id); + if (!relay || relay.orgId !== actor.orgId) { + throw new NotFoundError({ message: `Relay ${resource.id} not found` }); + } + + const view = await $loadAuthMethodView(resource); + if (!view) { + throw new NotFoundError({ message: "Relay has no auth method configured" }); + } + return view; + }; + + const loadView = async (resource: ResourceRef): Promise => $loadAuthMethodView(resource); // Revoke is meaningful when there's something to invalidate: an active JWT // (tokenVersion > 0) or a pending unused enrollment-token row. - const canRevoke = async (gateway: { id: string; tokenVersion: number; identityId?: string | null }) => { - if (gateway.identityId) return false; - if (gateway.tokenVersion > 0) return true; - const registry = await resourceAuthMethodDAL.findOne({ gatewayId: gateway.id }); + const canRevoke = async ( + resourceInfo: { id: string; tokenVersion: number; identityId?: string | null }, + resourceType: ResourceRef["type"] = RESOURCE_TYPE_GATEWAY + ) => { + if (resourceInfo.identityId) return false; + if (resourceInfo.tokenVersion > 0) return true; + const registryFilter = + resourceType === RESOURCE_TYPE_GATEWAY ? { gatewayId: resourceInfo.id } : { relayId: resourceInfo.id }; + const registry = await resourceAuthMethodDAL.findOne(registryFilter); if (!registry || registry.method !== ResourceAuthMethodType.Token) return false; const pending = await resourceTokenAuthDAL.findOne({ authMethodId: registry.id }); return Boolean(pending); @@ -144,17 +220,22 @@ export const resourceAuthMethodServiceFactory = ({ const initAtCreate = async ( { - gatewayId, + resource, authMethod }: { - gatewayId: string; + resource: ResourceRef; authMethod: | { method: typeof ResourceAuthMethodType.Aws; config: TAwsAuthMethodConfig } | { method: typeof ResourceAuthMethodType.Token }; }, tx: Knex ) => { - const registry = await resourceAuthMethodDAL.create({ gatewayId, method: authMethod.method }, tx); + const registryRow = + resource.type === RESOURCE_TYPE_GATEWAY + ? { gatewayId: resource.id, method: authMethod.method } + : { relayId: resource.id, method: authMethod.method }; + + const registry = await resourceAuthMethodDAL.create(registryRow, tx); if (authMethod.method === ResourceAuthMethodType.Aws) { await resourceAwsAuthDAL.create( { @@ -168,25 +249,37 @@ export const resourceAuthMethodServiceFactory = ({ } }; - // tokenVersion is intentionally NOT bumped on method change — running gateways keep + // tokenVersion is intentionally NOT bumped on method change — running resources keep // their JWT until the next restart, avoiding forced downtime. Use revoke for that. const setMethod = async ({ resource, authMethod, actor }: TSetAuthMethodDTO): Promise => { - assertGatewayResource(resource, "auth-method"); - await $checkPermission(actor, OrgPermissionGatewayActions.EditGateways); + await $checkPermission(actor, "edit", resource.type); - const gateway = await gatewayV2DAL.findById(resource.id); - if (!gateway || gateway.orgId !== actor.orgId) { - throw new NotFoundError({ message: `Gateway ${resource.id} not found` }); + const resourceLabel = resource.type === RESOURCE_TYPE_GATEWAY ? "Gateway" : "Relay"; + let identityId: string | null | undefined; + + if (resource.type === RESOURCE_TYPE_GATEWAY) { + const gateway = await gatewayV2DAL.findById(resource.id); + if (!gateway || gateway.orgId !== actor.orgId) { + throw new NotFoundError({ message: `${resourceLabel} ${resource.id} not found` }); + } + identityId = gateway.identityId; + } else { + const relay = await relayDAL.findById(resource.id); + if (!relay || relay.orgId !== actor.orgId) { + throw new NotFoundError({ message: `${resourceLabel} ${resource.id} not found` }); + } + identityId = relay.identityId; } - if (gateway.identityId) { + if (identityId) { throw new BadRequestError({ - message: - "This gateway is using legacy machine identity auth. Create a new gateway with the desired auth method instead of migrating this one." + message: `This ${resourceLabel.toLowerCase()} is using legacy machine identity auth. Create a new ${resourceLabel.toLowerCase()} with the desired auth method instead of migrating this one.` }); } - const current = await resourceAuthMethodDAL.findOne({ gatewayId: resource.id }); + const registryFilter = + resource.type === RESOURCE_TYPE_GATEWAY ? { gatewayId: resource.id } : { relayId: resource.id }; + const current = await resourceAuthMethodDAL.findOne(registryFilter); const previousMethod = current?.method ?? null; await resourceAuthMethodDAL.transaction(async (tx) => { @@ -195,7 +288,11 @@ export const resourceAuthMethodServiceFactory = ({ if (current) { registryRow = await resourceAuthMethodDAL.updateById(current.id, { method: authMethod.method }, tx); } else { - registryRow = await resourceAuthMethodDAL.create({ gatewayId: resource.id, method: authMethod.method }, tx); + const createPayload = + resource.type === RESOURCE_TYPE_GATEWAY + ? { gatewayId: resource.id, method: authMethod.method } + : { relayId: resource.id, method: authMethod.method }; + registryRow = await resourceAuthMethodDAL.create(createPayload, tx); } // 2. Drop the previous method's config artifacts. @@ -241,7 +338,7 @@ export const resourceAuthMethodServiceFactory = ({ } }); - const view = await $loadAuthMethodView(resource.id); + const view = await $loadAuthMethodView(resource); if (!view) { throw new NotFoundError({ message: "Auth method not found after set" }); } @@ -249,20 +346,36 @@ export const resourceAuthMethodServiceFactory = ({ }; // Non-destructive: minting a new token does NOT bump tokenVersion or clear heartbeat, - // so a running gateway keeps working. The next login (with the new token) does the bump. + // so a running resource keeps working. The next login (with the new token) does the bump. const mintToken = async ({ resource, actor }: TMintTokenDTO) => { - assertGatewayResource(resource, "token"); - await $checkPermission(actor, OrgPermissionGatewayActions.EditGateways); + await $checkPermission(actor, "edit", resource.type); - const gateway = await gatewayV2DAL.findById(resource.id); - if (!gateway || gateway.orgId !== actor.orgId) { - throw new NotFoundError({ message: `Gateway ${resource.id} not found` }); + const resourceLabel = resource.type === RESOURCE_TYPE_GATEWAY ? "Gateway" : "Relay"; + let resourceName: string; + let resourceOrgId: string; + + if (resource.type === RESOURCE_TYPE_GATEWAY) { + const gateway = await gatewayV2DAL.findById(resource.id); + if (!gateway || gateway.orgId !== actor.orgId) { + throw new NotFoundError({ message: `${resourceLabel} ${resource.id} not found` }); + } + resourceName = gateway.name; + resourceOrgId = gateway.orgId; + } else { + const relay = await relayDAL.findById(resource.id); + if (!relay || relay.orgId !== actor.orgId) { + throw new NotFoundError({ message: `${resourceLabel} ${resource.id} not found` }); + } + resourceName = relay.name; + resourceOrgId = relay.orgId!; } - const registry = await resourceAuthMethodDAL.findOne({ gatewayId: resource.id }); + const registryFilter = + resource.type === RESOURCE_TYPE_GATEWAY ? { gatewayId: resource.id } : { relayId: resource.id }; + const registry = await resourceAuthMethodDAL.findOne(registryFilter); if (!registry || registry.method !== ResourceAuthMethodType.Token) { throw new BadRequestError({ - message: `Gateway is not configured for token authentication (current method: ${registry?.method ?? "none"})` + message: `${resourceLabel} is not configured for token authentication (current method: ${registry?.method ?? "none"})` }); } @@ -285,28 +398,46 @@ export const resourceAuthMethodServiceFactory = ({ return { ...record, token: generated.plainToken, - gatewayName: gateway.name, - orgId: gateway.orgId + resourceName, + orgId: resourceOrgId }; }; const revokeAccess = async ({ resource, actor }: TRevokeTokenDTO) => { - assertGatewayResource(resource, "auth-method"); - await $checkPermission(actor, OrgPermissionGatewayActions.RevokeGatewayAccess); + await $checkPermission(actor, "revoke", resource.type); - const gateway = await gatewayV2DAL.findById(resource.id); - if (!gateway || gateway.orgId !== actor.orgId) { - throw new NotFoundError({ message: `Gateway ${resource.id} not found` }); + const resourceLabel = resource.type === RESOURCE_TYPE_GATEWAY ? "Gateway" : "Relay"; + let resourceName: string; + let resourceOrgId: string; + let identityId: string | null | undefined; + + if (resource.type === RESOURCE_TYPE_GATEWAY) { + const gateway = await gatewayV2DAL.findById(resource.id); + if (!gateway || gateway.orgId !== actor.orgId) { + throw new NotFoundError({ message: `${resourceLabel} ${resource.id} not found` }); + } + resourceName = gateway.name; + resourceOrgId = gateway.orgId; + identityId = gateway.identityId; + } else { + const relay = await relayDAL.findById(resource.id); + if (!relay || relay.orgId !== actor.orgId) { + throw new NotFoundError({ message: `${resourceLabel} ${resource.id} not found` }); + } + resourceName = relay.name; + resourceOrgId = relay.orgId!; + identityId = relay.identityId; } - const registry = await resourceAuthMethodDAL.findOne({ gatewayId: resource.id }); + const registryFilter = + resource.type === RESOURCE_TYPE_GATEWAY ? { gatewayId: resource.id } : { relayId: resource.id }; + const registry = await resourceAuthMethodDAL.findOne(registryFilter); if (!registry) { - throw new NotFoundError({ message: "Gateway has no auth method configured" }); + throw new NotFoundError({ message: `${resourceLabel} has no auth method configured` }); } - if (gateway.identityId) { + if (identityId) { throw new BadRequestError({ - message: - "Identity-bound gateways cannot be revoked directly. Create a new gateway with AWS or Token auth instead." + message: `Identity-bound ${resourceLabel.toLowerCase()}s cannot be revoked directly. Create a new ${resourceLabel.toLowerCase()} with AWS or Token auth instead.` }); } @@ -319,17 +450,21 @@ export const resourceAuthMethodServiceFactory = ({ await resourceTokenAuthDAL.delete({ authMethodId: registry.id }, tx); } } - await gatewayV2DAL.updateById( - gateway.id, - { $incr: { tokenVersion: 1 }, heartbeat: null, heartbeatTTL: null }, - tx - ); + if (resource.type === RESOURCE_TYPE_GATEWAY) { + await gatewayV2DAL.updateById( + resource.id, + { $incr: { tokenVersion: 1 }, heartbeat: null, heartbeatTTL: null }, + tx + ); + } else { + await relayDAL.updateById(resource.id, { $incr: { tokenVersion: 1 }, heartbeat: null }, tx); + } return { deletedTokenCount }; }); return { - gatewayName: gateway.name, - orgId: gateway.orgId, + resourceName, + orgId: resourceOrgId, method: registry.method as "aws" | "token", deletedTokenCount: result.deletedTokenCount }; @@ -341,30 +476,45 @@ export const resourceAuthMethodServiceFactory = ({ iamRequestBody, iamRequestHeaders }: TLoginWithAwsDTO) => { - assertGatewayResource(resource, "AWS"); - - const gateway = await gatewayV2DAL.findById(resource.id); - if (!gateway) { - throw new UnauthorizedError({ message: "Invalid gateway credentials" }); + const resourceLabel = resource.type === RESOURCE_TYPE_GATEWAY ? "Gateway" : "Relay"; + let resourceName: string; + let resourceOrgId: string; + + if (resource.type === RESOURCE_TYPE_GATEWAY) { + const gateway = await gatewayV2DAL.findById(resource.id); + if (!gateway) { + throw new UnauthorizedError({ message: `Invalid ${resourceLabel.toLowerCase()} credentials` }); + } + resourceName = gateway.name; + resourceOrgId = gateway.orgId; + } else { + const relay = await relayDAL.findById(resource.id); + if (!relay || !relay.orgId) { + throw new UnauthorizedError({ message: `Invalid ${resourceLabel.toLowerCase()} credentials` }); + } + resourceName = relay.name; + resourceOrgId = relay.orgId; } - const registry = await resourceAuthMethodDAL.findOne({ gatewayId: gateway.id }); + const registryFilter = + resource.type === RESOURCE_TYPE_GATEWAY ? { gatewayId: resource.id } : { relayId: resource.id }; + const registry = await resourceAuthMethodDAL.findOne(registryFilter); if (!registry || registry.method !== ResourceAuthMethodType.Aws) { throw new UnauthorizedError({ - message: "Gateway is not configured for AWS authentication", - detail: { reasonCode: "method_mismatch", gatewayId: gateway.id, orgId: gateway.orgId } + message: `${resourceLabel} is not configured for AWS authentication`, + detail: { reasonCode: "method_mismatch", resourceId: resource.id, orgId: resourceOrgId } }); } const config = await resourceAwsAuthDAL.findOne({ authMethodId: registry.id }); if (!config) { throw new UnauthorizedError({ - message: "Gateway is not configured for AWS authentication", - detail: { reasonCode: "config_missing", gatewayId: gateway.id, orgId: gateway.orgId } + message: `${resourceLabel} is not configured for AWS authentication`, + detail: { reasonCode: "config_missing", resourceId: resource.id, orgId: resourceOrgId } }); } - const errorContext = { gatewayId: gateway.id, orgId: gateway.orgId, gatewayName: gateway.name }; + const errorContext = { resourceId: resource.id, orgId: resourceOrgId, resourceName }; const { Account, Arn } = await verifyStsAndExtractCaller({ iamHttpRequestMethod, @@ -382,22 +532,42 @@ export const resourceAuthMethodServiceFactory = ({ errorContext }); - const refreshed = await gatewayV2DAL.updateById(gateway.id, { - $incr: { tokenVersion: 1 }, - heartbeat: null, - heartbeatTTL: null - }); + let refreshedTokenVersion: number; + if (resource.type === RESOURCE_TYPE_GATEWAY) { + const refreshed = await gatewayV2DAL.updateById(resource.id, { + $incr: { tokenVersion: 1 }, + heartbeat: null, + heartbeatTTL: null + }); + refreshedTokenVersion = refreshed.tokenVersion; + } else { + const refreshed = await relayDAL.updateById(resource.id, { + $incr: { tokenVersion: 1 }, + heartbeat: null + }); + refreshedTokenVersion = refreshed.tokenVersion; + } - const accessToken = mintGatewayJwt({ - gatewayId: gateway.id, - orgId: gateway.orgId, - tokenVersion: refreshed.tokenVersion, - accessTokenTTL: 0 - }); + const accessToken = + resource.type === RESOURCE_TYPE_GATEWAY + ? mintGatewayJwt({ + gatewayId: resource.id, + orgId: resourceOrgId, + tokenVersion: refreshedTokenVersion, + accessTokenTTL: 0 + }) + : mintRelayJwt({ + relayId: resource.id, + orgId: resourceOrgId, + tokenVersion: refreshedTokenVersion, + accessTokenTTL: 0 + }); return { accessToken, - gateway: refreshed, + resourceId: resource.id, + resourceName, + orgId: resourceOrgId, config, principalArn: Arn, accountId: Account @@ -405,7 +575,7 @@ export const resourceAuthMethodServiceFactory = ({ }; // Single-use: row is deleted on consume, not flagged. - const loginWithToken = async ({ token }: TLoginWithTokenDTO) => { + const loginWithToken = async ({ token, expectedResourceType }: TLoginWithTokenDTO) => { const tokenHash = crypto.nativeCrypto.createHash("sha256").update(token).digest("hex"); const tokenRecord = await resourceTokenAuthDAL.findOne({ tokenHash }); @@ -418,44 +588,88 @@ export const resourceAuthMethodServiceFactory = ({ } const registry = await resourceAuthMethodDAL.findById(tokenRecord.authMethodId); - if (!registry || !registry.gatewayId) { - throw new BadRequestError({ message: "Enrollment token is not linked to a gateway" }); + if (!registry) { + throw new BadRequestError({ message: "Enrollment token is not linked to a resource" }); + } + + // Determine resource type from which FK is set on the registry row. + const isGateway = Boolean(registry.gatewayId); + const isRelay = Boolean(registry.relayId); + if (!isGateway && !isRelay) { + throw new BadRequestError({ message: "Enrollment token is not linked to a resource" }); + } + + const actualResourceType = isGateway ? RESOURCE_TYPE_GATEWAY : RESOURCE_TYPE_RELAY; + if (actualResourceType !== expectedResourceType) { + throw new BadRequestError({ + message: `Enrollment token belongs to a ${actualResourceType}, not a ${expectedResourceType}` + }); } - const linkedGatewayId = registry.gatewayId; - const gateway = await resourceTokenAuthDAL.transaction(async (tx) => { - // Reject concurrent consumption: if delete returns 0, another caller won the race. + const linkedResourceId = (isGateway ? registry.gatewayId : registry.relayId)!; + + if (isGateway) { + const gateway = await resourceTokenAuthDAL.transaction(async (tx) => { + const deleted = await resourceTokenAuthDAL.delete({ id: tokenRecord.id }, tx); + if (deleted.length === 0) { + throw new BadRequestError({ message: "Enrollment token has already been used" }); + } + const existing = await gatewayV2DAL.findById(linkedResourceId, tx); + if (!existing) throw new NotFoundError({ message: `Gateway ${linkedResourceId} not found` }); + return gatewayV2DAL.updateById( + existing.id, + { $incr: { tokenVersion: 1 }, heartbeat: null, heartbeatTTL: null }, + tx + ); + }); + + const accessToken = mintGatewayJwt({ + gatewayId: gateway.id, + orgId: gateway.orgId, + tokenVersion: gateway.tokenVersion, + accessTokenTTL: 0 + }); + + return { + accessToken, + resourceType: "gateway" as const, + resourceId: gateway.id, + resourceName: gateway.name, + orgId: gateway.orgId, + enrollmentTokenId: tokenRecord.id + }; + } + + const relay = await resourceTokenAuthDAL.transaction(async (tx) => { const deleted = await resourceTokenAuthDAL.delete({ id: tokenRecord.id }, tx); if (deleted.length === 0) { throw new BadRequestError({ message: "Enrollment token has already been used" }); } - const existing = await gatewayV2DAL.findById(linkedGatewayId, tx); - if (!existing) throw new NotFoundError({ message: `Gateway ${linkedGatewayId} not found` }); - return gatewayV2DAL.updateById( - existing.id, - { $incr: { tokenVersion: 1 }, heartbeat: null, heartbeatTTL: null }, - tx - ); + const existing = await relayDAL.findById(linkedResourceId, tx); + if (!existing) throw new NotFoundError({ message: `Relay ${linkedResourceId} not found` }); + return relayDAL.updateById(existing.id, { $incr: { tokenVersion: 1 }, heartbeat: null }, tx); }); - const accessToken = mintGatewayJwt({ - gatewayId: gateway.id, - orgId: gateway.orgId, - tokenVersion: gateway.tokenVersion, + const accessToken = mintRelayJwt({ + relayId: relay.id, + orgId: relay.orgId!, + tokenVersion: relay.tokenVersion, accessTokenTTL: 0 }); return { accessToken, - gatewayId: gateway.id, - gatewayName: gateway.name, - orgId: gateway.orgId, + resourceType: "relay" as const, + resourceId: relay.id, + resourceName: relay.name, + orgId: relay.orgId!, enrollmentTokenId: tokenRecord.id }; }; return { getByGatewayId, + getByRelayId, loadView, canRevoke, initAtCreate, diff --git a/backend/src/ee/services/resource-auth-method/resource-auth-method-types.ts b/backend/src/ee/services/resource-auth-method/resource-auth-method-types.ts index a6455308e03..a59170229c1 100644 --- a/backend/src/ee/services/resource-auth-method/resource-auth-method-types.ts +++ b/backend/src/ee/services/resource-auth-method/resource-auth-method-types.ts @@ -35,6 +35,7 @@ export type TLoginWithAwsDTO = { export type TLoginWithTokenDTO = { token: string; + expectedResourceType: "gateway" | "relay"; }; export type TAuthMethodView = diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 00b39b6a1ee..c841665b2d3 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -3676,3 +3676,23 @@ export const GATEWAYS = { token: "The one-time enrollment token previously issued for this gateway (token method only)." } } as const; + +export const RELAYS = { + CREATE: { + name: "Name of the relay.", + host: "Host address where the relay is reachable.", + authMethod: + "Auth method to configure on the relay. `aws` carries the AWS allowlists; `token` is configurationless and requires a separate POST /v2/relays/:id/token-auth/generate-enrollment-token call to mint the bootstrap token." + }, + UPDATE: { + authMethod: + "Replacement auth method. Same shape as in create — `aws` with allowlists or `token` with no config. Existing relays keep working until they restart and re-authenticate via the new method." + }, + LOGIN: { + relayId: "The ID of the relay logging in (AWS method only).", + iamHttpRequestMethod: "The HTTP request method used in the signed STS request.", + iamRequestBody: "The base64-encoded body of the signed STS request.", + iamRequestHeaders: "The base64-encoded headers of the sts:GetCallerIdentity signed request.", + token: "The one-time enrollment token previously issued for this relay (token method only)." + } +} as const; diff --git a/backend/src/lib/gateway-v2/gateway-v2.ts b/backend/src/lib/gateway-v2/gateway-v2.ts index b602a361fa0..1c686f796e6 100644 --- a/backend/src/lib/gateway-v2/gateway-v2.ts +++ b/backend/src/lib/gateway-v2/gateway-v2.ts @@ -20,16 +20,20 @@ interface IGatewayRelayServer { getRelayError: () => string; } +const DEFAULT_RELAY_CONNECTION_TIMEOUT_MS = 100000; + export const createRelayConnection = async ({ relayHost, clientCertificate, clientPrivateKey, - serverCertificateChain + serverCertificateChain, + timeoutMs = DEFAULT_RELAY_CONNECTION_TIMEOUT_MS }: { relayHost: string; clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string; + timeoutMs?: number; }): Promise => { const [targetHost] = await verifyHostInputValidity({ host: relayHost, isDynamicSecret: false }); const [, portStr] = relayHost.split(":"); @@ -65,12 +69,12 @@ export const createRelayConnection = async ({ }); socket.on("timeout", () => { - logger.error(`TLS connection timeout after 120 seconds`); + logger.error(`TLS connection timeout after ${timeoutMs / 1000}s`); socket.destroy(); reject(new Error("TLS connection timeout")); }); - socket.setTimeout(100000); + socket.setTimeout(timeoutMs); } catch (error: unknown) { reject(new Error(`Failed to create TLS connection: ${error instanceof Error ? error.message : String(error)}`)); } diff --git a/backend/src/server/plugins/audit-log.ts b/backend/src/server/plugins/audit-log.ts index 725f8b5ca5f..702292208d0 100644 --- a/backend/src/server/plugins/audit-log.ts +++ b/backend/src/server/plugins/audit-log.ts @@ -95,6 +95,13 @@ export const injectAuditLogInfo = fp(async (server: FastifyZodProvider) => { gatewayId: req.permission.id } }; + } else if (req.auth.actor === ActorType.RELAY) { + payload.actor = { + type: ActorType.RELAY, + metadata: { + relayId: req.permission.id + } + }; } else { throw new BadRequestError({ message: "Invalid actor type provided" }); } diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 8f11dfc570c..1de9babecab 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -16,7 +16,8 @@ import { AuthModeJwtTokenPayload, AuthTokenType, MfaMethod, - TGatewayAccessTokenJwtPayload + TGatewayAccessTokenJwtPayload, + TRelayAccessTokenJwtPayload } from "@app/services/auth/auth-type"; import { TIdentityAccessTokenJwtPayload } from "@app/services/identity-access-token/identity-access-token-types"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -88,6 +89,16 @@ export type TAuthMode = parentOrgId: string; authMethod: null; token: TGatewayAccessTokenJwtPayload; + } + | { + authMode: AuthMode.RELAY_ACCESS_TOKEN; + actor: ActorType.RELAY; + relayId: string; + orgId: string; + rootOrgId: string; + parentOrgId: string; + authMethod: null; + token: TRelayAccessTokenJwtPayload; }; export const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { @@ -146,6 +157,12 @@ export const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { token: decodedToken as TGatewayAccessTokenJwtPayload, actor: ActorType.GATEWAY } as const; + case AuthTokenType.RELAY_ACCESS_TOKEN: + return { + authMode: AuthMode.RELAY_ACCESS_TOKEN, + token: decodedToken as TRelayAccessTokenJwtPayload, + actor: ActorType.RELAY + } as const; default: return { authMode: null, token: null } as const; } @@ -205,6 +222,11 @@ export const injectIdentity = fp( return; } + // Authentication is handled on a route-level (enrollment token / AWS creds in body) + if (pathname === "/api/v2/relays/login") { + return; + } + // Authentication is handled on a route-level if (pathname === "/api/v1/relays/heartbeat-instance-relay") { return; @@ -374,6 +396,31 @@ export const injectIdentity = fp( }; break; } + case AuthMode.RELAY_ACCESS_TOKEN: { + const relay = await server.services.relay.getRelayById({ relayId: token.relayId }); + + if (relay.tokenVersion !== token.tokenVersion) { + throw new UnauthorizedError({ message: "Relay token has been revoked" }); + } + + if (relay.orgId !== token.orgId) { + throw new UnauthorizedError({ message: "Relay token org mismatch" }); + } + + requestContext.set(RequestContextKey.OrgId, token.orgId); + + req.auth = { + authMode: AuthMode.RELAY_ACCESS_TOKEN, + actor, + relayId: token.relayId, + orgId: token.orgId, + rootOrgId: token.orgId, + parentOrgId: token.orgId, + authMethod: null, + token + }; + break; + } default: throw new BadRequestError({ message: "Invalid token strategy provided" }); } diff --git a/backend/src/server/plugins/auth/inject-permission.ts b/backend/src/server/plugins/auth/inject-permission.ts index b19f7cd97ab..e7e6de774d0 100644 --- a/backend/src/server/plugins/auth/inject-permission.ts +++ b/backend/src/server/plugins/auth/inject-permission.ts @@ -74,6 +74,19 @@ export const injectPermission = fp(async (server) => { logger.info( `injectPermission: Injecting permissions for [permissionsForGateway=${req.auth.gatewayId}] [type=${ActorType.GATEWAY}]` ); + } else if (req.auth.actor === ActorType.RELAY) { + req.permission = { + type: ActorType.RELAY, + id: req.auth.relayId, + orgId: req.auth.orgId, + rootOrgId: req.auth.rootOrgId, + parentOrgId: req.auth.parentOrgId, + authMethod: null + }; + + logger.info( + `injectPermission: Injecting permissions for [permissionsForRelay=${req.auth.relayId}] [type=${ActorType.RELAY}]` + ); } }); }); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 5039e679575..852de98d64c 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1542,6 +1542,16 @@ export const registerRoutes = async ( keyStore }); + const resourceAuthMethodService = resourceAuthMethodServiceFactory({ + resourceAuthMethodDAL, + resourceAwsAuthDAL, + resourceTokenAuthDAL, + gatewayV2DAL, + relayDAL, + identityDAL, + permissionService + }); + const relayService = relayServiceFactory({ instanceRelayConfigDAL, orgRelayConfigDAL, @@ -1551,16 +1561,9 @@ export const registerRoutes = async ( orgDAL, notificationService, smtpService, - userDAL - }); - - const resourceAuthMethodService = resourceAuthMethodServiceFactory({ - resourceAuthMethodDAL, - resourceAwsAuthDAL, - resourceTokenAuthDAL, - gatewayV2DAL, - identityDAL, - permissionService + userDAL, + resourceAuthMethodService, + gatewayV2DAL }); const gatewayV2Service = gatewayV2ServiceFactory({ diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 4ab41adf500..c9b6c657468 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -23,6 +23,7 @@ export enum AuthTokenType { IDENTITY_ACCESS_TOKEN = "identityAccessToken", SCIM_TOKEN = "scimToken", GATEWAY_ACCESS_TOKEN = "gatewayAccessToken", + RELAY_ACCESS_TOKEN = "relayAccessToken", ACCOUNT_RECOVERY_TOKEN = "accountRecoveryToken" } @@ -41,7 +42,8 @@ export enum AuthMode { IDENTITY_ACCESS_TOKEN = "identityAccessToken", SCIM_TOKEN = "scimToken", MCP_JWT = "mcpJwt", - GATEWAY_ACCESS_TOKEN = "gatewayAccessToken" + GATEWAY_ACCESS_TOKEN = "gatewayAccessToken", + RELAY_ACCESS_TOKEN = "relayAccessToken" } export enum ActorType { // would extend to AWS, Azure, ... @@ -56,7 +58,8 @@ export enum ActorType { // would extend to AWS, Azure, ... EST_ACCOUNT = "estAccount", SCEP_ACCOUNT = "scepAccount", UNKNOWN_USER = "unknownUser", - GATEWAY = "gateway" + GATEWAY = "gateway", + RELAY = "relay" } export type TGatewayAccessTokenJwtPayload = { @@ -66,6 +69,13 @@ export type TGatewayAccessTokenJwtPayload = { tokenVersion: number; }; +export type TRelayAccessTokenJwtPayload = { + authTokenType: AuthTokenType.RELAY_ACCESS_TOKEN; + relayId: string; + orgId: string; + tokenVersion: number; +}; + // This will be null unless the token-type is JWT export type ActorAuthMethod = AuthMethod | null; diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index 309586a6b07..9f8e13cbb9c 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -7,6 +7,7 @@ import { IdentityActor, KmipClientActor, PlatformActor, + RelayActor, ScepAccountActor, ScimClientActor, ServiceActor, @@ -145,7 +146,8 @@ export type TSecretModifiedEvent = { | KmipClientActor | EstAccountActor | ScepAccountActor - | GatewayActor; + | GatewayActor + | RelayActor; }; }; diff --git a/docs/cli/commands/relay.mdx b/docs/cli/commands/relay.mdx index 92358ed8b62..96cb9640470 100644 --- a/docs/cli/commands/relay.mdx +++ b/docs/cli/commands/relay.mdx @@ -3,26 +3,11 @@ title: "infisical relay" description: "Relay-related commands for Infisical" --- - - - ```bash - infisical relay start --host= --name= --auth-method= - ``` - - - ```bash - # Install systemd service - sudo infisical relay systemd install --host= --name= --token= - - # Uninstall systemd service - sudo infisical relay systemd uninstall - ``` - - - ## Description -Relay-related commands for Infisical that provide identity-aware relay infrastructure for routing encrypted traffic. Relays are organization-deployed servers that route encrypted traffic between Infisical and your gateways. +Relay-related commands for Infisical. Relays are organization-deployed servers that route encrypted traffic between Infisical and your gateways. + +Relays are created via the Infisical dashboard (Networking → Relays → Create Relay) and then deployed using the CLI commands below. ## Subcommands & flags @@ -30,367 +15,133 @@ Relay-related commands for Infisical that provide identity-aware relay infrastru Run the Infisical relay component. The relay handles network traffic routing between Infisical and your gateways. -```bash -infisical relay start --host= --name= --auth-method= -``` - -### Flags - - - - The host (IP address or hostname) of the instance where the relay is deployed. This must be a static public IP or resolvable hostname that gateways can reach. - - ```bash - # Example with IP address - infisical relay start --host=203.0.113.100 --name=my-relay - - # Example with hostname - infisical relay start --host=relay.example.com --name=my-relay - ``` - - - - - The name of the relay. This is an arbitrary identifier for your relay instance. - - ```bash - # Example - infisical relay start --name=my-relay --host=192.168.1.100 - ``` - - - - ### Authentication -Relays support all standard Infisical authentication methods. Choose the authentication method that best fits your environment and set the corresponding flags when starting the relay. - -```bash -# Example with Universal Auth -infisical relay start --host=192.168.1.100 --name=my-relay --auth-method=universal-auth --client-id= --client-secret= -``` - -### Available Authentication Methods - -The Infisical CLI supports multiple authentication methods for relays. Below are the available authentication methods, with their respective flags. +Relays support two enrollment methods. The enrollment method is set when creating the relay in the dashboard. - - The Universal Auth method is a simple and secure way to authenticate with Infisical. It requires a client ID and a client secret to authenticate with Infisical. - - - - - Your machine identity client ID. - - - Your machine identity client secret. - - - The authentication method to use. Must be `universal-auth` when using Universal Auth. - - - - - ```bash - infisical relay start --auth-method=universal-auth --client-id= --client-secret= --host= --name= - ``` - - - - The Native Kubernetes method is used to authenticate with Infisical when running in a Kubernetes environment. It requires a service account token to authenticate with Infisical. - - - - - Your machine identity ID. - - - Path to the Kubernetes service account token to use. Default: `/var/run/secrets/kubernetes.io/serviceaccount/token`. - - - The authentication method to use. Must be `kubernetes` when using Native Kubernetes. - - - - - - - ```bash - infisical relay start --auth-method=kubernetes --machine-identity-id= --host= --name= - ``` - + + Token auth uses a one-time enrollment token (1 hour expiry) generated from the relay detail page. The token is exchanged for a long-lived access token on first start and stored on disk for subsequent restarts. + + The `--host` flag is not required — the host is stored server-side when the relay is created. + + + + ```bash + infisical relay start \ + --name= \ + --enroll-method=token \ + --token= \ + --domain= + ``` + + + ```bash + sudo infisical relay systemd install \ + --name= \ + --enroll-method=token \ + --token= \ + --domain= + sudo systemctl start infisical-relay + ``` + + + + On subsequent starts with the same enrollment token, the relay skips enrollment and uses the stored access token. + + Token-method enrollment tokens are single-use and expire after 1 hour. If the token expires before deployment, click **Show deploy command** on the relay detail page to generate a new one. - - The Native Azure method is used to authenticate with Infisical when running in an Azure environment. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `azure` when using Native Azure. - - - - - - - ```bash - infisical relay start --auth-method=azure --machine-identity-id= --host= --name= - ``` + + AWS auth uses the host's AWS credentials (instance role, env vars, or shared profile) to authenticate via STS GetCallerIdentity. A fresh token is minted on every start — no on-disk persistence needed. + + + + ```bash + infisical relay start \ + --name= \ + --enroll-method=aws \ + --relay-id= \ + --domain= + ``` + + + ```bash + sudo infisical relay systemd install \ + --name= \ + --enroll-method=aws \ + --relay-id= \ + --domain= + sudo systemctl start infisical-relay + ``` + + + + The `--relay-id` is the relay's UUID, visible on the relay detail page. The relay ID is persisted after first use so subsequent starts don't need `--relay-id` again. - - The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `gcp-id-token` when using Native GCP ID Token. - - - - - + - ```bash - infisical relay start --auth-method=gcp-id-token --machine-identity-id= --host= --name= - ``` +### Flags + + + The name of the relay. Must match the name used when creating the relay in the dashboard. - - The GCP IAM method is used to authenticate with Infisical with a GCP service account key. - - - - - Your machine identity ID. - - - Path to your GCP service account key file _(Must be in JSON format!)_ - - - The authentication method to use. Must be `gcp-iam` when using GCP IAM. - - - - - ```bash - infisical relay start --auth-method=gcp-iam --machine-identity-id= --service-account-key-file-path= --host= --name= - ``` + + The enrollment method to use. Supported values: `token`, `aws`. - - The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment like EC2, Lambda, etc. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `aws-iam` when using Native AWS IAM. - - - - - ```bash - infisical relay start --auth-method=aws-iam --machine-identity-id= --host= --name= - ``` + + The one-time enrollment token (required when `--enroll-method=token`). Generated from the relay detail page via **Show deploy command**. - - The OIDC Auth method is used to authenticate with Infisical via identity tokens with OIDC. - - - - - Your machine identity ID. - - - The OIDC JWT from the identity provider. - - - The authentication method to use. Must be `oidc-auth` when using OIDC Auth. - - - - - ```bash - infisical relay start --auth-method=oidc-auth --machine-identity-id= --jwt= --host= --name= - ``` + + The relay UUID (required when `--enroll-method=aws`). Visible on the relay detail page. - - The JWT Auth method is used to authenticate with Infisical via a JWT token. - - - - - The JWT token to use for authentication. - - - Your machine identity ID. - - - The authentication method to use. Must be `jwt-auth` when using JWT Auth. - - - - - - ```bash - infisical relay start --auth-method=jwt-auth --jwt= --machine-identity-id= --host= --name= - ``` - - - - You can use the `INFISICAL_TOKEN` environment variable to authenticate with Infisical with a raw machine identity access token. - - - - - The machine identity access token to use for authentication. - - - - - ```bash - infisical relay start --token= --host= --name= - ``` - + + Domain of your Infisical instance. Required for self-hosted deployments. - Manage systemd service for Infisical relay. This allows you to install and run the relay as a systemd service on Linux systems. - ### Requirements - - **Operating System**: Linux only (systemd is not supported on other operating systems) - - **Privileges**: Root/sudo privileges required for both install and uninstall operations - - **Systemd**: The system must be running systemd as the init system + Manage systemd service for the Infisical relay on Linux systems. -```bash -infisical relay systemd -``` + ### Requirements + - **Operating System**: Linux only + - **Privileges**: Root/sudo privileges required + - **Systemd**: The system must be running systemd ### Subcommands - Install and enable systemd service for the relay. Must be run with sudo on Linux systems. - -```bash -sudo infisical relay systemd install --host= --name= --token= [flags] -``` - -#### Flags - - - - The host (IP address or hostname) of the instance where the relay is deployed. This must be a static public IP or resolvable hostname that gateways can reach. - -```bash -# Example with IP address -sudo infisical relay systemd install --host=203.0.113.100 --name=my-relay --token= - -# Example with hostname -sudo infisical relay systemd install --host=relay.example.com --name=my-relay --token= -``` - - - - - The name of the relay. - -```bash -# Example -sudo infisical relay systemd install --name=my-relay --host=192.168.1.100 --token= -``` - - - - - Connect with Infisical using machine identity access token. + Install and enable systemd service for the relay. See the authentication section above for install commands. -```bash -# Example -sudo infisical relay systemd install --token= --host= --name= -``` + After installation, start the service: - - - - Domain of your self-hosted Infisical instance. Optional flag for specifying a custom domain. - -```bash -# Example -sudo infisical relay systemd install --domain=http://localhost:8080 --token= --host= --name= -``` - - - - -#### Examples - -```bash -# Install relay with token authentication -sudo infisical relay systemd install --host=192.168.1.100 --name=my-relay --token= - -# Install with custom domain -sudo infisical relay systemd install --domain=http://localhost:8080 --token= --host= --name= -``` - -#### Post-installation + ```bash + sudo systemctl start infisical-relay + sudo systemctl enable infisical-relay + ``` -After successful installation, the service will be enabled but not started. To start the service: - -```bash -sudo systemctl start infisical-relay -``` - -To check the service status: - -```bash -sudo systemctl status infisical-relay -``` - -To view service logs: - -```bash -sudo journalctl -u infisical-relay -f -``` + To check status and logs: + ```bash + sudo systemctl status infisical-relay + sudo journalctl -u infisical-relay -f + ``` - Uninstall and remove systemd service for the relay. Must be run with sudo on Linux systems. - -```bash -sudo infisical relay systemd uninstall -``` - -#### Examples - -```bash -# Uninstall the relay systemd service -sudo infisical relay systemd uninstall -``` - -#### What it does - -- Stops the `infisical-relay` systemd service if it's running -- Disables the service from starting on boot -- Removes the systemd service file -- Cleans up the service configuration + Uninstall and remove systemd service for the relay. + ```bash + sudo infisical relay systemd uninstall + ``` diff --git a/docs/documentation/platform/gateways/relay-deployment/overview.mdx b/docs/documentation/platform/gateways/relay-deployment/overview.mdx index ba7689196e6..da234a61b48 100644 --- a/docs/documentation/platform/gateways/relay-deployment/overview.mdx +++ b/docs/documentation/platform/gateways/relay-deployment/overview.mdx @@ -25,90 +25,24 @@ If you are using Infisical Cloud and do not have specific requirements, you can To successfully deploy an Infisical Relay for use, follow these steps in order. - - Create a machine identity with the correct permissions to create and manage relays. This identity is used by the relay to authenticate with Infisical and should be provisioned in advance. - The relay supports several [machine identity auth methods](/documentation/platform/identities/machine-identities) for authentication, as listed below. Choose the one that best fits your environment and set the corresponding environment variables when deploying the relay. - - - - Simple and secure authentication using client ID and client secret. - - **Environment Variables:** - - `INFISICAL_AUTH_METHOD=universal-auth` - - `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=` - - `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=` - - - - Direct authentication using a machine identity access token. - - **Environment Variables:** - - `INFISICAL_TOKEN=` - - - - Authentication using Kubernetes service account tokens. - - **Environment Variables:** - - `INFISICAL_AUTH_METHOD=kubernetes` - - `INFISICAL_MACHINE_IDENTITY_ID=` - - - - Authentication using AWS IAM roles. - - **Environment Variables:** - - `INFISICAL_AUTH_METHOD=aws-iam` - - `INFISICAL_MACHINE_IDENTITY_ID=` - - - - Authentication using GCP identity tokens. - - **Environment Variables:** - - `INFISICAL_AUTH_METHOD=gcp-id-token` - - `INFISICAL_MACHINE_IDENTITY_ID=` - - - - Authentication using GCP service account keys. - - **Environment Variables:** - - `INFISICAL_AUTH_METHOD=gcp-iam` - - `INFISICAL_MACHINE_IDENTITY_ID=` - - `INFISICAL_GCP_SERVICE_ACCOUNT_KEY_FILE_PATH=` - - - - Authentication using Azure managed identity. - - **Environment Variables:** - - `INFISICAL_AUTH_METHOD=azure` - - `INFISICAL_MACHINE_IDENTITY_ID=` - - - - Authentication using OIDC identity tokens. - - **Environment Variables:** - - `INFISICAL_AUTH_METHOD=oidc-auth` - - `INFISICAL_MACHINE_IDENTITY_ID=` - - `INFISICAL_JWT=` - - - - Authentication using JWT tokens. + + Provision a server or virtual machine where you plan to deploy the relay. This server must have a static IP address or DNS name to be identifiable by the Infisical platform. + - **Environment Variables:** - - `INFISICAL_AUTH_METHOD=jwt-auth` - - `INFISICAL_MACHINE_IDENTITY_ID=` - - `INFISICAL_JWT=` - - + + 1. Navigate to **Organization Settings > Networking > Relays**. + 2. Click **Create Relay**. + 3. Enter a name and host address (the static IP or DNS name of the server from Step 1). + 4. (Optional) Open the new relay's detail page and click the edit icon next to **Authentication** to switch the auth method. Two methods are supported: + - **Token** (default): a one-time enrollment token (1h expiry) bootstraps the relay. + - **AWS**: the relay authenticates by signing an `sts:GetCallerIdentity` request with whatever AWS credentials it can resolve on the host (instance role, env vars, shared profile). Configure the allowed principal ARNs and/or account IDs that match your hosts. + 5. Click **Show deploy command** and copy the generated CLI command. - - Provision a server or virtual machine where you plan to deploy the relay. This server must have a static IP address or DNS name to be identifiable by the Infisical platform. + + Make sure the Infisical CLI is installed on the target machine. See the [CLI Installation Guide](/cli/overview) for instructions. + + To view all available flags and equivalent environment variables for relay deployment, see the [Relay CLI Command Reference](/cli/commands/relay). @@ -127,54 +61,81 @@ To successfully deploy an Infisical Relay for use, follow these steps in order. - - You can deploy the Infisical Relay in various ways. This guide provides a manual setup example using the Infisical CLI. For an infrastructure-as-code approach, see our [Terraform guide](/documentation/platform/gateways/relay-deployment/terraform). + + Run the command you copied from the UI on the target machine. This single command enrolls the relay and starts it immediately. - The Infisical CLI is used to install and start the relay in your chosen environment. The CLI provides commands for both production and development scenarios, and supports a variety of options/flags to configure your deployment. - - To view all available flags and equivalent environment variables for relay deployment, see the [Relay CLI Command Reference](/cli/commands/relay). - - - For production deployments on Linux servers, install the Relay as a systemd service. This installation method only supports [Token Auth](/documentation/platform/identities/token-auth) at the moment. - - Once you have a [Token Auth](/documentation/platform/identities/token-auth) token, set the following environment variables for relay authentication: - - ```bash - export INFISICAL_TOKEN= - ``` - - - The systemd install command requires a Linux operating system with root/sudo privileges. - - - ```bash - sudo infisical relay systemd install \ - --token \ - --name \ - --domain \ - --host + + + A one-time enrollment token (1h expiry) bootstraps the relay. + + + + ```bash + sudo infisical relay systemd install \ + --name= \ + --enroll-method=token \ + --token= \ + --domain= + sudo systemctl start infisical-relay + ``` + + + ```bash + infisical relay start \ + --name= \ + --enroll-method=token \ + --token= \ + --domain= + ``` + + + + + The host must have AWS credentials whose principal matches your allowlist. The relay re-authenticates via STS on every start. + + + + ```bash + sudo infisical relay systemd install \ + --name= \ + --enroll-method=aws \ + --relay-id= \ + --domain= + sudo systemctl start infisical-relay + ``` + + + ```bash + infisical relay start \ + --name= \ + --enroll-method=aws \ + --relay-id= \ + --domain= + ``` + + + + - # Start the relay service - sudo systemctl start infisical-relay - sudo systemctl enable infisical-relay - ``` - + + The systemd install command requires Linux with root/sudo privileges. + - - For non-Linux systems or when you need more control over the relay process: + + Token-method enrollment tokens are single-use and expire after 1 hour. If the token expires before deployment, click **Show deploy command** again on the relay detail page to generate a new one. + - ```bash - infisical relay start \ - --host= \ - --name= \ - --auth-method= - ``` + + You can safely re-run the same command to restart the relay. The CLI detects the token has already been used locally and skips enrollment automatically. + - This method supports all [machine identity auth methods](/documentation/platform/identities/machine-identities) and runs in the foreground. Suitable for production use on non-Linux systems or development environments. - Set the appropriate environment variables for your chosen auth method as described in Step 1 before running the relay start command. - - + + + After deployment, verify your relay is working: + 1. **Check logs** for "Relay server started successfully" message. + 2. **Verify registration** in the Infisical UI. Navigate to **Networking > Relays** and click on your relay to confirm it shows a "Healthy" status. + 3. **Test connectivity** by deploying a gateway that routes through this relay. diff --git a/docs/documentation/platform/gateways/relay-deployment/terraform.mdx b/docs/documentation/platform/gateways/relay-deployment/terraform.mdx index e89871cd9f3..164a41000ae 100644 --- a/docs/documentation/platform/gateways/relay-deployment/terraform.mdx +++ b/docs/documentation/platform/gateways/relay-deployment/terraform.mdx @@ -106,14 +106,14 @@ module "infisical_relay_instance" { apt-get update && apt-get install -y infisical # Install the relay as a systemd service. - # This example uses a Machine Identity token for authentication via the INFISICAL_TOKEN environment variable. + # Create the relay in the Infisical UI first, then use the enrollment token here. # # Note: For production environments, you might consider fetching the token from AWS Parameter Store or AWS Secrets Manager. - export INFISICAL_TOKEN="your-machine-identity-token" - sudo -E infisical relay systemd install \ + sudo infisical relay systemd install \ --name "my-relay-example" \ - --domain "https://app.infisical.com" \ - --host "${aws_eip.infisical_relay_eip.public_ip}" + --enroll-method=token \ + --token "your-enrollment-token" \ + --domain "https://app.infisical.com" # Start and enable the service to run on boot sudo systemctl start infisical-relay @@ -139,7 +139,7 @@ The provided security group rules are open to the internet (`0.0.0.0/0`) for sim - `region` in the `provider` block. - `vpc_id` in the `aws_security_group` resource. - `ami` and `subnet_id` in the `infisical_relay_instance` module. - - The `INFISICAL_TOKEN` environment variable in the `user_data` script (e.g., `export INFISICAL_TOKEN="your-machine-identity-token"`). + - The `--token` in the `user_data` script with the enrollment token from the relay detail page. - The `--domain` in the `user_data` script if you are self-hosting Infisical. 3. **Apply the configuration:** Run the following Terraform commands in your terminal: ```bash diff --git a/docs/internals/permissions/organization-permissions.mdx b/docs/internals/permissions/organization-permissions.mdx index 646d1e864f8..ffbe7feedf9 100644 --- a/docs/internals/permissions/organization-permissions.mdx +++ b/docs/internals/permissions/organization-permissions.mdx @@ -245,10 +245,11 @@ Supports conditions and permission inversion | Action | Description | | --------------- | ------------------------------- | -| `list-relays` | View all organization relays | -| `create-relays` | Add new relays to organization | -| `edit-relays` | Modify existing relay settings | -| `delete-relays` | Remove relays from organization | +| `list-relays` | View all organization relays | +| `create-relays` | Add new relays to organization | +| `edit-relays` | Modify existing relay settings | +| `delete-relays` | Remove relays from organization | +| `revoke-relay-access` | Revoke relay access and invalidate tokens | #### Subject: `machine-identity-auth-template` diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index adcf0aba4a0..9ba139014b8 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -76,6 +76,10 @@ export const ROUTE_PATHS = Object.freeze({ GatewayDetailsByIDPage: setRoute( "/organizations/$orgId/networking/gateways/$gatewayId", "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking/gateways/$gatewayId" + ), + RelayDetailsByIDPage: setRoute( + "/organizations/$orgId/networking/relays/$relayId", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking/relays/$relayId" ) }, SecretManager: { diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 44f86efabb6..6632e59cee9 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -42,7 +42,8 @@ export enum OrgRelayPermissionActions { CreateRelays = "create-relays", ListRelays = "list-relays", EditRelays = "edit-relays", - DeleteRelays = "delete-relays" + DeleteRelays = "delete-relays", + RevokeRelayAccess = "revoke-relay-access" } export enum OrgPermissionMachineIdentityAuthTemplateActions { diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 93bdb7f68cc..e707bf0f369 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -438,10 +438,14 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.GATEWAY_CREATE]: "Create gateway", [EventType.GATEWAY_ENROLL]: "Enroll gateway (legacy)", [EventType.GATEWAY_ENROLLMENT_TOKEN_CREATE]: "Generate gateway enrollment token", - [EventType.RESOURCE_AUTH_METHOD_LOGIN]: "Gateway login", - [EventType.RESOURCE_AUTH_METHOD_LOGIN_FAILED]: "Gateway login failed", - [EventType.RESOURCE_AUTH_METHOD_UPDATE]: "Update gateway auth method", - [EventType.RESOURCE_AUTH_METHOD_REVOKE]: "Revoke gateway access" + [EventType.RESOURCE_AUTH_METHOD_LOGIN]: "Resource login", + [EventType.RESOURCE_AUTH_METHOD_LOGIN_FAILED]: "Resource login failed", + [EventType.RESOURCE_AUTH_METHOD_UPDATE]: "Update resource auth method", + [EventType.RESOURCE_AUTH_METHOD_REVOKE]: "Revoke resource access", + [EventType.RELAY_CREATE]: "Create relay", + [EventType.RELAY_UPDATE]: "Update relay", + [EventType.RELAY_DELETE]: "Delete relay", + [EventType.RELAY_ENROLLMENT_TOKEN_CREATE]: "Generate relay enrollment token" }; export const userAgentTypeToNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index a1e348ac545..cba5ac0b029 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -437,5 +437,9 @@ export enum EventType { RESOURCE_AUTH_METHOD_LOGIN = "resource-auth-method-login", RESOURCE_AUTH_METHOD_LOGIN_FAILED = "resource-auth-method-login-failed", RESOURCE_AUTH_METHOD_UPDATE = "resource-auth-method-update", - RESOURCE_AUTH_METHOD_REVOKE = "resource-auth-method-revoke" + RESOURCE_AUTH_METHOD_REVOKE = "resource-auth-method-revoke", + RELAY_CREATE = "relay-create", + RELAY_UPDATE = "relay-update", + RELAY_DELETE = "relay-delete", + RELAY_ENROLLMENT_TOKEN_CREATE = "relay-enrollment-token-create" } diff --git a/frontend/src/hooks/api/relays/mutations.tsx b/frontend/src/hooks/api/relays/mutations.tsx index 4e1d52a6720..a6816e85613 100644 --- a/frontend/src/hooks/api/relays/mutations.tsx +++ b/frontend/src/hooks/api/relays/mutations.tsx @@ -3,6 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { relayQueryKeys } from "./queries"; +import { + TCreateRelayDTO, + TGenerateRelayEnrollmentTokenDTO, + TRelayWithAuthMethod, + TRevokeRelayAccessDTO, + TUpdateRelayDTO +} from "./types"; export const useDeleteRelayById = () => { const queryClient = useQueryClient(); @@ -15,3 +22,64 @@ export const useDeleteRelayById = () => { } }); }; + +export const useCreateRelay = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (dto: TCreateRelayDTO) => { + const { data } = await apiRequest.post("/api/v2/relays", dto); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: relayQueryKeys.list() }); + } + }); +}; + +export const useUpdateRelay = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ relayId, host, authMethod }: TUpdateRelayDTO) => { + const body: Record = {}; + if (host !== undefined) body.host = host; + if (authMethod !== undefined) body.authMethod = authMethod; + + const { data } = await apiRequest.patch( + `/api/v2/relays/${relayId}`, + body + ); + return data; + }, + onSuccess: (_, { relayId }) => { + queryClient.invalidateQueries({ queryKey: relayQueryKeys.byId(relayId) }); + queryClient.invalidateQueries({ queryKey: relayQueryKeys.list() }); + } + }); +}; + +export const useGenerateRelayEnrollmentToken = () => { + return useMutation({ + mutationFn: async ({ relayId }: TGenerateRelayEnrollmentTokenDTO) => { + const { data } = await apiRequest.post<{ token: string; expiresAt: string }>( + `/api/v2/relays/${relayId}/token-auth/generate-enrollment-token` + ); + return data; + } + }); +}; + +export const useRevokeRelayAccess = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ relayId }: TRevokeRelayAccessDTO) => { + const { data } = await apiRequest.post<{ method: string; deletedTokenCount: number }>( + `/api/v2/relays/${relayId}/revoke` + ); + return data; + }, + onSuccess: (_, { relayId }) => { + queryClient.invalidateQueries({ queryKey: relayQueryKeys.byId(relayId) }); + queryClient.invalidateQueries({ queryKey: relayQueryKeys.list() }); + } + }); +}; diff --git a/frontend/src/hooks/api/relays/queries.tsx b/frontend/src/hooks/api/relays/queries.tsx index 27472435631..a0bbca2d4da 100644 --- a/frontend/src/hooks/api/relays/queries.tsx +++ b/frontend/src/hooks/api/relays/queries.tsx @@ -2,10 +2,19 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TRelay } from "./types"; +import { TRelay, TRelayWithAuthMethod } from "./types"; + +export type TRelayConnectedGateway = { + id: string; + name: string; + createdAt: string; + heartbeat: string | null; +}; export const relayQueryKeys = { - list: () => ["relays"] as const + list: () => ["relays"] as const, + byId: (relayId: string) => [{ relayId }, "relay"] as const, + connectedGateways: (relayId: string) => [{ relayId }, "relay-connected-gateways"] as const }; const fetchRelays = async (): Promise => { @@ -13,9 +22,39 @@ const fetchRelays = async (): Promise => { return data; }; +const fetchRelayById = async (relayId: string): Promise => { + const { data } = await apiRequest.get(`/api/v2/relays/${relayId}`); + return data; +}; + export const useGetRelays = () => { return useQuery({ queryKey: relayQueryKeys.list(), queryFn: fetchRelays }); }; + +export const useGetRelayById = (relayId: string) => { + return useQuery({ + queryKey: relayQueryKeys.byId(relayId), + queryFn: () => fetchRelayById(relayId), + enabled: Boolean(relayId), + staleTime: 0, + gcTime: 0 + }); +}; + +const fetchRelayConnectedGateways = async (relayId: string): Promise => { + const { data } = await apiRequest.get( + `/api/v2/relays/${relayId}/gateways` + ); + return data; +}; + +export const useGetRelayConnectedGateways = (relayId: string) => { + return useQuery({ + queryKey: relayQueryKeys.connectedGateways(relayId), + queryFn: () => fetchRelayConnectedGateways(relayId), + enabled: Boolean(relayId) + }); +}; diff --git a/frontend/src/hooks/api/relays/types.ts b/frontend/src/hooks/api/relays/types.ts index 1d40cb9f0c8..13473b59c19 100644 --- a/frontend/src/hooks/api/relays/types.ts +++ b/frontend/src/hooks/api/relays/types.ts @@ -1,3 +1,24 @@ +export type TRelayAuthMethodView = + | { + method: "aws"; + config: { + id: string; + stsEndpoint: string; + allowedPrincipalArns: string; + allowedAccountIds: string; + createdAt: string; + updatedAt: string; + }; + } + | { + method: "token"; + config: Record; + } + | { + method: "identity"; + config: { identityId: string; identityName: string | null }; + }; + export type TRelay = { id: string; createdAt: string; @@ -9,6 +30,45 @@ export type TRelay = { heartbeat: string; }; +export type TRelayWithAuthMethod = TRelay & { + canRevoke: boolean; + authMethod: TRelayAuthMethodView; +}; + export type TDeleteRelayDTO = { id: string; }; + +export type TCreateRelayDTO = { + name: string; + host: string; + authMethod: + | { + method: "aws"; + stsEndpoint: string; + allowedPrincipalArns: string; + allowedAccountIds: string; + } + | { method: "token" }; +}; + +export type TUpdateRelayDTO = { + relayId: string; + host?: string; + authMethod?: + | { + method: "aws"; + stsEndpoint: string; + allowedPrincipalArns: string; + allowedAccountIds: string; + } + | { method: "token" }; +}; + +export type TGenerateRelayEnrollmentTokenDTO = { + relayId: string; +}; + +export type TRevokeRelayAccessDTO = { + relayId: string; +}; diff --git a/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/RelayDetailsByIDPage.tsx b/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/RelayDetailsByIDPage.tsx new file mode 100644 index 00000000000..94bdf173046 --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/RelayDetailsByIDPage.tsx @@ -0,0 +1,84 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { Link, useParams } from "@tanstack/react-router"; +import { ChevronLeftIcon } from "lucide-react"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { EmptyState } from "@app/components/v2"; +import { PageLoader } from "@app/components/v3"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { useOrganization } from "@app/context"; +import { + OrgPermissionSubjects, + OrgRelayPermissionActions +} from "@app/context/OrgPermissionContext/types"; +import { useGetRelayById } from "@app/hooks/api/relays"; + +import { RelayConnectedGatewaysSection } from "./components/RelayConnectedGatewaysSection/RelayConnectedGatewaysSection"; +import { RelayDeploySection } from "./components/RelayDeploySection/RelayDeploySection"; +import { RelayDetailsCard } from "./components/RelayDetailsCard/RelayDetailsCard"; +import { RelayPageHeader } from "./components/RelayPageHeader/RelayPageHeader"; + +const Page = () => { + const params = useParams({ from: ROUTE_PATHS.Organization.RelayDetailsByIDPage.id }); + const relayId = params.relayId as string; + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { data: relay, isPending } = useGetRelayById(relayId); + + if (isPending) { + return ; + } + + if (!relay) { + return ; + } + + return ( +
+ + + Relays + + +
+
+ +
+
+ + +
+
+
+ ); +}; + +export const RelayDetailsByIDPage = () => { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: t("settings.org.title") })} + + + + + + + ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayAuthMethod/RelayAuthMethodModal.tsx b/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayAuthMethod/RelayAuthMethodModal.tsx new file mode 100644 index 00000000000..5bb570d3bf8 --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayAuthMethod/RelayAuthMethodModal.tsx @@ -0,0 +1,235 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Field, + FieldContent, + FieldError, + FieldLabel, + FilterableSelect, + Input +} from "@app/components/v3"; +import { useOrganization } from "@app/context"; +import { useUpdateRelay } from "@app/hooks/api/relays"; +import { TRelayAuthMethodView } from "@app/hooks/api/relays/types"; + +type SettableMethod = "aws" | "token"; + +const METHOD_OPTIONS: { value: SettableMethod; label: string }[] = [ + { value: "token", label: "Token Auth" }, + { value: "aws", label: "AWS Auth" } +]; + +const schema = z + .object({ + method: z.enum(["aws", "token"]), + stsEndpoint: z.string(), + allowedPrincipalArns: z.string(), + allowedAccountIds: z.string() + }) + .superRefine((data, ctx) => { + if (data.method === "aws") { + const hasArns = data.allowedPrincipalArns.trim().length > 0; + const hasAccountIds = data.allowedAccountIds.trim().length > 0; + if (!hasArns && !hasAccountIds) { + const message = "At least one of allowed principal ARNs or allowed account IDs must be set"; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowedPrincipalArns"], + message + }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowedAccountIds"], + message + }); + } + } + }); + +type FormData = z.infer; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + relayId: string; + currentMethod: TRelayAuthMethodView; +}; + +export const RelayAuthMethodModal = ({ isOpen, onOpenChange, relayId, currentMethod }: Props) => { + const { mutateAsync: updateRelay, isPending } = useUpdateRelay(); + const { isSubOrganization } = useOrganization(); + + const initialMethod: SettableMethod = currentMethod.method === "aws" ? "aws" : "token"; + const initialAws = currentMethod.method === "aws" ? currentMethod.config : null; + + const { + control, + handleSubmit, + watch, + reset, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + method: initialMethod, + stsEndpoint: initialAws?.stsEndpoint ?? "https://sts.amazonaws.com/", + allowedPrincipalArns: initialAws?.allowedPrincipalArns ?? "", + allowedAccountIds: initialAws?.allowedAccountIds ?? "" + } + }); + + useEffect(() => { + if (isOpen) { + reset({ + method: initialMethod, + stsEndpoint: initialAws?.stsEndpoint ?? "https://sts.amazonaws.com/", + allowedPrincipalArns: initialAws?.allowedPrincipalArns ?? "", + allowedAccountIds: initialAws?.allowedAccountIds ?? "" + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + const method = watch("method"); + + const onSubmit = async (form: FormData) => { + try { + if (form.method === "aws") { + await updateRelay({ + relayId, + authMethod: { + method: "aws", + stsEndpoint: form.stsEndpoint, + allowedPrincipalArns: form.allowedPrincipalArns, + allowedAccountIds: form.allowedAccountIds + } + }); + } else { + await updateRelay({ relayId, authMethod: { method: "token" } }); + } + createNotification({ type: "success", text: "Auth method updated" }); + onOpenChange(false); + } catch { + createNotification({ type: "error", text: "Failed to update auth method" }); + } + }; + + return ( + + + + Edit Auth Method + + Switch the relay's auth method or update the current method's config. + + +
+ { + const selected = + METHOD_OPTIONS.find((o) => o.value === field.value) ?? METHOD_OPTIONS[0]; + return ( + + Method + + { + const next = opt as { value: SettableMethod } | null; + if (next) field.onChange(next.value); + }} + options={METHOD_OPTIONS} + isSearchable={false} + isClearable={false} + getOptionLabel={(o) => o.label} + getOptionValue={(o) => o.value} + /> + + + ); + }} + /> + + {method === "aws" && ( + <> + ( + + Allowed Principal ARNs + + + + + + )} + /> + ( + + Allowed Account IDs + + + + + + )} + /> + ( + + STS Endpoint + + + + + + )} + /> + + )} + + + + + + +
+
+ ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayConnectedGatewaysSection/RelayConnectedGatewaysSection.tsx b/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayConnectedGatewaysSection/RelayConnectedGatewaysSection.tsx new file mode 100644 index 00000000000..0ad7ee0aea3 --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayConnectedGatewaysSection/RelayConnectedGatewaysSection.tsx @@ -0,0 +1,139 @@ +import { useMemo } from "react"; +import { Link } from "@tanstack/react-router"; +import { formatDistanceToNow } from "date-fns"; +import { ExternalLinkIcon } from "lucide-react"; + +import { Spinner } from "@app/components/v2"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Badge, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, + Item, + ItemContent, + ItemDescription, + ItemGroup, + ItemTitle +} from "@app/components/v3"; +import { useOrganization } from "@app/context"; +import { + TRelayConnectedGateway, + useGetRelayConnectedGateways +} from "@app/hooks/api/relays/queries"; + +type StatusGroup = "healthy" | "unreachable" | "unregistered"; + +const classifyGateway = (g: TRelayConnectedGateway): StatusGroup => { + if (!g.heartbeat) return "unregistered"; + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + if (new Date(g.heartbeat) > oneHourAgo) return "healthy"; + return "unreachable"; +}; + +const STATUS_CONFIG: Record< + StatusGroup, + { label: string; variant: "success" | "danger" | "warning" } +> = { + healthy: { label: "Healthy", variant: "success" }, + unreachable: { label: "Unreachable", variant: "danger" }, + unregistered: { label: "Unregistered", variant: "warning" } +}; + +const STATUS_ORDER: StatusGroup[] = ["healthy", "unreachable", "unregistered"]; + +const gatewaySubtitle = (g: TRelayConnectedGateway) => { + const parts: string[] = []; + parts.push(`Created ${formatDistanceToNow(new Date(g.createdAt), { addSuffix: true })}`); + if (g.heartbeat) { + parts.push(`Last seen ${formatDistanceToNow(new Date(g.heartbeat), { addSuffix: true })}`); + } + return parts.join(" · "); +}; + +export const RelayConnectedGatewaysSection = ({ relayId }: { relayId: string }) => { + const { currentOrg } = useOrganization(); + const { data: gateways, isPending } = useGetRelayConnectedGateways(relayId); + + const grouped = useMemo(() => { + if (!gateways) return {}; + return gateways.reduce>>((groups, g) => { + const status = classifyGateway(g); + return { ...groups, [status]: [...(groups[status] || []), g] }; + }, {}); + }, [gateways]); + + const total = gateways?.length ?? 0; + + return ( + + + Connected Gateways + Gateways currently routing through this relay + + + {isPending && ( +
+ +
+ )} + {!isPending && total === 0 && ( + + + No connected gateways + + Gateways that route through this relay will show up here. + + + + )} + {!isPending && total > 0 && ( + + {STATUS_ORDER.map((status) => { + const items = grouped[status]; + if (!items || items.length === 0) return null; + const { label, variant } = STATUS_CONFIG[status]; + return ( + + + {label} + {items.length} + + + + {items.map((g) => ( + + + + {g.name} + + {gatewaySubtitle(g)} + + + + + + ))} + + + + ); + })} + + )} +
+
+ ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayDeploySection/RelayDeployCommandDialog.tsx b/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayDeploySection/RelayDeployCommandDialog.tsx new file mode 100644 index 00000000000..4a36a00743b --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayDeploySection/RelayDeployCommandDialog.tsx @@ -0,0 +1,152 @@ +import { useMemo } from "react"; +import { faCopy, faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormLabel, + IconButton, + Input, + Modal, + ModalClose, + ModalContent, + Tab, + TabList, + TabPanel, + Tabs +} from "@app/components/v2"; +import { Badge } from "@app/components/v3/generic/Badge"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + relayId: string; + relayName: string; + authMethod: "token" | "aws"; + enrollmentToken: string | null; +}; + +export const RelayDeployCommandDialog = ({ + isOpen, + onOpenChange, + relayId, + relayName, + authMethod, + enrollmentToken +}: Props) => { + const { protocol, hostname, port } = window.location; + const portSuffix = port && port !== "80" ? `:${port}` : ""; + const siteURL = `${protocol}//${hostname}${portSuffix}`; + + const cliCommand = useMemo(() => { + if (authMethod === "aws") { + return `infisical relay start --name=${relayName} --enroll-method=aws --relay-id=${relayId} --domain=${siteURL}`; + } + return `infisical relay start --name=${relayName} --enroll-method=token --token=${enrollmentToken} --domain=${siteURL}`; + }, [relayName, relayId, enrollmentToken, authMethod, siteURL]); + + const systemdInstallCommand = useMemo(() => { + if (authMethod === "aws") { + return `sudo infisical relay systemd install --name=${relayName} --enroll-method=aws --relay-id=${relayId} --domain=${siteURL}`; + } + return `sudo infisical relay systemd install --name=${relayName} --enroll-method=token --token=${enrollmentToken} --domain=${siteURL}`; + }, [relayName, relayId, enrollmentToken, authMethod, siteURL]); + + const startServiceCommand = "sudo systemctl start infisical-relay"; + + const copy = (text: string, label: string) => { + navigator.clipboard.writeText(text); + createNotification({ type: "info", text: `${label} copied to clipboard` }); + }; + + const badgeLabel = authMethod === "aws" ? "AWS Auth" : "Token Auth"; + const helperText = + authMethod === "aws" + ? "The host must have AWS credentials whose principal matches your allowlist." + : "The enrollment token expires in 1 hour and can only be used once."; + + return ( + + +
+
+ + {badgeLabel} +
+

{helperText}

+
+ + + + CLI + CLI (systemd) + + +
+ + copy(cliCommand, "Command")} + className="w-10" + > + + +
+
+ + +
+ + copy(systemdInstallCommand, "Installation command")} + className="w-10" + > + + +
+ +
+ + copy(startServiceCommand, "Start command")} + className="w-10" + > + + +
+
+
+ + Install the Infisical CLI + + +
+ + + +
+
+
+ ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayDeploySection/RelayDeploySection.tsx b/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayDeploySection/RelayDeploySection.tsx new file mode 100644 index 00000000000..0c7195095c5 --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayDeploySection/RelayDeploySection.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { RocketIcon } from "lucide-react"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + Card, + CardAction, + CardDescription, + CardHeader, + CardTitle +} from "@app/components/v3"; +import { useOrganization } from "@app/context"; +import { + OrgPermissionSubjects, + OrgRelayPermissionActions +} from "@app/context/OrgPermissionContext/types"; +import { useGenerateRelayEnrollmentToken } from "@app/hooks/api/relays"; +import { TRelayAuthMethodView } from "@app/hooks/api/relays/types"; + +import { RelayDeployCommandDialog } from "./RelayDeployCommandDialog"; + +type Props = { + relayId: string; + relayName: string; + authMethod: TRelayAuthMethodView; + isFirstTimeSetup: boolean; +}; + +export const RelayDeploySection = ({ relayId, relayName, authMethod, isFirstTimeSetup }: Props) => { + const { isSubOrganization } = useOrganization(); + const [showDialog, setShowDialog] = useState(false); + const [enrollmentToken, setEnrollmentToken] = useState(null); + const { mutateAsync: mint, isPending: isMinting } = useGenerateRelayEnrollmentToken(); + + if (authMethod.method === "identity") return null; + + const handleClick = async () => { + if (authMethod.method === "token") { + try { + const result = await mint({ relayId }); + setEnrollmentToken(result.token); + setShowDialog(true); + } catch { + createNotification({ type: "error", text: "Failed to generate enrollment token" }); + } + } else { + setShowDialog(true); + } + }; + + return ( + <> + + + Deployment + Launch this relay on a target host + + + {(isAllowed) => { + let variant: "neutral" | "org" | "sub-org" = "neutral"; + if (isFirstTimeSetup) variant = isSubOrganization ? "sub-org" : "org"; + return ( + + ); + }} + + + + + + {showDialog && ( + { + if (!open) { + setShowDialog(false); + setEnrollmentToken(null); + } + }} + relayId={relayId} + relayName={relayName} + authMethod={authMethod.method as "token" | "aws"} + enrollmentToken={enrollmentToken} + /> + )} + + ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayDetailsCard/RelayDetailsCard.tsx b/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayDetailsCard/RelayDetailsCard.tsx new file mode 100644 index 00000000000..edcf4bb3f3f --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayDetailsCard/RelayDetailsCard.tsx @@ -0,0 +1,314 @@ +import { useState } from "react"; +import { Link } from "@tanstack/react-router"; +import { format } from "date-fns"; +import { CheckIcon, ClipboardListIcon, PencilIcon, TriangleAlertIcon } from "lucide-react"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Alert, + AlertDescription, + AlertTitle, + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Detail, + DetailGroup, + DetailGroupHeader, + DetailLabel, + DetailValue, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Field, + FieldContent, + FieldLabel, + IconButton, + Input, + Separator +} from "@app/components/v3"; +import { useOrganization } from "@app/context"; +import { + OrgPermissionSubjects, + OrgRelayPermissionActions +} from "@app/context/OrgPermissionContext/types"; +import { useTimedReset } from "@app/hooks"; +import { useUpdateRelay } from "@app/hooks/api/relays"; +import { TRelayAuthMethodView, TRelayWithAuthMethod } from "@app/hooks/api/relays/types"; + +import { RelayAuthMethodModal } from "../RelayAuthMethod/RelayAuthMethodModal"; + +const HealthBadge = ({ relay }: { relay: TRelayWithAuthMethod }) => { + if (!relay.heartbeat) { + return Unregistered; + } + const lastHeartbeat = new Date(relay.heartbeat); + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + if (lastHeartbeat > oneHourAgo) { + return Healthy; + } + return Unreachable; +}; + +const AuthMethodBadge = ({ method }: { method: TRelayAuthMethodView["method"] }) => { + if (method === "aws") return AWS Auth; + if (method === "token") return Token Auth; + return Machine Identity; +}; + +const EditGeneralModal = ({ + isOpen, + onOpenChange, + relayId, + currentHost +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + relayId: string; + currentHost: string; +}) => { + const [host, setHost] = useState(currentHost); + const { mutateAsync: updateRelay, isPending } = useUpdateRelay(); + const { isSubOrganization } = useOrganization(); + + const onSubmit = async () => { + if (!host.trim()) return; + try { + await updateRelay({ relayId, host: host.trim() }); + createNotification({ type: "success", text: "Relay updated" }); + onOpenChange(false); + } catch { + createNotification({ type: "error", text: "Failed to update relay" }); + } + }; + + return ( + { + if (open) setHost(currentHost); + onOpenChange(open); + }} + > + + + Edit Relay + Update the relay host address. + + + Host + + setHost(e.target.value)} + placeholder="10.0.0.5 or relay.example.com" + /> + + + + + + + + + ); +}; + +export const RelayDetailsCard = ({ relay }: { relay: TRelayWithAuthMethod }) => { + const [, isCopyingId, setCopyTextId] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); + const [authModalOpen, setAuthModalOpen] = useState(false); + const [generalModalOpen, setGeneralModalOpen] = useState(false); + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const { authMethod } = relay; + const isIdentityRelay = authMethod.method === "identity"; + + return ( + <> + + + Details + + + + + General + + {(isAllowed) => ( + setGeneralModalOpen(true)} + > + + + )} + + + + ID + + {relay.id} + { + navigator.clipboard.writeText(relay.id); + setCopyTextId("Copied"); + }} + variant="ghost" + size="xs" + > + {isCopyingId ? : } + + + + + Host + {relay.host} + + + Health + + + + + + Last Heartbeat + + {relay.heartbeat ? ( + format(new Date(relay.heartbeat), "PPpp") + ) : ( + + )} + + + + Created + {format(new Date(relay.createdAt), "PPpp")} + + + + + + Authentication + {!isIdentityRelay && ( + + {(isAllowed) => ( + setAuthModalOpen(true)} + > + + + )} + + )} + + {isIdentityRelay && ( + + + Authenticated via Machine Identity (Legacy) + +

+ This relay is still using machine identity. We recommend creating a new relay. +

+ + Create a new relay + +
+
+ )} + {!isIdentityRelay && ( + + Method + + + + + )} + {authMethod.method === "aws" && ( + <> + + STS Endpoint + {authMethod.config.stsEndpoint} + + + Allowed Principal ARNs + + {authMethod.config.allowedPrincipalArns || ( + + )} + + + + Allowed Account IDs + + {authMethod.config.allowedAccountIds || ( + + )} + + + + )} + {authMethod.method === "identity" && authMethod.config.identityName && ( + + Machine Identity + {authMethod.config.identityName} + + )} +
+
+
+ + + + {!isIdentityRelay && ( + + )} + + ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayPageHeader/RelayPageHeader.tsx b/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayPageHeader/RelayPageHeader.tsx new file mode 100644 index 00000000000..0df41b3e1c0 --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/components/RelayPageHeader/RelayPageHeader.tsx @@ -0,0 +1,137 @@ +import { useNavigate } from "@tanstack/react-router"; +import { BanIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { DeleteActionModal, PageHeader } from "@app/components/v2"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/v3"; +import { + OrgPermissionSubjects, + OrgRelayPermissionActions +} from "@app/context/OrgPermissionContext/types"; +import { usePopUp } from "@app/hooks"; +import { useDeleteRelayById, useRevokeRelayAccess } from "@app/hooks/api/relays"; +import { TRelayWithAuthMethod } from "@app/hooks/api/relays/types"; + +export const RelayPageHeader = ({ + relay, + orgId +}: { + relay: TRelayWithAuthMethod; + orgId: string; +}) => { + const navigate = useNavigate(); + const { mutateAsync: deleteRelay } = useDeleteRelayById(); + const { mutateAsync: revokeRelay } = useRevokeRelayAccess(); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "deleteRelay", + "revokeRelay" + ] as const); + + const onDelete = async () => { + await deleteRelay(relay.id); + createNotification({ type: "success", text: "Successfully deleted relay" }); + navigate({ + to: "/organizations/$orgId/networking", + params: { orgId }, + search: { selectedTab: "relays" } + }); + }; + + const onRevoke = async () => { + try { + await revokeRelay({ relayId: relay.id }); + createNotification({ type: "success", text: "Relay access revoked" }); + handlePopUpToggle("revokeRelay", false); + } catch { + createNotification({ type: "error", text: "Failed to revoke relay access" }); + } + }; + + const { canRevoke } = relay; + + return ( + <> + + + + + + + { + navigator.clipboard.writeText(relay.id); + createNotification({ type: "info", text: "Relay ID copied to clipboard" }); + }} + > + + Copy Relay ID + + {canRevoke && ( + + {(isAllowed) => ( + handlePopUpOpen("revokeRelay")} + > + + Revoke Access + + )} + + )} + + {(isAllowed) => ( + handlePopUpOpen("deleteRelay")} + > + + Delete Relay + + )} + + + + + + handlePopUpToggle("deleteRelay", isOpen)} + deleteKey="confirm" + onDeleteApproved={onDelete} + /> + handlePopUpToggle("revokeRelay", isOpen)} + deleteKey="confirm" + buttonText="Revoke access" + onDeleteApproved={onRevoke} + /> + + ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/route.tsx b/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/route.tsx new file mode 100644 index 00000000000..5d3284c4b2d --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/RelayDetailsByIDPage/route.tsx @@ -0,0 +1,20 @@ +import { createFileRoute, linkOptions } from "@tanstack/react-router"; + +import { RelayDetailsByIDPage } from "./RelayDetailsByIDPage"; + +export const Route = createFileRoute( + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking/relays/$relayId" +)({ + component: RelayDetailsByIDPage, + context: ({ params }) => ({ + breadcrumbs: [ + { + label: "Networking", + link: linkOptions({ to: "/organizations/$orgId/networking" as const, params }) + }, + { + label: "Relays" + } + ] + }) +}); diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx index d90b542bcd9..d1b0acad9ad 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx @@ -37,6 +37,7 @@ import { } from "@app/components/v2"; import { DocumentationLinkBadge } from "@app/components/v3"; import { ROUTE_PATHS } from "@app/const/routes"; +import { useOrganization } from "@app/context"; import { OrgPermissionSubjects, OrgRelayPermissionActions @@ -49,16 +50,20 @@ import { RelayDeployModal } from "./components/RelayDeployModal"; const RelayHealthStatus = ({ heartbeat }: { heartbeat?: string }) => { const heartbeatDate = heartbeat ? new Date(heartbeat) : null; - const now = new Date(); - const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); - const isHealthy = !heartbeatDate || heartbeatDate >= oneHourAgo; - const tooltipContent = heartbeatDate - ? `Last heartbeat: ${heartbeatDate.toLocaleString()}` - : "No heartbeat data available"; + if (!heartbeatDate) { + return ( + + Unregistered + + ); + } + + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + const isHealthy = heartbeatDate >= oneHourAgo; return ( - + {isHealthy ? "Healthy" : "Unreachable"} @@ -70,6 +75,8 @@ export const RelayTab = withPermission( () => { const [search, setSearch] = useState(""); const { data: relays, isPending: isRelaysLoading } = useGetRelays(); + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ "deleteRelay", @@ -124,7 +131,7 @@ export const RelayTab = withPermission( leftIcon={} onClick={() => handlePopUpOpen("deployRelay")} > - Deploy Relay + Create Relay @@ -166,7 +173,18 @@ export const RelayTab = withPermission( )} {filteredRelays?.map((el) => ( - + { + if (el.orgId) { + navigate({ + to: "/organizations/$orgId/networking/relays/$relayId", + params: { orgId, relayId: el.id } + }); + } + }} + >
{el.name} diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliDeploymentMethod.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliDeploymentMethod.tsx deleted file mode 100644 index c7f3a4e66e2..00000000000 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliDeploymentMethod.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { useMemo, useState } from "react"; -import { SingleValue } from "react-select"; -import { faCopy, faQuestionCircle, faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { - Button, - Checkbox, - FilterableSelect, - FormLabel, - IconButton, - Input, - ModalClose, - Tooltip -} from "@app/components/v2"; -import { - OrgPermissionIdentityActions, - OrgPermissionSubjects, - useOrganization, - useOrgPermission -} from "@app/context"; -import { - useAddIdentityTokenAuth, - useCreateTokenIdentityTokenAuth, - useGetIdentityMembershipOrgs, - useGetIdentityTokenAuth -} from "@app/hooks/api"; -import { safeJWTSchema, slugSchema } from "@app/lib/schemas"; - -const baseFormSchema = z.object({ - name: slugSchema({ field: "name" }), - host: z.string().min(1, "Host is required") -}); - -const formSchemaWithIdentity = baseFormSchema.extend({ - identity: z - .object( - { - id: z.string(), - name: z.string() - }, - { required_error: "Machine identity is required" } - ) - .nullable() - .refine((val) => val !== null, { message: "Machine identity is required" }) -}); - -const formSchemaWithToken = baseFormSchema.extend({ - identityToken: safeJWTSchema -}); - -export const RelayCliDeploymentMethod = () => { - const { protocol, hostname, port } = window.location; - const portSuffix = port && port !== "80" ? `:${port}` : ""; - const siteURL = `${protocol}//${hostname}${portSuffix}`; - - const [autogenerateToken, setAutogenerateToken] = useState(true); - const [step, setStep] = useState<"form" | "command">("form"); - const [name, setName] = useState(""); - const [host, setHost] = useState(""); - - const [identity, setIdentity] = useState(null); - const [identityToken, setIdentityToken] = 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 { currentOrg } = useOrganization(); - const organizationId = currentOrg?.id || ""; - - const { permission } = useOrgPermission(); - const canCreateToken = permission.can( - OrgPermissionIdentityActions.CreateToken, - OrgPermissionSubjects.Identity - ); - - const { data: identityMembershipOrgsData, isPending: isIdentitiesLoading } = - useGetIdentityMembershipOrgs({ - organizationId, - limit: 20000 - }); - const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships || []; - - const { mutateAsync: createToken, isPending: isCreatingToken } = - useCreateTokenIdentityTokenAuth(); - const { mutateAsync: addIdentityTokenAuth, isPending: isAddingTokenAuth } = - useAddIdentityTokenAuth(); - const { refetch } = useGetIdentityTokenAuth(identity?.id ?? ""); - - const handleGenerateCommand = async () => { - setFormErrors([]); - - if (canCreateToken && autogenerateToken) { - const validation = formSchemaWithIdentity.safeParse({ name, host, identity }); - if (!validation.success) { - setFormErrors(validation.error.issues); - return; - } - - const validatedIdentity = validation.data.identity; - - try { - const { data: identityTokenAuth } = await refetch(); - if (!identityTokenAuth) { - await addIdentityTokenAuth({ - identityId: validatedIdentity.id, - organizationId, - accessTokenTTL: 2592000, - accessTokenMaxTTL: 2592000, - accessTokenNumUsesLimit: 0, - accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] - }); - createNotification({ - text: "Token authentication has been automatically enabled for the selected identity. By default, it is configured to allow all IP addresses with a default token TTL of 30 days. You can manage these settings in Access Control.", - type: "warning" - }); - } - - const token = await createToken({ - identityId: validatedIdentity.id, - name: `relay token for ${name} (autogenerated)` - }); - setIdentityToken(token.accessToken); - createNotification({ - text: "Automatically generated a token for the selected identity.", - type: "info" - }); - setStep("command"); - } catch { - setIdentityToken(""); - } - } else { - const validation = formSchemaWithToken.safeParse({ - name, - host, - identityToken - }); - if (!validation.success) { - setFormErrors(validation.error.issues); - return; - } - setStep("command"); - } - }; - - const command = useMemo(() => { - return `infisical relay start --name=${name} --domain=${siteURL} --host=${host} --token=${identityToken}`; - }, [name, siteURL, host, identityToken]); - - if (step === "command") { - return ( - <> - -
- - { - navigator.clipboard.writeText(command); - createNotification({ - text: "Command copied to clipboard", - type: "info" - }); - }} - className="w-10" - > - - -
- - Install the Infisical CLI - - -
- - - -
- - ); - } - - return ( - <> - - setName(e.target.value)} - placeholder="Enter relay name..." - isError={Boolean(errors.name)} - /> - {errors.name &&

{errors.name}

} - - - setHost(e.target.value)} - placeholder="0.0.0.0" - isError={Boolean(errors.host)} - /> - {errors.host &&

{errors.host}

} - - {canCreateToken && autogenerateToken ? ( - <> - - - setIdentity( - e as SingleValue<{ - id: string; - name: string; - }> - ) - } - isLoading={isIdentitiesLoading} - placeholder="Select machine identity..." - options={identityMembershipOrgs.map((membership) => membership.identity)} - getOptionValue={(option) => option.id} - getOptionLabel={(option) => option.name} - /> - {errors.identity &&

{errors.identity}

} - - ) : ( - <> - - setIdentityToken(e.target.value)} - placeholder="Enter machine identity token..." - isError={Boolean(errors.identityToken)} - /> - {errors.identityToken &&

{errors.identityToken}

} - - )} - - {canCreateToken && ( -
- { - setAutogenerateToken(Boolean(e)); - }} - id="autogenerate-token" - className="mr-2" - > -
- Automatically enable token auth and generate a token for machine identity - - Token authentication will be automatically enabled for the selected machine - identity if it isn't already configured. By default, it will be configured - to allow all IP addresses with a token TTL of 30 days. You can manage these - settings in Access Control. -
-
A token will automatically be generated to be used with the CLI command. - - } - > - -
-
-
-
- )} - -
- - - - -
- - ); -}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliSystemdDeploymentMethod.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliSystemdDeploymentMethod.tsx deleted file mode 100644 index c24638fe79c..00000000000 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliSystemdDeploymentMethod.tsx +++ /dev/null @@ -1,362 +0,0 @@ -import { useMemo, useState } from "react"; -import { SingleValue } from "react-select"; -import { faCopy, faQuestionCircle, faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { - Button, - Checkbox, - FilterableSelect, - FormLabel, - IconButton, - Input, - ModalClose, - Tooltip -} from "@app/components/v2"; -import { - OrgPermissionIdentityActions, - OrgPermissionSubjects, - useOrganization, - useOrgPermission -} from "@app/context"; -import { - useAddIdentityTokenAuth, - useCreateTokenIdentityTokenAuth, - useGetIdentityMembershipOrgs, - useGetIdentityTokenAuth -} from "@app/hooks/api"; -import { safeJWTSchema, slugSchema } from "@app/lib/schemas"; - -const baseFormSchema = z.object({ - name: slugSchema({ field: "name" }), - host: z.string().min(1, "Host is required") -}); - -const formSchemaWithIdentity = baseFormSchema.extend({ - identity: z - .object( - { - id: z.string(), - name: z.string() - }, - { required_error: "Machine identity is required" } - ) - .nullable() - .refine((val) => val !== null, { message: "Machine identity is required" }) -}); - -const formSchemaWithToken = baseFormSchema.extend({ - identityToken: safeJWTSchema -}); - -export const RelayCliSystemdDeploymentMethod = () => { - const { protocol, hostname, port } = window.location; - const portSuffix = port && port !== "80" ? `:${port}` : ""; - const siteURL = `${protocol}//${hostname}${portSuffix}`; - - const [autogenerateToken, setAutogenerateToken] = useState(true); - const [step, setStep] = useState<"form" | "command">("form"); - const [name, setName] = useState(""); - const [host, setHost] = useState(""); - - const [identity, setIdentity] = useState(null); - const [identityToken, setIdentityToken] = 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 { currentOrg } = useOrganization(); - const organizationId = currentOrg?.id || ""; - - const { permission } = useOrgPermission(); - const canCreateToken = permission.can( - OrgPermissionIdentityActions.CreateToken, - OrgPermissionSubjects.Identity - ); - - const { data: identityMembershipOrgsData, isPending: isIdentitiesLoading } = - useGetIdentityMembershipOrgs({ - organizationId, - limit: 20000 - }); - const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships || []; - - const { mutateAsync: createToken, isPending: isCreatingToken } = - useCreateTokenIdentityTokenAuth(); - const { mutateAsync: addIdentityTokenAuth, isPending: isAddingTokenAuth } = - useAddIdentityTokenAuth(); - const { refetch } = useGetIdentityTokenAuth(identity?.id ?? ""); - - const handleGenerateCommand = async () => { - setFormErrors([]); - - if (canCreateToken && autogenerateToken) { - const validation = formSchemaWithIdentity.safeParse({ name, host, identity }); - if (!validation.success) { - setFormErrors(validation.error.issues); - return; - } - - const validatedIdentity = validation.data.identity; - - try { - const { data: identityTokenAuth } = await refetch(); - if (!identityTokenAuth) { - await addIdentityTokenAuth({ - identityId: validatedIdentity.id, - organizationId, - accessTokenTTL: 2592000, - accessTokenMaxTTL: 2592000, - accessTokenNumUsesLimit: 0, - accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] - }); - createNotification({ - text: "Token authentication has been automatically enabled for the selected identity. By default, it is configured to allow all IP addresses with a default token TTL of 30 days. You can manage these settings in Access Control.", - type: "warning" - }); - } - - const token = await createToken({ - identityId: validatedIdentity.id, - name: `relay token for ${name} (autogenerated)` - }); - setIdentityToken(token.accessToken); - createNotification({ - text: "Automatically generated a token for the selected identity.", - type: "info" - }); - setStep("command"); - } catch { - setIdentityToken(""); - } - } else { - const validation = formSchemaWithToken.safeParse({ - name, - host, - identityToken - }); - if (!validation.success) { - setFormErrors(validation.error.issues); - return; - } - setStep("command"); - } - }; - - const installCommand = useMemo(() => { - return `sudo infisical relay systemd install --name=${name} --domain=${siteURL} --host=${host} --token=${identityToken}`; - }, [name, siteURL, host, identityToken]); - - const startServiceCommand = "sudo systemctl start infisical-relay"; - const enableServiceCommand = "sudo systemctl enable infisical-relay"; - - if (step === "command") { - return ( - <> - -
- - { - navigator.clipboard.writeText(installCommand); - createNotification({ - text: "Installation command copied to clipboard", - type: "info" - }); - }} - className="w-10" - > - - -
- - -
- - { - navigator.clipboard.writeText(startServiceCommand); - createNotification({ - text: "Start service command copied to clipboard", - type: "info" - }); - }} - className="w-10" - > - - -
-
- - { - navigator.clipboard.writeText(enableServiceCommand); - createNotification({ - text: "Enable service command copied to clipboard", - type: "info" - }); - }} - className="w-10" - > - - -
- - Install the Infisical CLI - - -
- - - -
- - ); - } - - return ( - <> - - setName(e.target.value)} - placeholder="Enter relay name..." - isError={Boolean(errors.name)} - /> - {errors.name &&

{errors.name}

} - - - setHost(e.target.value)} - placeholder="0.0.0.0" - isError={Boolean(errors.host)} - /> - {errors.host &&

{errors.host}

} - - {canCreateToken && autogenerateToken ? ( - <> - - - setIdentity( - e as SingleValue<{ - id: string; - name: string; - }> - ) - } - isLoading={isIdentitiesLoading} - placeholder="Select machine identity..." - options={identityMembershipOrgs.map((membership) => membership.identity)} - getOptionValue={(option) => option.id} - getOptionLabel={(option) => option.name} - /> - {errors.identity &&

{errors.identity}

} - - ) : ( - <> - - setIdentityToken(e.target.value)} - placeholder="Enter machine identity token..." - isError={Boolean(errors.identityToken)} - /> - {errors.identityToken &&

{errors.identityToken}

} - - )} - - {canCreateToken && ( -
- { - setAutogenerateToken(Boolean(e)); - }} - id="autogenerate-token" - className="mr-2" - > -
- Automatically enable token auth and generate a token for machine identity - - Token authentication will be automatically enabled for the selected machine - identity if it isn't already configured. By default, it will be configured - to allow all IP addresses with a token TTL of 30 days. You can manage these - settings in Access Control. -
-
A token will automatically be generated to be used with the CLI command. - - } - > - -
-
-
-
- )} - -
- - - - -
- - ); -}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal.tsx index ab4a58a32fb..9fa8e76dd59 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal.tsx @@ -1,52 +1,104 @@ import { useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; -import { Modal, ModalContent } from "@app/components/v2"; -import { RelayDeploymentMethodSelect } from "@app/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeploymentMethodSelect"; - -import { RelayCliDeploymentMethod } from "./RelayCliDeploymentMethod"; -import { RelayCliSystemdDeploymentMethod } from "./RelayCliSystemdDeploymentMethod"; -import { RelayTerraformDeploymentMethod } from "./RelayTerraformDeploymentMethod"; +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalClose, ModalContent } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useCreateRelay } from "@app/hooks/api/relays"; type Props = { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; }; -export const RelayDeploymentInfoMap = { - cli: { name: "CLI", image: "SSH.png", component: RelayCliDeploymentMethod }, - systemd: { name: "CLI (systemd)", image: "SSH.png", component: RelayCliSystemdDeploymentMethod }, - terraform: { - name: "Terraform", - image: "Terraform.png", - component: RelayTerraformDeploymentMethod - } -} as const; - -export type RelayDeploymentMethod = keyof typeof RelayDeploymentInfoMap; - -const Content = () => { - const [selectedMethod, setSelectedMethod] = useState(null); - - if (selectedMethod) { - const ComponentToRender = RelayDeploymentInfoMap[selectedMethod]?.component; - if (ComponentToRender) { - return ; +export const RelayDeployModal = ({ isOpen, onOpenChange }: Props) => { + const [name, setName] = useState(""); + const [host, setHost] = useState(""); + const [formErrors, setFormErrors] = useState([]); + + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const navigate = useNavigate(); + const { mutateAsync: createRelay, isPending: isCreating } = useCreateRelay(); + + const handleCreate = async () => { + const errors: string[] = []; + if (!name.trim()) errors.push("Name is required"); + if (!host.trim()) errors.push("Host is required"); + if (errors.length) { + setFormErrors(errors); + return; } - } + setFormErrors([]); - return ; -}; + try { + const relay = await createRelay({ + name: name.trim(), + host: host.trim(), + authMethod: { method: "token" } + }); + + onOpenChange(false); + navigate({ + to: "/organizations/$orgId/networking/relays/$relayId", + params: { orgId, relayId: relay.id } + }); + } catch (err: any) { + createNotification({ + type: "error", + text: err?.message || "Failed to create relay" + }); + } + }; + + const handleClose = (open: boolean) => { + if (!open) { + setName(""); + setHost(""); + setFormErrors([]); + } + onOpenChange(open); + }; -export const RelayDeployModal = ({ isOpen, onOpenChange }: Props) => { return ( - + - +
+ {formErrors.length > 0 && ( +
+ {formErrors.map((e) => ( +

+ {e} +

+ ))} +
+ )} + + setName(e.target.value)} placeholder="my-relay" /> + + + setHost(e.target.value)} + placeholder="10.0.0.5 or relay.example.com" + /> + +
+ + + + +
+
); diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeploymentMethodSelect.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeploymentMethodSelect.tsx deleted file mode 100644 index cf7861a8bf0..00000000000 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeploymentMethodSelect.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useMemo } from "react"; - -import { - RelayDeploymentInfoMap, - RelayDeploymentMethod -} from "@app/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal"; - -type Props = { - onSelect: (method: RelayDeploymentMethod) => void; -}; - -export const RelayDeploymentMethodSelect = ({ onSelect }: Props) => { - const deploymentOptions = useMemo( - () => - (Object.keys(RelayDeploymentInfoMap) as RelayDeploymentMethod[]).map((method) => ({ - method, - name: RelayDeploymentInfoMap[method].name, - image: RelayDeploymentInfoMap[method].image - })), - [] - ); - - const handleResourceSelect = (method: RelayDeploymentMethod) => { - onSelect(method); - }; - - return ( -
- {deploymentOptions.map((option) => { - const { image, name, method } = option; - - return ( - - ); - })} -
- ); -}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayTerraformDeploymentMethod.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayTerraformDeploymentMethod.tsx deleted file mode 100644 index ed555c78802..00000000000 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayTerraformDeploymentMethod.tsx +++ /dev/null @@ -1,500 +0,0 @@ -import { useMemo, useState } from "react"; -import { SingleValue } from "react-select"; -import { faCopy, faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Tab } from "@headlessui/react"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { - Button, - Checkbox, - FilterableSelect, - FormLabel, - IconButton, - Input, - ModalClose, - Tooltip -} from "@app/components/v2"; -import { - OrgPermissionIdentityActions, - OrgPermissionSubjects, - useOrganization, - useOrgPermission -} from "@app/context"; -import { AWS_REGIONS } from "@app/helpers/appConnections"; -import { - useAddIdentityTokenAuth, - useCreateTokenIdentityTokenAuth, - useGetIdentityMembershipOrgs, - useGetIdentityTokenAuth -} from "@app/hooks/api"; -import { safeJWTSchema, slugSchema } from "@app/lib/schemas"; - -const baseFormSchema = z.object({ - name: slugSchema({ field: "name" }) -}); - -const formSchemaWithIdentity = baseFormSchema.extend({ - identity: z - .object( - { - id: z.string(), - name: z.string() - }, - { required_error: "Machine identity is required" } - ) - .nullable() - .refine((val) => val !== null, { message: "Machine identity is required" }) -}); - -const formSchemaWithToken = baseFormSchema.extend({ - identityToken: safeJWTSchema -}); - -const ec2FormSchema = z.object({ - awsRegion: z.string().min(1, "AWS Region is required"), - vpcId: z.string().min(1, "VPC ID is required"), - ami: z.string().min(1, "AMI ID is required"), - subnetId: z.string().min(1, "Subnet ID is required") -}); - -export const RelayTerraformDeploymentMethod = () => { - const { protocol, hostname, port } = window.location; - const portSuffix = port && port !== "80" ? `:${port}` : ""; - const siteURL = `${protocol}//${hostname}${portSuffix}`; - - const [selectedTabIndex, setSelectedTabIndex] = useState(0); - - const [autogenerateToken, setAutogenerateToken] = useState(true); - const [step, setStep] = useState<"form" | "command">("form"); - const [name, setName] = useState(""); - - const [identity, setIdentity] = useState(null); - const [identityToken, setIdentityToken] = useState(""); - const [formErrors, setFormErrors] = useState([]); - - const [awsRegion, setAwsRegion] = useState("us-east-1"); - const [vpcId, setVpcId] = useState(""); - const [ami, setAmi] = useState("ami-01b2110eef525172b"); - const [subnetId, setSubnetId] = 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 { currentOrg } = useOrganization(); - const organizationId = currentOrg?.id || ""; - - const { permission } = useOrgPermission(); - const canCreateToken = permission.can( - OrgPermissionIdentityActions.CreateToken, - OrgPermissionSubjects.Identity - ); - - const { data: identityMembershipOrgsData, isPending: isIdentitiesLoading } = - useGetIdentityMembershipOrgs({ - organizationId, - limit: 20000 - }); - const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships || []; - - const { mutateAsync: createToken, isPending: isCreatingToken } = - useCreateTokenIdentityTokenAuth(); - const { mutateAsync: addIdentityTokenAuth, isPending: isAddingTokenAuth } = - useAddIdentityTokenAuth(); - const { refetch } = useGetIdentityTokenAuth(identity?.id ?? ""); - - const handleGenerateCommand = async () => { - setFormErrors([]); - - if (canCreateToken && autogenerateToken) { - const validation = formSchemaWithIdentity.safeParse({ name, identity }); - if (!validation.success) { - setFormErrors(validation.error.issues); - return; - } - - if (selectedTabIndex === 0) { - const ec2Validation = ec2FormSchema.safeParse({ awsRegion, vpcId, ami, subnetId }); - if (!ec2Validation.success) { - setFormErrors(ec2Validation.error.issues); - return; - } - } - - const validatedIdentity = validation.data.identity; - - try { - const { data: identityTokenAuth } = await refetch(); - if (!identityTokenAuth) { - await addIdentityTokenAuth({ - identityId: validatedIdentity.id, - organizationId, - accessTokenTTL: 2592000, - accessTokenMaxTTL: 2592000, - accessTokenNumUsesLimit: 0, - accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] - }); - createNotification({ - text: "Token authentication has been automatically enabled for the selected identity. By default, it is configured to allow all IP addresses with a default token TTL of 30 days. You can manage these settings in Access Control.", - type: "warning" - }); - } - - const token = await createToken({ - identityId: validatedIdentity.id, - name: `relay token for ${name} (autogenerated)` - }); - setIdentityToken(token.accessToken); - createNotification({ - text: "Automatically generated a token for the selected identity.", - type: "info" - }); - setStep("command"); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to generate token for the selected identity", - type: "error" - }); - setIdentityToken(""); - } - } else { - const validation = formSchemaWithToken.safeParse({ - name, - identityToken - }); - if (!validation.success) { - setFormErrors(validation.error.issues); - return; - } - - if (selectedTabIndex === 0) { - const ec2Validation = ec2FormSchema.safeParse({ awsRegion, vpcId, ami, subnetId }); - if (!ec2Validation.success) { - setFormErrors(ec2Validation.error.issues); - return; - } - } - setStep("command"); - } - }; - - const terraformCommand = useMemo(() => { - return `terraform { - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.0" - } - } -} - -provider "aws" { - region = "${awsRegion}" -} - -# Security Group for the Infisical Relay instance -resource "aws_security_group" "infisical_relay_sg" { - name = "${name}-relay-sg" - description = "Allows inbound traffic for Infisical Relay and SSH" - vpc_id = "${vpcId}" - - # Inbound: Allows the Infisical platform to securely communicate with the Relay server. - ingress { - from_port = 8443 - to_port = 8443 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - } - - # Inbound: Allows Infisical Gateway to securely communicate via the Relay. - ingress { - from_port = 2222 - to_port = 2222 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - } - - # Inbound: Allows secure shell (SSH) access for administration. - ingress { - from_port = 22 - to_port = 22 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] # Restrict this to your IP in production - } - - # Outbound: Allows the Relay server to make necessary outbound connections to the Infisical platform. - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - } - - tags = { - Name = "${name}-relay-sg" - } -} - -# Elastic IP for a static public IP address -resource "aws_eip" "infisical_relay_eip" { - tags = { - Name = "${name}-relay-eip" - } -} - -# EC2 instance to run Infisical Relay -module "infisical_relay_instance" { - source = "terraform-aws-modules/ec2-instance/aws" - version = "~> 5.6" - - name = "${name}-relay-instance" - ami = "${ami}" - instance_type = "t3.micro" - subnet_id = "${subnetId}" - - vpc_security_group_ids = [aws_security_group.infisical_relay_sg.id] - associate_public_ip_address = false # We are using an Elastic IP instead - - user_data = <<-EOT - #!/bin/bash - set -e - # Install Infisical CLI - curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash - apt-get update && apt-get install -y infisical - - # Install the relay as a systemd service. - # This example uses a Machine Identity token for authentication via the INFISICAL_TOKEN environment variable. - # - # Note: For production environments, you might consider fetching the token from AWS Parameter Store or AWS Secrets Manager. - export INFISICAL_TOKEN="${identityToken}" - sudo -E infisical relay systemd install \\ - --name "${name}" \\ - --domain "${siteURL}" \\ - --host "\${aws_eip.infisical_relay_eip.public_ip}" - - # Start and enable the service to run on boot - sudo systemctl start infisical-relay - sudo systemctl enable infisical-relay - EOT -} - -# Associate the Elastic IP with the EC2 instance -resource "aws_eip_association" "eip_assoc" { - instance_id = module.infisical_relay_instance.id - allocation_id = aws_eip.infisical_relay_eip.id -} -`; - }, [name, siteURL, identityToken, awsRegion, vpcId, ami, subnetId]); - - if (step === "command") { - return ( - <> -
- Terraform Configuration - { - navigator.clipboard.writeText(terraformCommand); - createNotification({ - text: "Terraform configuration copied to clipboard", - type: "info" - }); - }} - className="w-10" - > - - -
-
-
-            {terraformCommand}
-          
-
-
- - - -
- - ); - } - - return ( - <> - - setName(e.target.value)} - placeholder="Enter relay name..." - isError={Boolean(errors.name)} - /> - {errors.name &&

{errors.name}

} - - {canCreateToken && autogenerateToken ? ( - <> - - - setIdentity( - e as SingleValue<{ - id: string; - name: string; - }> - ) - } - isLoading={isIdentitiesLoading} - placeholder="Select machine identity..." - options={identityMembershipOrgs.map((membership) => membership.identity)} - getOptionValue={(option) => option.id} - getOptionLabel={(option) => option.name} - /> - {errors.identity &&

{errors.identity}

} - - ) : ( - <> - - setIdentityToken(e.target.value)} - placeholder="Enter machine identity token..." - isError={Boolean(errors.identityToken)} - /> - {errors.identityToken &&

{errors.identityToken}

} - - )} - - {canCreateToken && ( -
- { - setAutogenerateToken(Boolean(e)); - }} - id="autogenerate-token" - className="mr-2" - > -
- Automatically enable token auth and generate a token for machine identity - - Token authentication will be automatically enabled for the selected machine - identity if it isn't already configured. By default, it will be configured - to allow all IP addresses with a token TTL of 30 days. You can manage these - settings in Access Control. -
-
A token will automatically be generated to be used with the CLI command. - - } - > - -
-
-
-
- )} - - - - - `-mb-[0.14rem] px-4 py-2 text-sm font-medium whitespace-nowrap outline-hidden disabled:opacity-60 ${ - selected ? "border-b-2 border-mineshaft-300 text-mineshaft-200" : "text-bunker-300" - }` - } - > - EC2 - - - - - - r.slug === awsRegion)} - onChange={(selected) => { - if (selected) { - setAwsRegion((selected as SingleValue<{ slug: string; name: string }>)!.slug); - } - }} - options={AWS_REGIONS} - getOptionLabel={(option) => option.name} - getOptionValue={(option) => option.slug} - /> - {errors.awsRegion &&

{errors.awsRegion}

} - - setVpcId(e.target.value)} - placeholder="vpc-..." - isError={Boolean(errors.vpcId)} - /> - {errors.vpcId &&

{errors.vpcId}

} - - setAmi(e.target.value)} - placeholder="ami-..." - isError={Boolean(errors.ami)} - /> - {errors.ami &&

{errors.ami}

} - - setSubnetId(e.target.value)} - placeholder="subnet-..." - isError={Boolean(errors.subnetId)} - /> - {errors.subnetId &&

{errors.subnetId}

} -
-
-
- -
- - - - -
- - ); -}; diff --git a/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts b/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts index 1504365be7a..792654e13b1 100644 --- a/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts @@ -128,7 +128,8 @@ const orgRelayPermissionSchema = z [OrgRelayPermissionActions.ListRelays]: z.boolean().optional(), [OrgRelayPermissionActions.EditRelays]: z.boolean().optional(), [OrgRelayPermissionActions.DeleteRelays]: z.boolean().optional(), - [OrgRelayPermissionActions.CreateRelays]: z.boolean().optional() + [OrgRelayPermissionActions.CreateRelays]: z.boolean().optional(), + [OrgRelayPermissionActions.RevokeRelayAccess]: z.boolean().optional() }) .optional(); diff --git a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionRelayRow.tsx b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionRelayRow.tsx index 238a03ff5d1..2e3df0850a5 100644 --- a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionRelayRow.tsx +++ b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionRelayRow.tsx @@ -27,7 +27,8 @@ const PERMISSION_ACTIONS = [ { action: OrgRelayPermissionActions.ListRelays, label: "List Relays" }, { action: OrgRelayPermissionActions.CreateRelays, label: "Create Relays" }, { action: OrgRelayPermissionActions.EditRelays, label: "Edit Relays" }, - { action: OrgRelayPermissionActions.DeleteRelays, label: "Delete Relays" } + { action: OrgRelayPermissionActions.DeleteRelays, label: "Delete Relays" }, + { action: OrgRelayPermissionActions.RevokeRelayAccess, label: "Revoke Relay Access" } ] as const; export const OrgRelayPermissionRow = ({ isEditable, control, setValue }: Props) => { @@ -81,7 +82,8 @@ export const OrgRelayPermissionRow = ({ isEditable, control, setValue }: Props) [OrgRelayPermissionActions.ListRelays]: true, [OrgRelayPermissionActions.EditRelays]: true, [OrgRelayPermissionActions.CreateRelays]: true, - [OrgRelayPermissionActions.DeleteRelays]: true + [OrgRelayPermissionActions.DeleteRelays]: true, + [OrgRelayPermissionActions.RevokeRelayAccess]: true }, { shouldDirty: true } ); @@ -93,7 +95,8 @@ export const OrgRelayPermissionRow = ({ isEditable, control, setValue }: Props) [OrgRelayPermissionActions.ListRelays]: true, [OrgRelayPermissionActions.EditRelays]: false, [OrgRelayPermissionActions.CreateRelays]: false, - [OrgRelayPermissionActions.DeleteRelays]: false + [OrgRelayPermissionActions.DeleteRelays]: false, + [OrgRelayPermissionActions.RevokeRelayAccess]: false }, { shouldDirty: true } ); @@ -107,7 +110,8 @@ export const OrgRelayPermissionRow = ({ isEditable, control, setValue }: Props) [OrgRelayPermissionActions.ListRelays]: false, [OrgRelayPermissionActions.EditRelays]: false, [OrgRelayPermissionActions.CreateRelays]: false, - [OrgRelayPermissionActions.DeleteRelays]: false + [OrgRelayPermissionActions.DeleteRelays]: false, + [OrgRelayPermissionActions.RevokeRelayAccess]: false }, { shouldDirty: true } ); diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 048a1ff700e..13a98b31539 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -75,6 +75,7 @@ import { Route as organizationNetworkingPageRouteImport } from './pages/organiza import { Route as organizationAppConnectionsAppConnectionsPageRouteImport } from './pages/organization/AppConnections/AppConnectionsPage/route' import { Route as secretManagerRedirectsRedirectApprovalPageImport } from './pages/secret-manager/redirects/redirect-approval-page' import { Route as organizationSettingsPageOauthCallbackPageRouteImport } from './pages/organization/SettingsPage/OauthCallbackPage/route' +import { Route as organizationNetworkingPageRelayDetailsByIDPageRouteImport } from './pages/organization/NetworkingPage/RelayDetailsByIDPage/route' import { Route as organizationNetworkingPageGatewayDetailsByIDPageRouteImport } from './pages/organization/NetworkingPage/GatewayDetailsByIDPage/route' import { Route as sshLayoutImport } from './pages/ssh/layout' import { Route as secretScanningLayoutImport } from './pages/secret-scanning/layout' @@ -1017,6 +1018,14 @@ const organizationSettingsPageOauthCallbackPageRouteRoute = AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdSettingsRoute, } as any) +const organizationNetworkingPageRelayDetailsByIDPageRouteRoute = + organizationNetworkingPageRelayDetailsByIDPageRouteImport.update({ + id: '/relays/$relayId', + path: '/relays/$relayId', + getParentRoute: () => + AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdNetworkingRoute, + } as any) + const organizationNetworkingPageGatewayDetailsByIDPageRouteRoute = organizationNetworkingPageGatewayDetailsByIDPageRouteImport.update({ id: '/gateways/$gatewayId', @@ -3199,6 +3208,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof organizationNetworkingPageGatewayDetailsByIDPageRouteImport parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdNetworkingImport } + '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking/relays/$relayId': { + id: '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking/relays/$relayId' + path: '/relays/$relayId' + fullPath: '/organizations/$orgId/networking/relays/$relayId' + preLoaderRoute: typeof organizationNetworkingPageRelayDetailsByIDPageRouteImport + parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdNetworkingImport + } '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/settings/oauth/callback': { id: '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/settings/oauth/callback' path: '/oauth/callback' @@ -5874,6 +5890,7 @@ const AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdAppConnectionsRoute interface AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdNetworkingRouteChildren { organizationNetworkingPageRouteRoute: typeof organizationNetworkingPageRouteRoute organizationNetworkingPageGatewayDetailsByIDPageRouteRoute: typeof organizationNetworkingPageGatewayDetailsByIDPageRouteRoute + organizationNetworkingPageRelayDetailsByIDPageRouteRoute: typeof organizationNetworkingPageRelayDetailsByIDPageRouteRoute } const AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdNetworkingRouteChildren: AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdNetworkingRouteChildren = @@ -5881,6 +5898,8 @@ const AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdNetworkingRouteChil organizationNetworkingPageRouteRoute: organizationNetworkingPageRouteRoute, organizationNetworkingPageGatewayDetailsByIDPageRouteRoute: organizationNetworkingPageGatewayDetailsByIDPageRouteRoute, + organizationNetworkingPageRelayDetailsByIDPageRouteRoute: + organizationNetworkingPageRelayDetailsByIDPageRouteRoute, } const AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdNetworkingRouteWithChildren = @@ -6248,6 +6267,7 @@ export interface FileRoutesByFullPath { '/organizations/$orgId/secret-manager/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdSecretManagerProjectIdRouteWithChildren '/organization/app-connections/$appConnection/oauth/callback': typeof redirectsOauthCallbackRedirectRoute '/organizations/$orgId/networking/gateways/$gatewayId': typeof organizationNetworkingPageGatewayDetailsByIDPageRouteRoute + '/organizations/$orgId/networking/relays/$relayId': typeof organizationNetworkingPageRelayDetailsByIDPageRouteRoute '/organizations/$orgId/settings/oauth/callback': typeof organizationSettingsPageOauthCallbackPageRouteRoute '/organizations/$orgId/projects/ai/$projectId': typeof aiLayoutRouteWithChildren '/organizations/$orgId/projects/cert-manager/$projectId': typeof certManagerLayoutRouteWithChildren @@ -6530,6 +6550,7 @@ export interface FileRoutesByTo { '/organizations/$orgId/secret-manager/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdSecretManagerProjectIdRouteWithChildren '/organization/app-connections/$appConnection/oauth/callback': typeof redirectsOauthCallbackRedirectRoute '/organizations/$orgId/networking/gateways/$gatewayId': typeof organizationNetworkingPageGatewayDetailsByIDPageRouteRoute + '/organizations/$orgId/networking/relays/$relayId': typeof organizationNetworkingPageRelayDetailsByIDPageRouteRoute '/organizations/$orgId/settings/oauth/callback': typeof organizationSettingsPageOauthCallbackPageRouteRoute '/organizations/$orgId/projects/ai/$projectId': typeof aiLayoutRouteWithChildren '/organizations/$orgId/projects/cert-manager/$projectId': typeof certManagerDashboardPageRouteIndexRoute @@ -6805,6 +6826,7 @@ export interface FileRoutesById { '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/secret-manager/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdSecretManagerProjectIdRouteWithChildren '/_authenticate/_inject-org-details/organization/app-connections/$appConnection/oauth/callback': typeof redirectsOauthCallbackRedirectRoute '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking/gateways/$gatewayId': typeof organizationNetworkingPageGatewayDetailsByIDPageRouteRoute + '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking/relays/$relayId': typeof organizationNetworkingPageRelayDetailsByIDPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/settings/oauth/callback': typeof organizationSettingsPageOauthCallbackPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdProjectsAiProjectIdRouteWithChildren '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdProjectsCertManagerProjectIdRouteWithChildren @@ -7104,6 +7126,7 @@ export interface FileRouteTypes { | '/organizations/$orgId/secret-manager/$projectId' | '/organization/app-connections/$appConnection/oauth/callback' | '/organizations/$orgId/networking/gateways/$gatewayId' + | '/organizations/$orgId/networking/relays/$relayId' | '/organizations/$orgId/settings/oauth/callback' | '/organizations/$orgId/projects/ai/$projectId' | '/organizations/$orgId/projects/cert-manager/$projectId' @@ -7385,6 +7408,7 @@ export interface FileRouteTypes { | '/organizations/$orgId/secret-manager/$projectId' | '/organization/app-connections/$appConnection/oauth/callback' | '/organizations/$orgId/networking/gateways/$gatewayId' + | '/organizations/$orgId/networking/relays/$relayId' | '/organizations/$orgId/settings/oauth/callback' | '/organizations/$orgId/projects/ai/$projectId' | '/organizations/$orgId/projects/cert-manager/$projectId' @@ -7658,6 +7682,7 @@ export interface FileRouteTypes { | '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/secret-manager/$projectId' | '/_authenticate/_inject-org-details/organization/app-connections/$appConnection/oauth/callback' | '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking/gateways/$gatewayId' + | '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking/relays/$relayId' | '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/settings/oauth/callback' | '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId' | '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId' @@ -8218,7 +8243,8 @@ export const routeTree = rootRoute "parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId", "children": [ "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking/", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking/gateways/$gatewayId" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking/gateways/$gatewayId", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking/relays/$relayId" ] }, "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/secret-sharing": { @@ -8323,6 +8349,10 @@ export const routeTree = rootRoute "filePath": "organization/NetworkingPage/GatewayDetailsByIDPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking" }, + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking/relays/$relayId": { + "filePath": "organization/NetworkingPage/RelayDetailsByIDPage/route.tsx", + "parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking" + }, "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/settings/oauth/callback": { "filePath": "organization/SettingsPage/OauthCallbackPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/settings" diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 01b3a5ed162..9392fa3e4f1 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -456,7 +456,8 @@ const organizationRoutes = route("/organizations/$orgId", [ ]), route("/networking", [ index("organization/NetworkingPage/route.tsx"), - route("/gateways/$gatewayId", "organization/NetworkingPage/GatewayDetailsByIDPage/route.tsx") + route("/gateways/$gatewayId", "organization/NetworkingPage/GatewayDetailsByIDPage/route.tsx"), + route("/relays/$relayId", "organization/NetworkingPage/RelayDetailsByIDPage/route.tsx") ]) ]);