From 88176daf471327dd157d9617eb43ce16b19d1231 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 19 Sep 2025 01:52:33 +0800 Subject: [PATCH] feat: add support for org relay registration --- backend/src/ee/routes/v1/relay-router.ts | 55 ++++- .../ee/services/permission/org-permission.ts | 24 ++ .../src/ee/services/relay/relay-service.ts | 106 ++++++++- backend/src/server/routes/index.ts | 4 +- .../src/context/OrgPermissionContext/types.ts | 9 + frontend/src/hooks/api/relays/index.tsx | 3 + frontend/src/hooks/api/relays/mutations.tsx | 17 ++ frontend/src/hooks/api/relays/queries.tsx | 21 ++ frontend/src/hooks/api/relays/types.ts | 13 ++ .../components/OrgSidebar/OrgSidebar.tsx | 13 ++ .../Relay/RelayListPage/RelayListPage.tsx | 214 ++++++++++++++++++ .../Gateways/Relay/RelayListPage/route.tsx | 16 ++ .../components/OrgRoleModifySection.utils.ts | 13 +- .../OrgPermissionRelayRow.tsx | 180 +++++++++++++++ .../RolePermissionRow.tsx | 1 + .../RolePermissionsSection.tsx | 6 + frontend/src/routeTree.gen.ts | 75 ++++++ frontend/src/routes.ts | 3 +- 18 files changed, 760 insertions(+), 13 deletions(-) create mode 100644 frontend/src/hooks/api/relays/index.tsx create mode 100644 frontend/src/hooks/api/relays/mutations.tsx create mode 100644 frontend/src/hooks/api/relays/queries.tsx create mode 100644 frontend/src/hooks/api/relays/types.ts create mode 100644 frontend/src/pages/organization/Gateways/Relay/RelayListPage/RelayListPage.tsx create mode 100644 frontend/src/pages/organization/Gateways/Relay/RelayListPage/route.tsx create mode 100644 frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionRelayRow.tsx diff --git a/backend/src/ee/routes/v1/relay-router.ts b/backend/src/ee/routes/v1/relay-router.ts index e20480088..0737eeb56 100644 --- a/backend/src/ee/routes/v1/relay-router.ts +++ b/backend/src/ee/routes/v1/relay-router.ts @@ -1,8 +1,9 @@ import { z } from "zod"; +import { RelaysSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { UnauthorizedError } from "@app/lib/errors"; import { writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -89,14 +90,56 @@ export const registerRelayRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - throw new BadRequestError({ - message: "Org relay registration is not yet supported" - }); - return server.services.relay.registerRelay({ ...req.body, identityId: req.permission.id, - orgId: req.permission.orgId + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + } + }); + + server.route({ + method: "GET", + url: "/", + schema: { + response: { + 200: RelaysSchema.array() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + return server.services.relay.getRelays({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string() + }), + response: { + 200: RelaysSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + return server.services.relay.deleteRelay({ + id: req.params.id, + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId }); } }); diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 89a518032..0548911e8 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -58,6 +58,13 @@ export enum OrgPermissionGatewayActions { AttachGateways = "attach-gateways" } +export enum OrgPermissionRelayActions { + CreateRelays = "create-relays", + ListRelays = "list-relays", + EditRelays = "edit-relays", + DeleteRelays = "delete-relays" +} + export enum OrgPermissionIdentityActions { Read = "read", Create = "create", @@ -109,6 +116,7 @@ export enum OrgPermissionSubjects { AppConnections = "app-connections", Kmip = "kmip", Gateway = "gateway", + Relay = "relay", SecretShare = "secret-share" } @@ -136,6 +144,7 @@ export type OrgPermissionSet = | [OrgPermissionAuditLogsActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionGatewayActions, OrgPermissionSubjects.Gateway] + | [OrgPermissionRelayActions, OrgPermissionSubjects.Relay] | [ OrgPermissionAppConnectionActions, ( @@ -279,6 +288,12 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionGatewayActions).describe( "Describe what action an entity can take." ) + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.Relay).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionRelayActions).describe( + "Describe what action an entity can take." + ) }) ]); @@ -383,6 +398,11 @@ const buildAdminPermission = () => { can(OrgPermissionGatewayActions.DeleteGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway); + can(OrgPermissionRelayActions.ListRelays, OrgPermissionSubjects.Relay); + can(OrgPermissionRelayActions.CreateRelays, OrgPermissionSubjects.Relay); + can(OrgPermissionRelayActions.EditRelays, OrgPermissionSubjects.Relay); + can(OrgPermissionRelayActions.DeleteRelays, OrgPermissionSubjects.Relay); + can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); can(OrgPermissionKmipActions.Setup, OrgPermissionSubjects.Kmip); @@ -445,6 +465,10 @@ const buildMemberPermission = () => { can(OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway); + can(OrgPermissionRelayActions.ListRelays, OrgPermissionSubjects.Relay); + can(OrgPermissionRelayActions.CreateRelays, OrgPermissionSubjects.Relay); + can(OrgPermissionRelayActions.EditRelays, OrgPermissionSubjects.Relay); + can(OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate); can( OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates, diff --git a/backend/src/ee/services/relay/relay-service.ts b/backend/src/ee/services/relay/relay-service.ts index 92401faaf..038dfb248 100644 --- a/backend/src/ee/services/relay/relay-service.ts +++ b/backend/src/ee/services/relay/relay-service.ts @@ -1,9 +1,11 @@ +import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import { TRelays } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { constructPemChainFromCerts, prependCertToPemChain } from "@app/services/certificate/certificate-fns"; import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; import { @@ -14,6 +16,9 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { verifyHostInputValidity } from "../dynamic-secret/dynamic-secret-fns"; +import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionRelayActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service-types"; 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"; @@ -29,12 +34,16 @@ export const relayServiceFactory = ({ instanceRelayConfigDAL, orgRelayConfigDAL, relayDAL, - kmsService + kmsService, + licenseService, + permissionService }: { instanceRelayConfigDAL: TInstanceRelayConfigDALFactory; orgRelayConfigDAL: TOrgRelayConfigDALFactory; relayDAL: TRelayDALFactory; kmsService: TKmsServiceFactory; + licenseService: TLicenseServiceFactory; + permissionService: TPermissionServiceFactory; }) => { const $getInstanceCAs = async () => { const instanceConfig = await instanceRelayConfigDAL.transaction(async (tx) => { @@ -819,10 +828,10 @@ export const relayServiceFactory = ({ const relayClientSshCert = await createSshCert({ caPrivateKey: orgCAs.relaySshClientCaPrivateKey.toString("utf8"), clientPublicKey: relayClientSshPublicKey, - keyId: `relay-client-${relay.id}`, + keyId: `client-${relayName}`, principals: [gatewayId], certType: SshCertType.USER, - requestedTtl: "30d" + requestedTtl: "1d" }); return { @@ -895,11 +904,13 @@ export const relayServiceFactory = ({ host, name, identityId, + actorAuthMethod, orgId }: { host: string; name: string; identityId?: string; + actorAuthMethod?: ActorAuthMethod; orgId?: string; }) => { let relay: TRelays; @@ -908,6 +919,27 @@ export const relayServiceFactory = ({ await verifyHostInputValidity(host); if (isOrgRelay) { + const orgLicensePlan = await licenseService.getPlan(orgId); + if (!orgLicensePlan.gateway) { + throw new BadRequestError({ + message: + "Relay registration failed due to organization plan restrictions. Please upgrade your instance to Infisical's Enterprise plan." + }); + } + + const { permission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityId, + orgId, + actorAuthMethod!, + orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionRelayActions.CreateRelays, + OrgPermissionSubjects.Relay + ); + relay = await relayDAL.transaction(async (tx) => { const existingRelay = await relayDAL.findOne( { @@ -995,9 +1027,75 @@ export const relayServiceFactory = ({ }); }; + const getRelays = async ({ + actorId, + actor, + actorAuthMethod, + actorOrgId + }: { + actorId: string; + actor: ActorType; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + }) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionRelayActions.ListRelays, OrgPermissionSubjects.Relay); + + const instanceRelays = await relayDAL.find({ + orgId: null + }); + + const orgRelays = await relayDAL.find({ + orgId: actorOrgId + }); + + return [...instanceRelays, ...orgRelays]; + }; + + const deleteRelay = async ({ + id, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: { + id: string; + actorId: string; + actor: ActorType; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + }) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionRelayActions.DeleteRelays, OrgPermissionSubjects.Relay); + + const relay = await relayDAL.findById(id); + if (!relay || relay.orgId !== actorOrgId) { + throw new NotFoundError({ message: "Relay not found" }); + } + + const deletedRelay = await relayDAL.deleteById(id); + return deletedRelay; + }; + return { registerRelay, getCredentialsForGateway, - getCredentialsForClient + getCredentialsForClient, + getRelays, + deleteRelay }; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index f98f2c1d8..8444e8765 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1110,7 +1110,9 @@ export const registerRoutes = async ( instanceRelayConfigDAL, orgRelayConfigDAL, relayDAL, - kmsService + kmsService, + licenseService, + permissionService }); const gatewayV2Service = gatewayV2ServiceFactory({ diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 8e4f7514c..bcab6169e 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -21,6 +21,13 @@ export enum OrgGatewayPermissionActions { AttachGateways = "attach-gateways" } +export enum OrgRelayPermissionActions { + CreateRelays = "create-relays", + ListRelays = "list-relays", + EditRelays = "edit-relays", + DeleteRelays = "delete-relays" +} + export enum OrgPermissionMachineIdentityAuthTemplateActions { ListTemplates = "list-templates", CreateTemplates = "create-templates", @@ -51,6 +58,7 @@ export enum OrgPermissionSubjects { AppConnections = "app-connections", Kmip = "kmip", Gateway = "gateway", + Relay = "relay", SecretShare = "secret-share", GithubOrgSync = "github-org-sync", GithubOrgSyncManual = "github-org-sync-manual", @@ -135,6 +143,7 @@ export type OrgPermissionSet = OrgPermissionSubjects.MachineIdentityAuthTemplate ] | [OrgGatewayPermissionActions, OrgPermissionSubjects.Gateway] + | [OrgRelayPermissionActions, OrgPermissionSubjects.Relay] | [OrgPermissionSecretShareAction, OrgPermissionSubjects.SecretShare] | [ OrgPermissionAppConnectionActions, diff --git a/frontend/src/hooks/api/relays/index.tsx b/frontend/src/hooks/api/relays/index.tsx new file mode 100644 index 000000000..177955438 --- /dev/null +++ b/frontend/src/hooks/api/relays/index.tsx @@ -0,0 +1,3 @@ +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/relays/mutations.tsx b/frontend/src/hooks/api/relays/mutations.tsx new file mode 100644 index 000000000..4e1d52a67 --- /dev/null +++ b/frontend/src/hooks/api/relays/mutations.tsx @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { relayQueryKeys } from "./queries"; + +export const useDeleteRelayById = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => { + return apiRequest.delete(`/api/v1/relays/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: relayQueryKeys.list() }); + } + }); +}; diff --git a/frontend/src/hooks/api/relays/queries.tsx b/frontend/src/hooks/api/relays/queries.tsx new file mode 100644 index 000000000..274724356 --- /dev/null +++ b/frontend/src/hooks/api/relays/queries.tsx @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TRelay } from "./types"; + +export const relayQueryKeys = { + list: () => ["relays"] as const +}; + +const fetchRelays = async (): Promise => { + const { data } = await apiRequest.get("/api/v1/relays"); + return data; +}; + +export const useGetRelays = () => { + return useQuery({ + queryKey: relayQueryKeys.list(), + queryFn: fetchRelays + }); +}; diff --git a/frontend/src/hooks/api/relays/types.ts b/frontend/src/hooks/api/relays/types.ts new file mode 100644 index 000000000..621fd52db --- /dev/null +++ b/frontend/src/hooks/api/relays/types.ts @@ -0,0 +1,13 @@ +export type TRelay = { + id: string; + createdAt: string; + updatedAt: string; + orgId: string | null; + identityId: string | null; + name: string; + host: string; +}; + +export type TDeleteRelayDTO = { + id: string; +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx b/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx index 4ab5051f8..e1c1e89f9 100644 --- a/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx @@ -5,6 +5,7 @@ import { faInfinity, faMoneyBill, faPlug, + faRoute, faShare, faTable, faUsers, @@ -136,6 +137,18 @@ export const OrgSidebar = ({ isHidden }: Props) => { )} + + {({ isActive }) => ( + +
+
+ +
+ Relays +
+
+ )} +
diff --git a/frontend/src/pages/organization/Gateways/Relay/RelayListPage/RelayListPage.tsx b/frontend/src/pages/organization/Gateways/Relay/RelayListPage/RelayListPage.tsx new file mode 100644 index 000000000..0e35044bc --- /dev/null +++ b/frontend/src/pages/organization/Gateways/Relay/RelayListPage/RelayListPage.tsx @@ -0,0 +1,214 @@ +import { useState } from "react"; +import { Helmet } from "react-helmet"; +import { + faArrowUpRightFromSquare, + faBookOpen, + faCopy, + faDoorClosed, + faEllipsisV, + faMagnifyingGlass, + faSearch, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { formatRelative } from "date-fns"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + IconButton, + Input, + PageHeader, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { + OrgPermissionSubjects, + OrgRelayPermissionActions +} from "@app/context/OrgPermissionContext/types"; +import { withPermission } from "@app/hoc"; +import { usePopUp } from "@app/hooks"; +import { useDeleteRelayById, useGetRelays } from "@app/hooks/api/relays"; + +export const RelayListPage = withPermission( + () => { + const [search, setSearch] = useState(""); + const { data: relays, isPending: isRelaysLoading } = useGetRelays(); + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["deleteRelay"] as const); + + const deleteRelayById = useDeleteRelayById(); + + const handleDeleteRelay = async () => { + const data = popUp.deleteRelay.data as { id: string }; + await deleteRelayById.mutateAsync(data.id); + + handlePopUpToggle("deleteRelay"); + createNotification({ + type: "success", + text: "Successfully deleted relay" + }); + }; + + const filteredRelays = relays?.filter((el) => + el.name.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+ + Infisical | Relays + + +
+
+ + Relays + +
+ + Docs + +
+
+
+ } + description="Create and configure relays to securely access private network resources from Infisical" + /> +
+
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search relay..." + className="flex-1" + /> +
+ + + + + + + + + + + {isRelaysLoading && ( + + )} + {filteredRelays?.map((el) => ( + + + + + + + ))} + +
NameHostCreated +
+
+ {el.name} + {!el.orgId && ( + + + Instance + + + )} +
+
{el.host}{formatRelative(new Date(el.createdAt), new Date())} + + + + + + + + + } + onClick={() => navigator.clipboard.writeText(el.id)} + > + Copy ID + + + {(isAllowed: boolean) => ( + } + className="text-red" + onClick={() => handlePopUpOpen("deleteRelay", el)} + > + Delete Relay + + )} + + + + +
+ {!isRelaysLoading && !filteredRelays?.length && ( + + )} + handlePopUpToggle("deleteRelay", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => handleDeleteRelay()} + /> +
+
+
+
+
+
+ ); + }, + { action: OrgRelayPermissionActions.ListRelays, subject: OrgPermissionSubjects.Relay } +); diff --git a/frontend/src/pages/organization/Gateways/Relay/RelayListPage/route.tsx b/frontend/src/pages/organization/Gateways/Relay/RelayListPage/route.tsx new file mode 100644 index 000000000..125f1c58c --- /dev/null +++ b/frontend/src/pages/organization/Gateways/Relay/RelayListPage/route.tsx @@ -0,0 +1,16 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { RelayListPage } from "./RelayListPage"; + +export const Route = createFileRoute( + "/_authenticate/_inject-org-details/_org-layout/organization/relays/" +)({ + component: RelayListPage, + context: () => ({ + breadcrumbs: [ + { + label: "Relays" + } + ] + }) +}); diff --git a/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts b/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts index 9c374d398..68da9cad2 100644 --- a/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts @@ -11,7 +11,8 @@ import { OrgPermissionIdentityActions, OrgPermissionKmipActions, OrgPermissionMachineIdentityAuthTemplateActions, - OrgPermissionSecretShareAction + OrgPermissionSecretShareAction, + OrgRelayPermissionActions } from "@app/context/OrgPermissionContext/types"; import { TPermission } from "@app/hooks/api/roles/types"; @@ -90,6 +91,15 @@ const orgGatewayPermissionSchema = z }) .optional(); +const orgRelayPermissionSchema = z + .object({ + [OrgRelayPermissionActions.ListRelays]: z.boolean().optional(), + [OrgRelayPermissionActions.EditRelays]: z.boolean().optional(), + [OrgRelayPermissionActions.DeleteRelays]: z.boolean().optional(), + [OrgRelayPermissionActions.CreateRelays]: z.boolean().optional() + }) + .optional(); + const machineIdentityAuthTemplatePermissionSchema = z .object({ [OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates]: z.boolean().optional(), @@ -147,6 +157,7 @@ export const formSchema = z.object({ "app-connections": appConnectionsPermissionSchema, kmip: kmipPermissionSchema, gateway: orgGatewayPermissionSchema, + relay: orgRelayPermissionSchema, "machine-identity-auth-template": machineIdentityAuthTemplatePermissionSchema, "secret-share": secretSharingPermissionSchema }) diff --git a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionRelayRow.tsx b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionRelayRow.tsx new file mode 100644 index 000000000..8c2dced21 --- /dev/null +++ b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionRelayRow.tsx @@ -0,0 +1,180 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2"; +import { OrgRelayPermissionActions } from "@app/context/OrgPermissionContext/types"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "../OrgRoleModifySection.utils"; + +type Props = { + isEditable: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-access", + Custom = "custom" +} + +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" } +] as const; + +export const OrgRelayPermissionRow = ({ isEditable, control, setValue }: Props) => { + const [isRowExpanded, setIsRowExpanded] = useToggle(); + const [isCustom, setIsCustom] = useToggle(); + + const rule = useWatch({ + control, + name: "permissions.relay" + }); + + const selectedPermissionCategory = useMemo(() => { + const actions = Object.keys(rule || {}) as Array; + const totalActions = PERMISSION_ACTIONS.length; + const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && rule?.[OrgRelayPermissionActions.ListRelays]) return Permission.ReadOnly; + + return Permission.Custom; + }, [rule, isCustom]); + + useEffect(() => { + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + }, [selectedPermissionCategory]); + + useEffect(() => { + const isRowCustom = selectedPermissionCategory === Permission.Custom; + if (isRowCustom) { + setIsRowExpanded.on(); + } + }, []); + + const handlePermissionChange = (val: Permission) => { + if (!val) return; + if (val === Permission.Custom) { + setIsRowExpanded.on(); + setIsCustom.on(); + return; + } + setIsCustom.off(); + + switch (val) { + case Permission.FullAccess: + setValue( + "permissions.relay", + { + [OrgRelayPermissionActions.ListRelays]: true, + [OrgRelayPermissionActions.EditRelays]: true, + [OrgRelayPermissionActions.CreateRelays]: true, + [OrgRelayPermissionActions.DeleteRelays]: true + }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setValue( + "permissions.relay", + { + [OrgRelayPermissionActions.ListRelays]: true, + [OrgRelayPermissionActions.EditRelays]: false, + [OrgRelayPermissionActions.CreateRelays]: false, + [OrgRelayPermissionActions.DeleteRelays]: false + }, + { shouldDirty: true } + ); + break; + + case Permission.NoAccess: + default: + setValue( + "permissions.relay", + { + [OrgRelayPermissionActions.ListRelays]: false, + [OrgRelayPermissionActions.EditRelays]: false, + [OrgRelayPermissionActions.CreateRelays]: false, + [OrgRelayPermissionActions.DeleteRelays]: false + }, + { shouldDirty: true } + ); + } + }; + + return ( + <> + setIsRowExpanded.toggle()} + > + + + + Relays + + + + + {isRowExpanded && ( + + +
+ {PERMISSION_ACTIONS.map(({ action, label }) => { + return ( + ( + { + if (!isEditable) { + createNotification({ + type: "error", + text: "Failed to update default role" + }); + return; + } + field.onChange(e); + }} + id={`permissions.relays.${action}`} + > + {label} + + )} + /> + ); + })} +
+ + + )} + + ); +}; diff --git a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionRow.tsx b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionRow.tsx index b3fd63efd..871218576 100644 --- a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionRow.tsx +++ b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionRow.tsx @@ -69,6 +69,7 @@ type Props = { | "organization-admin-console" | "kmip" | "gateway" + | "relay" | "secret-share" | "billing" | "audit-logs" diff --git a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx index 3b606cbb1..0fa704757 100644 --- a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -23,6 +23,7 @@ import { OrgPermissionGroupRow } from "./OrgPermissionGroupRow"; import { OrgPermissionIdentityRow } from "./OrgPermissionIdentityRow"; import { OrgPermissionKmipRow } from "./OrgPermissionKmipRow"; import { OrgPermissionMachineIdentityAuthTemplateRow } from "./OrgPermissionMachineIdentityAuthTemplateRow"; +import { OrgRelayPermissionRow } from "./OrgPermissionRelayRow"; import { OrgPermissionSecretShareRow } from "./OrgPermissionSecretShareRow"; import { OrgRoleWorkspaceRow } from "./OrgRoleWorkspaceRow"; import { RolePermissionRow } from "./RolePermissionRow"; @@ -188,6 +189,11 @@ export const RolePermissionsSection = ({ roleId }: Props) => { setValue={setValue} isEditable={isCustomRole} /> + + AuthenticateInjectOrgDetailsOrgLayoutOrganizationRoute, + } as any) + const AuthenticateInjectOrgDetailsOrgLayoutOrganizationGatewaysRoute = AuthenticateInjectOrgDetailsOrgLayoutOrganizationGatewaysImport.update({ id: '/gateways', @@ -765,6 +778,14 @@ const organizationSecretSharingPageRouteRoute = AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRoute, } as any) +const organizationGatewaysRelayRelayListPageRouteRoute = + organizationGatewaysRelayRelayListPageRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => + AuthenticateInjectOrgDetailsOrgLayoutOrganizationRelaysRoute, + } as any) + const organizationGatewaysGatewayListPageRouteRoute = organizationGatewaysGatewayListPageRouteImport.update({ id: '/', @@ -2376,6 +2397,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationGatewaysImport parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationImport } + '/_authenticate/_inject-org-details/_org-layout/organization/relays': { + id: '/_authenticate/_inject-org-details/_org-layout/organization/relays' + path: '/relays' + fullPath: '/organization/relays' + preLoaderRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationRelaysImport + parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationImport + } '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing': { id: '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing' path: '/secret-sharing' @@ -2411,6 +2439,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof organizationGatewaysGatewayListPageRouteImport parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationGatewaysImport } + '/_authenticate/_inject-org-details/_org-layout/organization/relays/': { + id: '/_authenticate/_inject-org-details/_org-layout/organization/relays/' + path: '/' + fullPath: '/organization/relays/' + preLoaderRoute: typeof organizationGatewaysRelayRelayListPageRouteImport + parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationRelaysImport + } '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/': { id: '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/' path: '/' @@ -3734,6 +3769,21 @@ const AuthenticateInjectOrgDetailsOrgLayoutOrganizationGatewaysRouteWithChildren AuthenticateInjectOrgDetailsOrgLayoutOrganizationGatewaysRouteChildren, ) +interface AuthenticateInjectOrgDetailsOrgLayoutOrganizationRelaysRouteChildren { + organizationGatewaysRelayRelayListPageRouteRoute: typeof organizationGatewaysRelayRelayListPageRouteRoute +} + +const AuthenticateInjectOrgDetailsOrgLayoutOrganizationRelaysRouteChildren: AuthenticateInjectOrgDetailsOrgLayoutOrganizationRelaysRouteChildren = + { + organizationGatewaysRelayRelayListPageRouteRoute: + organizationGatewaysRelayRelayListPageRouteRoute, + } + +const AuthenticateInjectOrgDetailsOrgLayoutOrganizationRelaysRouteWithChildren = + AuthenticateInjectOrgDetailsOrgLayoutOrganizationRelaysRoute._addFileChildren( + AuthenticateInjectOrgDetailsOrgLayoutOrganizationRelaysRouteChildren, + ) + interface AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRouteChildren { organizationSecretSharingPageRouteRoute: typeof organizationSecretSharingPageRouteRoute organizationSecretSharingSettingsPageRouteRoute: typeof organizationSecretSharingSettingsPageRouteRoute @@ -3776,6 +3826,7 @@ interface AuthenticateInjectOrgDetailsOrgLayoutOrganizationRouteChildren { organizationProjectsPageRouteRoute: typeof organizationProjectsPageRouteRoute AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsRouteWithChildren AuthenticateInjectOrgDetailsOrgLayoutOrganizationGatewaysRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationGatewaysRouteWithChildren + AuthenticateInjectOrgDetailsOrgLayoutOrganizationRelaysRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationRelaysRouteWithChildren AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRouteWithChildren AuthenticateInjectOrgDetailsOrgLayoutOrganizationSettingsRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationSettingsRouteWithChildren organizationGroupDetailsByIDPageRouteRoute: typeof organizationGroupDetailsByIDPageRouteRoute @@ -3795,6 +3846,8 @@ const AuthenticateInjectOrgDetailsOrgLayoutOrganizationRouteChildren: Authentica AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsRouteWithChildren, AuthenticateInjectOrgDetailsOrgLayoutOrganizationGatewaysRoute: AuthenticateInjectOrgDetailsOrgLayoutOrganizationGatewaysRouteWithChildren, + AuthenticateInjectOrgDetailsOrgLayoutOrganizationRelaysRoute: + AuthenticateInjectOrgDetailsOrgLayoutOrganizationRelaysRouteWithChildren, AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRoute: AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRouteWithChildren, AuthenticateInjectOrgDetailsOrgLayoutOrganizationSettingsRoute: @@ -4694,11 +4747,13 @@ export interface FileRoutesByFullPath { '/admin/integrations': typeof adminIntegrationsPageRouteRoute '/organization/app-connections': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsRouteWithChildren '/organization/gateways': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationGatewaysRouteWithChildren + '/organization/relays': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationRelaysRouteWithChildren '/organization/secret-sharing': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRouteWithChildren '/organization/settings': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationSettingsRouteWithChildren '/secret-manager/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutSecretManagerProjectIdRouteWithChildren '/organization/app-connections/': typeof organizationAppConnectionsAppConnectionsPageRouteRoute '/organization/gateways/': typeof organizationGatewaysGatewayListPageRouteRoute + '/organization/relays/': typeof organizationGatewaysRelayRelayListPageRouteRoute '/organization/secret-sharing/': typeof organizationSecretSharingPageRouteRoute '/organization/settings/': typeof organizationSettingsPageRouteRoute '/organization/groups/$groupId': typeof organizationGroupDetailsByIDPageRouteRoute @@ -4915,6 +4970,7 @@ export interface FileRoutesByTo { '/secret-manager/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutSecretManagerProjectIdRouteWithChildren '/organization/app-connections': typeof organizationAppConnectionsAppConnectionsPageRouteRoute '/organization/gateways': typeof organizationGatewaysGatewayListPageRouteRoute + '/organization/relays': typeof organizationGatewaysRelayRelayListPageRouteRoute '/organization/secret-sharing': typeof organizationSecretSharingPageRouteRoute '/organization/settings': typeof organizationSettingsPageRouteRoute '/organization/groups/$groupId': typeof organizationGroupDetailsByIDPageRouteRoute @@ -5134,11 +5190,13 @@ export interface FileRoutesById { '/_authenticate/_inject-org-details/admin/_admin-layout/integrations': typeof adminIntegrationsPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organization/app-connections': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsRouteWithChildren '/_authenticate/_inject-org-details/_org-layout/organization/gateways': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationGatewaysRouteWithChildren + '/_authenticate/_inject-org-details/_org-layout/organization/relays': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationRelaysRouteWithChildren '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingRouteWithChildren '/_authenticate/_inject-org-details/_org-layout/organization/settings': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationSettingsRouteWithChildren '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutSecretManagerProjectIdRouteWithChildren '/_authenticate/_inject-org-details/_org-layout/organization/app-connections/': typeof organizationAppConnectionsAppConnectionsPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organization/gateways/': typeof organizationGatewaysGatewayListPageRouteRoute + '/_authenticate/_inject-org-details/_org-layout/organization/relays/': typeof organizationGatewaysRelayRelayListPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/': typeof organizationSecretSharingPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organization/settings/': typeof organizationSettingsPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organization/groups/$groupId': typeof organizationGroupDetailsByIDPageRouteRoute @@ -5365,11 +5423,13 @@ export interface FileRouteTypes { | '/admin/integrations' | '/organization/app-connections' | '/organization/gateways' + | '/organization/relays' | '/organization/secret-sharing' | '/organization/settings' | '/secret-manager/$projectId' | '/organization/app-connections/' | '/organization/gateways/' + | '/organization/relays/' | '/organization/secret-sharing/' | '/organization/settings/' | '/organization/groups/$groupId' @@ -5585,6 +5645,7 @@ export interface FileRouteTypes { | '/secret-manager/$projectId' | '/organization/app-connections' | '/organization/gateways' + | '/organization/relays' | '/organization/secret-sharing' | '/organization/settings' | '/organization/groups/$groupId' @@ -5802,11 +5863,13 @@ export interface FileRouteTypes { | '/_authenticate/_inject-org-details/admin/_admin-layout/integrations' | '/_authenticate/_inject-org-details/_org-layout/organization/app-connections' | '/_authenticate/_inject-org-details/_org-layout/organization/gateways' + | '/_authenticate/_inject-org-details/_org-layout/organization/relays' | '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing' | '/_authenticate/_inject-org-details/_org-layout/organization/settings' | '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId' | '/_authenticate/_inject-org-details/_org-layout/organization/app-connections/' | '/_authenticate/_inject-org-details/_org-layout/organization/gateways/' + | '/_authenticate/_inject-org-details/_org-layout/organization/relays/' | '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/' | '/_authenticate/_inject-org-details/_org-layout/organization/settings/' | '/_authenticate/_inject-org-details/_org-layout/organization/groups/$groupId' @@ -6228,6 +6291,7 @@ export const routeTree = rootRoute "/_authenticate/_inject-org-details/_org-layout/organization/projects", "/_authenticate/_inject-org-details/_org-layout/organization/app-connections", "/_authenticate/_inject-org-details/_org-layout/organization/gateways", + "/_authenticate/_inject-org-details/_org-layout/organization/relays", "/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing", "/_authenticate/_inject-org-details/_org-layout/organization/settings", "/_authenticate/_inject-org-details/_org-layout/organization/groups/$groupId", @@ -6309,6 +6373,13 @@ export const routeTree = rootRoute "/_authenticate/_inject-org-details/_org-layout/organization/gateways/" ] }, + "/_authenticate/_inject-org-details/_org-layout/organization/relays": { + "filePath": "", + "parent": "/_authenticate/_inject-org-details/_org-layout/organization", + "children": [ + "/_authenticate/_inject-org-details/_org-layout/organization/relays/" + ] + }, "/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing": { "filePath": "", "parent": "/_authenticate/_inject-org-details/_org-layout/organization", @@ -6340,6 +6411,10 @@ export const routeTree = rootRoute "filePath": "organization/Gateways/GatewayListPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/organization/gateways" }, + "/_authenticate/_inject-org-details/_org-layout/organization/relays/": { + "filePath": "organization/Gateways/Relay/RelayListPage/route.tsx", + "parent": "/_authenticate/_inject-org-details/_org-layout/organization/relays" + }, "/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/": { "filePath": "organization/SecretSharingPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing" diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 76680a851..a81e9a619 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -40,7 +40,8 @@ const organizationRoutes = route("/organization", [ "organization/AppConnections/OauthCallbackPage/route.tsx" ) ]), - route("/gateways", [index("organization/Gateways/GatewayListPage/route.tsx")]) + route("/gateways", [index("organization/Gateways/GatewayListPage/route.tsx")]), + route("/relays", [index("organization/Gateways/Relay/RelayListPage/route.tsx")]) ]); const secretManagerRoutes = route("/projects/secret-management/$projectId", [