diff --git a/backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts b/backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts new file mode 100644 index 000000000..3bc64d86b --- /dev/null +++ b/backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts @@ -0,0 +1,49 @@ +import { Knex } from "knex"; + +import { ProjectType, TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasDefaultUserCaCol = await knex.schema.hasColumn(TableName.ProjectSshConfig, "defaultUserSshCaId"); + const hasDefaultHostCaCol = await knex.schema.hasColumn(TableName.ProjectSshConfig, "defaultHostSshCaId"); + + if (hasDefaultUserCaCol && hasDefaultHostCaCol) { + await knex.schema.alterTable(TableName.ProjectSshConfig, (t) => { + t.dropForeign(["defaultUserSshCaId"]); + t.dropForeign(["defaultHostSshCaId"]); + }); + + await knex.schema.alterTable(TableName.ProjectSshConfig, (t) => { + // allow nullable (does not wipe existing values) + t.uuid("defaultUserSshCaId").nullable().alter(); + t.uuid("defaultHostSshCaId").nullable().alter(); + + // re-add with SET NULL behavior (previously CASCADE) + t.foreign("defaultUserSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL"); + t.foreign("defaultHostSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL"); + }); + } + + // (dangtony98): backfill by adding null defaults CAs for all existing Infisical SSH projects + // that do not have an associated ProjectSshConfig record introduced in Infisical SSH V2. + + const allProjects = await knex(TableName.Project).where("type", ProjectType.SSH).select("id"); + + const projectsWithConfig = await knex(TableName.ProjectSshConfig).select("projectId"); + const projectIdsWithConfig = new Set(projectsWithConfig.map((config) => config.projectId)); + + const projectsNeedingConfig = allProjects.filter((project) => !projectIdsWithConfig.has(project.id)); + + if (projectsNeedingConfig.length > 0) { + const configsToInsert = projectsNeedingConfig.map((project) => ({ + projectId: project.id, + defaultUserSshCaId: null, + defaultHostSshCaId: null, + createdAt: new Date(), + updatedAt: new Date() + })); + + await knex.batchInsert(TableName.ProjectSshConfig, configsToInsert); + } +} + +export async function down(): Promise {} diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index b5cfadbeb..d171fb3d8 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -965,7 +965,6 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); - can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateAuthorities); can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificates); can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates); can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateTemplates); @@ -1031,7 +1030,6 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateAuthorities); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs); diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 4182ce389..ea899fcea 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -6,6 +6,7 @@ import { ProjectMembershipsSchema, ProjectRolesSchema, ProjectSlackConfigsSchema, + ProjectSshConfigsSchema, ProjectType, SecretFoldersSchema, SortDirection, @@ -612,6 +613,83 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:workspaceId/ssh-config", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + response: { + 200: ProjectSshConfigsSchema.pick({ + id: true, + createdAt: true, + updatedAt: true, + projectId: true, + defaultUserSshCaId: true, + defaultHostSshCaId: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const sshConfig = await server.services.project.getProjectSshConfig({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId + }); + + // TODO: consider adding audit logs + + return sshConfig; + } + }); + + server.route({ + method: "PATCH", + url: "/:workspaceId/ssh-config", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + defaultUserSshCaId: z.string().optional(), + defaultHostSshCaId: z.string().optional() + }), + response: { + 200: ProjectSshConfigsSchema.pick({ + id: true, + createdAt: true, + updatedAt: true, + projectId: true, + defaultUserSshCaId: true, + defaultHostSshCaId: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const sshConfig = await server.services.project.updateProjectSshConfig({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + ...req.body + }); + + return sshConfig; + } + }); + server.route({ method: "GET", url: "/:workspaceId/slack-config", diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 9b7c29c1b..9f2de8a85 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -73,6 +73,7 @@ import { TGetProjectDTO, TGetProjectKmsKey, TGetProjectSlackConfig, + TGetProjectSshConfig, TListProjectAlertsDTO, TListProjectCasDTO, TListProjectCertificateTemplatesDTO, @@ -92,6 +93,7 @@ import { TUpdateProjectKmsDTO, TUpdateProjectNameDTO, TUpdateProjectSlackConfig, + TUpdateProjectSshConfig, TUpdateProjectVersionLimitDTO, TUpgradeProjectDTO } from "./project-types"; @@ -104,7 +106,7 @@ export const DEFAULT_PROJECT_ENVS = [ type TProjectServiceFactoryDep = { projectDAL: TProjectDALFactory; - projectSshConfigDAL: Pick; + projectSshConfigDAL: Pick; projectQueue: TProjectQueueFactory; userDAL: TUserDALFactory; projectBotService: Pick; @@ -129,7 +131,7 @@ type TProjectServiceFactoryDep = { certificateTemplateDAL: Pick; pkiAlertDAL: Pick; pkiCollectionDAL: Pick; - sshCertificateAuthorityDAL: Pick; + sshCertificateAuthorityDAL: Pick; sshCertificateAuthoritySecretDAL: Pick; sshCertificateDAL: Pick; sshCertificateTemplateDAL: Pick; @@ -1327,6 +1329,129 @@ export const projectServiceFactory = ({ return { secretManagerKmsKey: kmsKey }; }; + const getProjectSshConfig = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId + }: TGetProjectSshConfig) => { + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ + message: `Project with ID '${projectId}' not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); + + const projectSshConfig = await projectSshConfigDAL.findOne({ + projectId: project.id + }); + + if (!projectSshConfig) { + throw new NotFoundError({ + message: `Project SSH config with ID '${project.id}' not found` + }); + } + + return projectSshConfig; + }; + + const updateProjectSshConfig = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId, + defaultUserSshCaId, + defaultHostSshCaId + }: TUpdateProjectSshConfig) => { + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ + message: `Project with ID '${projectId}' not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + + let projectSshConfig = await projectSshConfigDAL.findOne({ + projectId: project.id + }); + + if (!projectSshConfig) { + throw new NotFoundError({ + message: `Project SSH config with ID '${project.id}' not found` + }); + } + + projectSshConfig = await projectSshConfigDAL.transaction(async (tx) => { + if (defaultUserSshCaId) { + const userSshCa = await sshCertificateAuthorityDAL.findOne( + { + id: defaultUserSshCaId, + projectId: project.id + }, + tx + ); + + if (!userSshCa) { + throw new NotFoundError({ + message: "User SSH CA must exist and belong to this project" + }); + } + } + + if (defaultHostSshCaId) { + const hostSshCa = await sshCertificateAuthorityDAL.findOne( + { + id: defaultHostSshCaId, + projectId: project.id + }, + tx + ); + + if (!hostSshCa) { + throw new NotFoundError({ + message: "Host SSH CA must exist and belong to this project" + }); + } + } + + const updatedProjectSshConfig = await projectSshConfigDAL.updateById( + projectSshConfig.id, + { + defaultUserSshCaId, + defaultHostSshCaId + }, + tx + ); + + return updatedProjectSshConfig; + }); + + return projectSshConfig; + }; + const getProjectSlackConfig = async ({ actorId, actor, @@ -1548,6 +1673,8 @@ export const projectServiceFactory = ({ getProjectKmsBackup, loadProjectKmsBackup, getProjectKmsKeys, + getProjectSshConfig, + updateProjectSshConfig, getProjectSlackConfig, updateProjectSlackConfig, requestProjectAccess, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 444f6309c..274189668 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -159,6 +159,13 @@ export type TListProjectSshCertificatesDTO = { limit: number; } & TProjectPermission; +export type TUpdateProjectSshConfig = { + defaultUserSshCaId?: string; + defaultHostSshCaId?: string; +} & TProjectPermission; + +export type TGetProjectSshConfig = TProjectPermission; + export type TGetProjectSlackConfig = TProjectPermission; export type TUpdateProjectSlackConfig = { diff --git a/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go index 5b2bb37bb..94519dcc8 100644 --- a/cli/packages/cmd/ssh.go +++ b/cli/packages/cmd/ssh.go @@ -177,7 +177,6 @@ func issueCredentials(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -411,7 +410,6 @@ func signKey(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -610,25 +608,82 @@ func signKey(cmd *cobra.Command, args []string) { } func sshConnect(cmd *cobra.Command, args []string) { - util.RequireLogin() - util.RequireLocalWorkspaceFile() - - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + token, err := util.GetInfisicalToken(cmd) if err != nil { - util.HandleError(err, "Unable to authenticate") + util.HandleError(err, "Unable to parse flag") } + + var infisicalToken string - if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) { + infisicalToken = token.Token + } else { + util.RequireLogin() + + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + if err != nil { + util.HandleError(err, "Unable to authenticate") + } + + if loggedInUserDetails.LoginExpired { + util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } - infisicalToken := loggedInUserDetails.UserCredentials.JTWToken - writeHostCaToFile, err := cmd.Flags().GetBool("writeHostCaToFile") if err != nil { util.HandleError(err, "Unable to parse --writeHostCaToFile flag") } + outFilePath, err := cmd.Flags().GetString("outFilePath") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + hostname, _ := cmd.Flags().GetString("hostname") + loginUser, _ := cmd.Flags().GetString("loginUser") + + var outputDir, privateKeyPath, publicKeyPath, signedKeyPath string + if outFilePath != "" { + if strings.HasPrefix(outFilePath, "~") { + homeDir, err := os.UserHomeDir() + if err != nil { + util.HandleError(err, "Failed to resolve home directory") + } + outFilePath = strings.Replace(outFilePath, "~", homeDir, 1) + } + + if strings.HasSuffix(outFilePath, "-cert.pub") { + signedKeyPath = outFilePath + baseName := strings.TrimSuffix(filepath.Base(outFilePath), "-cert.pub") + outputDir = filepath.Dir(outFilePath) + privateKeyPath = filepath.Join(outputDir, baseName) + publicKeyPath = filepath.Join(outputDir, baseName+".pub") + } else { + outputDir = outFilePath + info, err := os.Stat(outputDir) + if os.IsNotExist(err) { + err = os.MkdirAll(outputDir, 0755) + if err != nil { + util.HandleError(err, "Failed to create output directory") + } + } else if err != nil { + util.HandleError(err, "Failed to access output directory") + } else if !info.IsDir() { + util.PrintErrorMessageAndExit("The provided --outFilePath is not a directory") + } + fileName := "id_ed25519" + privateKeyPath = filepath.Join(outputDir, fileName) + publicKeyPath = filepath.Join(outputDir, fileName+".pub") + signedKeyPath = filepath.Join(outputDir, fileName+"-cert.pub") + } + + if privateKeyPath == "" || publicKeyPath == "" || signedKeyPath == "" { + util.PrintErrorMessageAndExit("Failed to resolve file paths for writing credentials") + } + } + customHeaders, err := util.GetInfisicalCustomHeadersMap() if err != nil { util.HandleError(err, "Unable to get custom headers") @@ -651,43 +706,68 @@ func sshConnect(cmd *cobra.Command, args []string) { util.PrintErrorMessageAndExit("You do not have access to any SSH hosts") } - // Prompt to select host - hostNames := make([]string, len(hosts)) - for i, h := range hosts { - hostNames[i] = h.Hostname + var selectedHost = hosts[0] + if hostname != "" { + foundHost := false + for _, h := range hosts { + if h.Hostname == hostname { + selectedHost = h + foundHost = true + break + } + } + if !foundHost { + util.PrintErrorMessageAndExit("Specified --hostname not found or not accessible") + } + } else { + hostNames := make([]string, len(hosts)) + for i, h := range hosts { + hostNames[i] = h.Hostname + } + hostPrompt := promptui.Select{ + Label: "Select an SSH Host", + Items: hostNames, + Size: 10, + } + hostIdx, _, err := hostPrompt.Run() + if err != nil { + util.HandleError(err, "Prompt failed") + } + selectedHost = hosts[hostIdx] } - hostPrompt := promptui.Select{ - Label: "Select an SSH Host", - Items: hostNames, - Size: 10, + var selectedLoginUser string + if loginUser != "" { + foundLoginUser := false + for _, m := range selectedHost.LoginMappings { + if m.LoginUser == loginUser { + selectedLoginUser = loginUser + foundLoginUser = true + break + } + } + if !foundLoginUser { + util.PrintErrorMessageAndExit("Specified --loginUser not valid for selected host") + } + } else { + if len(selectedHost.LoginMappings) == 0 { + util.PrintErrorMessageAndExit("No login users available for selected host") + } + loginUsers := make([]string, len(selectedHost.LoginMappings)) + for i, m := range selectedHost.LoginMappings { + loginUsers[i] = m.LoginUser + } + loginPrompt := promptui.Select{ + Label: "Select Login User", + Items: loginUsers, + Size: 5, + } + loginIdx, _, err := loginPrompt.Run() + if err != nil { + util.HandleError(err, "Prompt failed") + } + selectedLoginUser = selectedHost.LoginMappings[loginIdx].LoginUser } - hostIdx, _, err := hostPrompt.Run() - if err != nil { - util.HandleError(err, "Prompt failed") - } - selectedHost := hosts[hostIdx] - - // Prompt to select login user - if len(selectedHost.LoginMappings) == 0 { - util.PrintErrorMessageAndExit("No login users available for selected host") - } - - loginUsers := make([]string, len(selectedHost.LoginMappings)) - for i, m := range selectedHost.LoginMappings { - loginUsers[i] = m.LoginUser - } - - loginPrompt := promptui.Select{ - Label: "Select Login User", - Items: loginUsers, - Size: 5, - } - loginIdx, _, err := loginPrompt.Run() - if err != nil { - util.HandleError(err, "Prompt failed") - } - selectedLoginUser := selectedHost.LoginMappings[loginIdx].LoginUser // Issue SSH creds for host creds, err := infisicalClient.Ssh().IssueSshHostUserCert(selectedHost.ID, infisicalSdk.IssueSshHostUserCertOptions{ @@ -731,10 +811,27 @@ func sshConnect(cmd *cobra.Command, args []string) { util.HandleError(err, "Failed to write Host CA to known_hosts") } - fmt.Printf("📁 Wrote Host CA entry to %s\n", knownHostsPath) + fmt.Printf("Successfully wrote Host CA entry to %s\n", knownHostsPath) } } + if outFilePath != "" { + err = os.WriteFile(privateKeyPath, []byte(creds.PrivateKey), 0600) + if err != nil { + util.HandleError(err, "Failed to write private key") + } + err = os.WriteFile(publicKeyPath, []byte(creds.PublicKey), 0644) + if err != nil { + util.HandleError(err, "Failed to write public key") + } + err = os.WriteFile(signedKeyPath, []byte(creds.SignedKey), 0644) + if err != nil { + util.HandleError(err, "Failed to write signed cert") + } + fmt.Printf("Successfully wrote credentials to %s, %s, and %s\n", privateKeyPath, publicKeyPath, signedKeyPath) + return + } + // Load credentials into SSH agent err = addCredentialsToAgent(creds.PrivateKey, creds.SignedKey) if err != nil { @@ -769,7 +866,6 @@ func sshAddHost(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -1006,7 +1102,11 @@ func init() { sshIssueCredentialsCmd.Flags().Bool("addToAgent", false, "Whether to add issued SSH credentials to the SSH agent") sshCmd.AddCommand(sshIssueCredentialsCmd) + sshConnectCmd.Flags().String("token", "", "Use a machine identity access token") sshConnectCmd.Flags().Bool("writeHostCaToFile", true, "Write Host CA public key to ~/.ssh/known_hosts as a separate entry if doesn't already exist") + sshConnectCmd.Flags().String("hostname", "", "Hostname of the SSH host to connect to") + sshConnectCmd.Flags().String("loginUser", "", "Login user for the SSH connection") + sshConnectCmd.Flags().String("outFilePath", "", "The path to write the SSH credentials to such as ~/.ssh, ./some_folder, ./some_folder/id_rsa-cert.pub. If not provided, the credentials will be added to the SSH agent and used to establish an interactive SSH connection") sshCmd.AddCommand(sshConnectCmd) sshAddHostCmd.Flags().String("token", "", "Use a machine identity access token") diff --git a/docs/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh.mdx index ae1e43df5..1f8856a5b 100644 --- a/docs/documentation/platform/ssh.mdx +++ b/docs/documentation/platform/ssh.mdx @@ -10,10 +10,10 @@ Infisical SSH can be configured to provide users on your team short-lived, secur and improves upon traditional SSH key-based authentication by mitigating private key compromise, static key management, unauthorized access, and SSH key sprawl. -The following entities and concepts are important to understand when using Infisical SSH: +The following entities are important to understand when configuring and using Infisical SSH: - Administrator: An individual on your team who is responsible for configuring Infisical SSH. -- Users: Other individuals on your team that need access to the remote host. +- Users: Other individuals that gain access to remote hosts through Infisical SSH. - Host: A remote machine (e.g. EC2 instance, GCP VM, Azure VM, on-prem Linux server, Raspberry Pi, VMware VM, etc.) that users need SSH access to that is registered with Infisical SSH. ## Workflow diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index c0f5f027d..c4defb68d 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -4,7 +4,8 @@ export { useLeaveProject, useMigrateProjectToV3, useRequestProjectAccess, - useUpdateGroupWorkspaceRole + useUpdateGroupWorkspaceRole, + useUpdateProjectSshConfig } from "./mutations"; export { useAddIdentityToWorkspace, @@ -14,6 +15,7 @@ export { useDeleteUserFromWorkspace, useDeleteWorkspace, useDeleteWsEnvironment, + useGetProjectSshConfig, useGetUpgradeProjectStatus, useGetUserWorkspaceMemberships, useGetUserWorkspaces, diff --git a/frontend/src/hooks/api/workspace/mutations.tsx b/frontend/src/hooks/api/workspace/mutations.tsx index 56f83f601..ea7376d3d 100644 --- a/frontend/src/hooks/api/workspace/mutations.tsx +++ b/frontend/src/hooks/api/workspace/mutations.tsx @@ -4,7 +4,11 @@ import { apiRequest } from "@app/config/request"; import { userKeys } from "../users/query-keys"; import { workspaceKeys } from "./query-keys"; -import { TUpdateWorkspaceGroupRoleDTO } from "./types"; +import { + TProjectSshConfig, + TUpdateProjectSshConfigDTO, + TUpdateWorkspaceGroupRoleDTO +} from "./types"; export const useAddGroupToWorkspace = () => { const queryClient = useQueryClient(); @@ -117,3 +121,20 @@ export const useRequestProjectAccess = () => { } }); }; + +export const useUpdateProjectSshConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ projectId, defaultUserSshCaId, defaultHostSshCaId }) => { + return apiRequest.patch(`/api/v1/workspace/${projectId}/ssh-config`, { + defaultUserSshCaId, + defaultHostSshCaId + }); + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getProjectSshConfig(projectId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index ca8feb6b6..8145e1904 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -34,6 +34,7 @@ import { TListProjectIdentitiesDTO, ToggleAutoCapitalizationDTO, ToggleDeleteProjectProtectionDTO, + TProjectSshConfig, TSearchProjectsDTO, TUpdateWorkspaceIdentityRoleDTO, TUpdateWorkspaceUserRoleDTO, @@ -887,3 +888,17 @@ export const useGetWorkspaceSlackConfig = ({ workspaceId }: { workspaceId: strin enabled: Boolean(workspaceId) }); }; + +export const useGetProjectSshConfig = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getProjectSshConfig(projectId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/workspace/${projectId}/ssh-config` + ); + + return data; + }, + enabled: Boolean(projectId) + }); +}; diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index 539ed2ac7..b23ca6868 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -69,5 +69,6 @@ export const workspaceKeys = { projectId: string; }) => [...workspaceKeys.allWorkspaceSshCertificates(projectId), { offset, limit }] as const, getWorkspaceSshCertificateTemplates: (projectId: string) => - [{ projectId }, "workspace-ssh-certificate-templates"] as const + [{ projectId }, "workspace-ssh-certificate-templates"] as const, + getProjectSshConfig: (projectId: string) => [{ projectId }, "project-ssh-config"] as const }; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 814a920c2..ddcf383fb 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -184,3 +184,18 @@ export type TSearchProjectsDTO = { orderBy?: ProjectIdentityOrderBy; orderDirection?: OrderByDirection; }; + +export type TProjectSshConfig = { + id: string; + createdAt: string; + updatedAt: string; + projectId: string; + defaultUserSshCaId: string | null; + defaultHostSshCaId: string | null; +}; + +export type TUpdateProjectSshConfigDTO = { + projectId: string; + defaultUserSshCaId?: string; + defaultHostSshCaId?: string; +}; diff --git a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx index 8b0d88ad5..b34c45a37 100644 --- a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx +++ b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link, Outlet, useRouterState } from "@tanstack/react-router"; import { motion } from "framer-motion"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { BreadcrumbContainer, Menu, @@ -11,7 +12,12 @@ import { MenuItem, TBreadcrumbFormat } from "@app/components/v2"; -import { useSubscription, useWorkspace } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; import { useGetAccessRequestsCount, useGetSecretApprovalRequestCount, @@ -159,22 +165,31 @@ export const ProjectLayout = () => { )} */} - {/* - {({ isActive }) => ( - - Certificate Authorities - - )} - */} + {(isAllowed) => + isAllowed && ( + + {({ isActive }) => ( + + Certificate Authorities + + )} + + ) + } + )} {isSecretManager && ( diff --git a/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx b/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx index dce31f01b..03d0e1c81 100644 --- a/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx @@ -1,11 +1,12 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectGeneralTab } from "./components/ProjectGeneralTab"; - -const tabs = [{ name: "General", key: "tab-project-general", Component: ProjectGeneralTab }]; +import { ProjectSshTab } from "./components/ProjectSshTab"; export const SettingsPage = () => { const { t } = useTranslation(); @@ -17,19 +18,28 @@ export const SettingsPage = () => {
- + - {tabs.map((tab) => ( - - {tab.name} - - ))} + General + + {(isAllowed) => isAllowed && SSH Settings} + - {tabs.map(({ key, Component }) => ( - - - - ))} + + + + + {(isAllowed) => + isAllowed && ( + + + + ) + } +
diff --git a/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/ProjectSshTab.tsx b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/ProjectSshTab.tsx new file mode 100644 index 000000000..8a79591bc --- /dev/null +++ b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/ProjectSshTab.tsx @@ -0,0 +1,9 @@ +import { ProjectSshConfigCasSection } from "./components"; + +export const ProjectSshTab = () => { + return ( +
+ +
+ ); +}; diff --git a/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/ProjectSshConfigCasSection.tsx b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/ProjectSshConfigCasSection.tsx new file mode 100644 index 000000000..36a6df348 --- /dev/null +++ b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/ProjectSshConfigCasSection.tsx @@ -0,0 +1,139 @@ +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 { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, FormControl, Select, SelectItem } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { + useGetProjectSshConfig, + useListWorkspaceSshCas, + useUpdateProjectSshConfig +} from "@app/hooks/api"; + +const schema = z + .object({ + defaultUserSshCaId: z.string().optional(), + defaultHostSshCaId: z.string().optional() + }) + .required(); + +export type FormData = z.infer; + +export const ProjectSshConfigCasSection = () => { + const { currentWorkspace } = useWorkspace(); + const { data: sshConfig } = useGetProjectSshConfig(currentWorkspace.id); + const { data: sshCas } = useListWorkspaceSshCas(currentWorkspace.id); + const { mutate: updateProjectSshConfig } = useUpdateProjectSshConfig(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + useEffect(() => { + if (sshConfig) { + reset({ + defaultUserSshCaId: sshConfig.defaultUserSshCaId || undefined, + defaultHostSshCaId: sshConfig.defaultHostSshCaId || undefined + }); + } + }, [sshConfig]); + + const onFormSubmit = async ({ defaultUserSshCaId, defaultHostSshCaId }: FormData) => { + try { + await updateProjectSshConfig({ + projectId: currentWorkspace.id, + defaultUserSshCaId: defaultUserSshCaId || undefined, + defaultHostSshCaId: defaultHostSshCaId || undefined + }); + + createNotification({ + text: "Successfully updated SSH project settings", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update SSH project settings", + type: "error" + }); + } + }; + + return ( +
+

Certificate Authorities

+
+ ( + + + + )} + /> + ( + + + + )} + /> + + {(isAllowed) => ( + + )} + + +
+ ); +}; diff --git a/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/index.tsx b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/index.tsx new file mode 100644 index 000000000..79010edb9 --- /dev/null +++ b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/index.tsx @@ -0,0 +1 @@ +export { ProjectSshConfigCasSection } from "./ProjectSshConfigCasSection"; diff --git a/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/index.tsx b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/index.tsx new file mode 100644 index 000000000..2fff359fe --- /dev/null +++ b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/index.tsx @@ -0,0 +1 @@ +export { ProjectSshTab } from "./ProjectSshTab";