diff --git a/backend/src/ee/routes/v1/ssh-host-group-router.ts b/backend/src/ee/routes/v1/ssh-host-group-router.ts index e82d76c30..c6f35c7e1 100644 --- a/backend/src/ee/routes/v1/ssh-host-group-router.ts +++ b/backend/src/ee/routes/v1/ssh-host-group-router.ts @@ -4,7 +4,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; import { sanitizedSshHostGroup } from "@app/ee/services/ssh-host-group/ssh-host-group-schema"; import { EHostGroupMembershipFilter } from "@app/ee/services/ssh-host-group/ssh-host-group-types"; -import { SSH_HOST_GROUPS } from "@app/lib/api-docs"; +import { ApiDocsTags, SSH_HOST_GROUPS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -18,6 +18,9 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Get SSH Host Group", params: z.object({ sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.GET.sshHostGroupId) }), @@ -60,6 +63,8 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => rateLimit: writeLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], description: "Create SSH Host Group", body: z.object({ projectId: z.string().describe(SSH_HOST_GROUPS.CREATE.projectId), @@ -107,6 +112,8 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], description: "Update SSH Host Group", params: z.object({ sshHostGroupId: z.string().trim().describe(SSH_HOST_GROUPS.UPDATE.sshHostGroupId) @@ -155,6 +162,9 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => rateLimit: writeLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Delete SSH Host Group", params: z.object({ sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.DELETE.sshHostGroupId) }), @@ -197,6 +207,9 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Get SSH Hosts in a Host Group", params: z.object({ sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.GET.sshHostGroupId) }), @@ -256,6 +269,9 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => rateLimit: writeLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Add an SSH Host to a Host Group", params: z.object({ sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.ADD_HOST.sshHostGroupId), hostId: z.string().describe(SSH_HOST_GROUPS.ADD_HOST.hostId) @@ -301,6 +317,9 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => rateLimit: writeLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Remove an SSH Host from a Host Group", params: z.object({ sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.DELETE_HOST.sshHostGroupId), hostId: z.string().describe(SSH_HOST_GROUPS.DELETE_HOST.hostId) diff --git a/backend/src/ee/routes/v1/ssh-host-router.ts b/backend/src/ee/routes/v1/ssh-host-router.ts index 739fec79a..93748c27f 100644 --- a/backend/src/ee/routes/v1/ssh-host-router.ts +++ b/backend/src/ee/routes/v1/ssh-host-router.ts @@ -5,7 +5,7 @@ import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certif import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; import { LoginMappingSource } from "@app/ee/services/ssh-host/ssh-host-types"; import { isValidHostname } from "@app/ee/services/ssh-host/ssh-host-validators"; -import { SSH_HOSTS } from "@app/lib/api-docs"; +import { ApiDocsTags, SSH_HOSTS } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; import { publicSshCaLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; @@ -22,6 +22,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], response: { 200: z.array( sanitizedSshHost.extend({ @@ -54,6 +56,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], params: z.object({ sshHostId: z.string().describe(SSH_HOSTS.GET.sshHostId) }), @@ -100,7 +104,9 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { - description: "Add an SSH Host", + hide: false, + tags: [ApiDocsTags.SshHosts], + description: "Register SSH Host", body: z.object({ projectId: z.string().describe(SSH_HOSTS.CREATE.projectId), hostname: z @@ -176,6 +182,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], description: "Update SSH Host", params: z.object({ sshHostId: z.string().trim().describe(SSH_HOSTS.UPDATE.sshHostId) @@ -252,6 +260,9 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], + description: "Delete SSH Host", params: z.object({ sshHostId: z.string().describe(SSH_HOSTS.DELETE.sshHostId) }), @@ -299,6 +310,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], description: "Issue SSH certificate for user", params: z.object({ sshHostId: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.sshHostId) @@ -371,6 +384,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], description: "Issue SSH certificate for host", params: z.object({ sshHostId: z.string().describe(SSH_HOSTS.ISSUE_HOST_CERT.sshHostId) @@ -435,6 +450,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: publicSshCaLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], description: "Get public key of the user SSH CA linked to the host", params: z.object({ sshHostId: z.string().trim().describe(SSH_HOSTS.GET_USER_CA_PUBLIC_KEY.sshHostId) @@ -456,6 +473,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: publicSshCaLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], description: "Get public key of the host SSH CA linked to the host", params: z.object({ sshHostId: z.string().trim().describe(SSH_HOSTS.GET_HOST_CA_PUBLIC_KEY.sshHostId) diff --git a/backend/src/ee/services/ssh-host/ssh-host-dal.ts b/backend/src/ee/services/ssh-host/ssh-host-dal.ts index f655fa9d1..e66f7da7a 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-dal.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-dal.ts @@ -90,15 +90,19 @@ export const sshHostDALFactory = (db: TDbClient) => { const { sshHostId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId, projectId } = hostRows[0]; - const loginMappingGrouped = groupBy(hostRows, (r) => `${r.loginUser}|${r.source}`); - const loginMappings = Object.entries(loginMappingGrouped).map(([key]) => { - const [loginUser, source] = key.split("|"); + const loginMappingGrouped = groupBy(hostRows, (r) => r.loginUser); + const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, mappings]) => { + // Prefer HOST source over HOST_GROUP + const preferredMapping = + mappings.find((m) => m.source === LoginMappingSource.HOST) || + mappings.find((m) => m.source === LoginMappingSource.HOST_GROUP); + return { loginUser, allowedPrincipals: { usernames: [user.username] }, - source: source as LoginMappingSource + source: preferredMapping!.source }; }); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 2e6d15ffb..dc90f6b3f 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -48,6 +48,8 @@ export enum ApiDocsTags { SshCertificates = "SSH Certificates", SshCertificateAuthorities = "SSH Certificate Authorities", SshCertificateTemplates = "SSH Certificate Templates", + SshHosts = "SSH Hosts", + SshHostGroups = "SSH Host Groups", KmsKeys = "KMS Keys", KmsEncryption = "KMS Encryption", KmsSigning = "KMS Signing" diff --git a/frontend/src/hooks/api/sshHostGroup/mutations.tsx b/frontend/src/hooks/api/sshHostGroup/mutations.tsx index 2afdaa94c..b75cff187 100644 --- a/frontend/src/hooks/api/sshHostGroup/mutations.tsx +++ b/frontend/src/hooks/api/sshHostGroup/mutations.tsx @@ -43,6 +43,9 @@ export const useUpdateSshHostGroup = () => { queryClient.invalidateQueries({ queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId) }); + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceSshHosts(projectId) + }); queryClient.invalidateQueries({ queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId) }); @@ -63,6 +66,9 @@ export const useDeleteSshHostGroup = () => { queryClient.invalidateQueries({ queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId) }); + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceSshHosts(projectId) + }); queryClient.invalidateQueries({ queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId) }); diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx index 2c4f6c839..c5f13d792 100644 --- a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx @@ -95,7 +95,8 @@ const Page = () => { )} onClick={() => handlePopUpOpen("deleteSshHostGroup", { - groupId: data.id + groupId: data.id, + name: data.name }) } disabled={!isAllowed} @@ -123,7 +124,9 @@ const Page = () => { handlePopUpToggle("deleteSshHostGroup", isOpen)} deleteKey="confirm" onDeleteApproved={() => diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx index 9f2487507..88a7eebae 100644 --- a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx @@ -88,9 +88,13 @@ export const SshHostGroupHostsSection = ({ sshHostGroupId }: Props) => { handlePopUpToggle("removeHostFromSshHostGroup", isOpen)} deleteKey="confirm" onDeleteApproved={() => diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx index c61cb1de7..7866d2125 100644 --- a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx @@ -1,4 +1,4 @@ -import { faServer, faUserMinus } from "@fortawesome/free-solid-svg-icons"; +import { faServer, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { ProjectPermissionCan } from "@app/components/permissions"; @@ -68,13 +68,14 @@ export const SshHostGroupHostsTable = ({ sshHostGroupId, handlePopUpOpen }: Prop onClick={() => handlePopUpOpen("removeHostFromSshHostGroup", { sshHostId: host.id, + alias: host.alias, hostname: host.hostname }) } variant="plain" colorSchema="danger" > - + )} diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx index 6d7206a9f..70036d88a 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx @@ -77,7 +77,7 @@ export const SshHostGroupsSection = () => { handlePopUpToggle("deleteSshHostGroup", isOpen)} deleteKey="confirm" onDeleteApproved={() => diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx index 75bef3ba6..194d9e90e 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx @@ -114,7 +114,33 @@ export const SshHostGroupsTable = ({ handlePopUpOpen }: Props) => { disabled={!isAllowed} icon={} > - Edit SSH host group + Edit Host Group + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + navigate({ + to: `/${ProjectType.SSH}/$projectId/ssh-host-groups/$sshHostGroupId` as const, + params: { + projectId: currentWorkspace.id, + sshHostGroupId: group.id + } + }); + }} + disabled={!isAllowed} + icon={} + > + Manage Hosts )} @@ -130,13 +156,14 @@ export const SshHostGroupsTable = ({ handlePopUpOpen }: Props) => { onClick={(e) => { e.stopPropagation(); handlePopUpOpen("deleteSshHostGroup", { - sshHostGroupId: group.id + sshHostGroupId: group.id, + name: group.name }); }} disabled={!isAllowed} icon={} > - Delete SSH host group + Delete Host Group )} diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx index c782cf176..5ac53a16e 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx @@ -362,7 +362,10 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { const newValue = e.target.value; const loginMappings = getValues("loginMappings"); const isDuplicate = loginMappings.some( - (mapping, index) => index !== i && mapping.loginUser === newValue + (mapping, index) => + index !== i && + mapping.loginUser === newValue && + mapping.source === LoginMappingSource.HOST ); if (isDuplicate) { diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx index 14a438455..d69328b82 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx @@ -91,7 +91,27 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => { {host.loginMappings.length === 0 ? ( None ) : ( - host.loginMappings.map(({ loginUser, allowedPrincipals, source }) => ( + Object.entries( + host.loginMappings.reduce( + (acc, mapping) => { + const { loginUser, source } = mapping; + const existing = acc[loginUser]; + + if (!existing) { + acc[loginUser] = mapping; + } else if ( + existing.source === LoginMappingSource.HOST_GROUP && + source === LoginMappingSource.HOST + ) { + // Prefer HOST over HOST_GROUP + acc[loginUser] = mapping; + } + + return acc; + }, + {} as Record + ) + ).map(([loginUser, { allowedPrincipals, source }]) => (
{loginUser} @@ -147,7 +167,7 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => { disabled={!isAllowed} icon={} > - Edit SSH host + Edit Host )} @@ -169,7 +189,7 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => { disabled={!isAllowed} icon={} > - Delete SSH host + Delete Host )}