Make infisical ssh v2 work in non-interactive mode, allow reassignment of default ssh cas

This commit is contained in:
Tuan Dang
2025-04-17 22:35:25 -07:00
parent 8cf125ed32
commit 0265665e83
18 changed files with 673 additions and 85 deletions
@@ -0,0 +1,49 @@
import { Knex } from "knex";
import { ProjectType, TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {}
@@ -965,7 +965,6 @@ const buildMemberPermissionRules = () => {
can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections);
can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateAuthorities);
can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificates); can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificates);
can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates); can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates);
can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateTemplates); can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateTemplates);
@@ -1031,7 +1030,6 @@ const buildViewerPermissionRules = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates);
can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateAuthorities);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates);
can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs); can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs);
@@ -6,6 +6,7 @@ import {
ProjectMembershipsSchema, ProjectMembershipsSchema,
ProjectRolesSchema, ProjectRolesSchema,
ProjectSlackConfigsSchema, ProjectSlackConfigsSchema,
ProjectSshConfigsSchema,
ProjectType, ProjectType,
SecretFoldersSchema, SecretFoldersSchema,
SortDirection, 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({ server.route({
method: "GET", method: "GET",
url: "/:workspaceId/slack-config", url: "/:workspaceId/slack-config",
+129 -2
View File
@@ -73,6 +73,7 @@ import {
TGetProjectDTO, TGetProjectDTO,
TGetProjectKmsKey, TGetProjectKmsKey,
TGetProjectSlackConfig, TGetProjectSlackConfig,
TGetProjectSshConfig,
TListProjectAlertsDTO, TListProjectAlertsDTO,
TListProjectCasDTO, TListProjectCasDTO,
TListProjectCertificateTemplatesDTO, TListProjectCertificateTemplatesDTO,
@@ -92,6 +93,7 @@ import {
TUpdateProjectKmsDTO, TUpdateProjectKmsDTO,
TUpdateProjectNameDTO, TUpdateProjectNameDTO,
TUpdateProjectSlackConfig, TUpdateProjectSlackConfig,
TUpdateProjectSshConfig,
TUpdateProjectVersionLimitDTO, TUpdateProjectVersionLimitDTO,
TUpgradeProjectDTO TUpgradeProjectDTO
} from "./project-types"; } from "./project-types";
@@ -104,7 +106,7 @@ export const DEFAULT_PROJECT_ENVS = [
type TProjectServiceFactoryDep = { type TProjectServiceFactoryDep = {
projectDAL: TProjectDALFactory; projectDAL: TProjectDALFactory;
projectSshConfigDAL: Pick<TProjectSshConfigDALFactory, "create">; projectSshConfigDAL: Pick<TProjectSshConfigDALFactory, "transaction" | "create" | "findOne" | "updateById">;
projectQueue: TProjectQueueFactory; projectQueue: TProjectQueueFactory;
userDAL: TUserDALFactory; userDAL: TUserDALFactory;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">; projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
@@ -129,7 +131,7 @@ type TProjectServiceFactoryDep = {
certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "getCertTemplatesByProjectId">; certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "getCertTemplatesByProjectId">;
pkiAlertDAL: Pick<TPkiAlertDALFactory, "find">; pkiAlertDAL: Pick<TPkiAlertDALFactory, "find">;
pkiCollectionDAL: Pick<TPkiCollectionDALFactory, "find">; pkiCollectionDAL: Pick<TPkiCollectionDALFactory, "find">;
sshCertificateAuthorityDAL: Pick<TSshCertificateAuthorityDALFactory, "find" | "create" | "transaction">; sshCertificateAuthorityDAL: Pick<TSshCertificateAuthorityDALFactory, "find" | "findOne" | "create" | "transaction">;
sshCertificateAuthoritySecretDAL: Pick<TSshCertificateAuthoritySecretDALFactory, "create">; sshCertificateAuthoritySecretDAL: Pick<TSshCertificateAuthoritySecretDALFactory, "create">;
sshCertificateDAL: Pick<TSshCertificateDALFactory, "find" | "countSshCertificatesInProject">; sshCertificateDAL: Pick<TSshCertificateDALFactory, "find" | "countSshCertificatesInProject">;
sshCertificateTemplateDAL: Pick<TSshCertificateTemplateDALFactory, "find">; sshCertificateTemplateDAL: Pick<TSshCertificateTemplateDALFactory, "find">;
@@ -1327,6 +1329,129 @@ export const projectServiceFactory = ({
return { secretManagerKmsKey: kmsKey }; 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 ({ const getProjectSlackConfig = async ({
actorId, actorId,
actor, actor,
@@ -1548,6 +1673,8 @@ export const projectServiceFactory = ({
getProjectKmsBackup, getProjectKmsBackup,
loadProjectKmsBackup, loadProjectKmsBackup,
getProjectKmsKeys, getProjectKmsKeys,
getProjectSshConfig,
updateProjectSshConfig,
getProjectSlackConfig, getProjectSlackConfig,
updateProjectSlackConfig, updateProjectSlackConfig,
requestProjectAccess, requestProjectAccess,
@@ -159,6 +159,13 @@ export type TListProjectSshCertificatesDTO = {
limit: number; limit: number;
} & TProjectPermission; } & TProjectPermission;
export type TUpdateProjectSshConfig = {
defaultUserSshCaId?: string;
defaultHostSshCaId?: string;
} & TProjectPermission;
export type TGetProjectSshConfig = TProjectPermission;
export type TGetProjectSlackConfig = TProjectPermission; export type TGetProjectSlackConfig = TProjectPermission;
export type TUpdateProjectSlackConfig = { export type TUpdateProjectSlackConfig = {
+147 -47
View File
@@ -177,7 +177,6 @@ func issueCredentials(cmd *cobra.Command, args []string) {
infisicalToken = token.Token infisicalToken = token.Token
} else { } else {
util.RequireLogin() util.RequireLogin()
util.RequireLocalWorkspaceFile()
loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
if err != nil { if err != nil {
@@ -411,7 +410,6 @@ func signKey(cmd *cobra.Command, args []string) {
infisicalToken = token.Token infisicalToken = token.Token
} else { } else {
util.RequireLogin() util.RequireLogin()
util.RequireLocalWorkspaceFile()
loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
if err != nil { if err != nil {
@@ -610,25 +608,82 @@ func signKey(cmd *cobra.Command, args []string) {
} }
func sshConnect(cmd *cobra.Command, args []string) { func sshConnect(cmd *cobra.Command, args []string) {
util.RequireLogin() token, err := util.GetInfisicalToken(cmd)
util.RequireLocalWorkspaceFile()
loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
if err != nil { if err != nil {
util.HandleError(err, "Unable to authenticate") util.HandleError(err, "Unable to parse flag")
} }
var infisicalToken string
if loggedInUserDetails.LoginExpired { if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) {
util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") 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") writeHostCaToFile, err := cmd.Flags().GetBool("writeHostCaToFile")
if err != nil { if err != nil {
util.HandleError(err, "Unable to parse --writeHostCaToFile flag") 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() customHeaders, err := util.GetInfisicalCustomHeadersMap()
if err != nil { if err != nil {
util.HandleError(err, "Unable to get custom headers") 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") util.PrintErrorMessageAndExit("You do not have access to any SSH hosts")
} }
// Prompt to select host var selectedHost = hosts[0]
hostNames := make([]string, len(hosts)) if hostname != "" {
for i, h := range hosts { foundHost := false
hostNames[i] = h.Hostname 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{ var selectedLoginUser string
Label: "Select an SSH Host", if loginUser != "" {
Items: hostNames, foundLoginUser := false
Size: 10, 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 // Issue SSH creds for host
creds, err := infisicalClient.Ssh().IssueSshHostUserCert(selectedHost.ID, infisicalSdk.IssueSshHostUserCertOptions{ 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") 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 // Load credentials into SSH agent
err = addCredentialsToAgent(creds.PrivateKey, creds.SignedKey) err = addCredentialsToAgent(creds.PrivateKey, creds.SignedKey)
if err != nil { if err != nil {
@@ -769,7 +866,6 @@ func sshAddHost(cmd *cobra.Command, args []string) {
infisicalToken = token.Token infisicalToken = token.Token
} else { } else {
util.RequireLogin() util.RequireLogin()
util.RequireLocalWorkspaceFile()
loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
if err != nil { if err != nil {
@@ -1006,7 +1102,11 @@ func init() {
sshIssueCredentialsCmd.Flags().Bool("addToAgent", false, "Whether to add issued SSH credentials to the SSH agent") sshIssueCredentialsCmd.Flags().Bool("addToAgent", false, "Whether to add issued SSH credentials to the SSH agent")
sshCmd.AddCommand(sshIssueCredentialsCmd) 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().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) sshCmd.AddCommand(sshConnectCmd)
sshAddHostCmd.Flags().String("token", "", "Use a machine identity access token") sshAddHostCmd.Flags().String("token", "", "Use a machine identity access token")
+2 -2
View File
@@ -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, and improves upon traditional SSH key-based authentication by mitigating private key compromise, static key management,
unauthorized access, and SSH key sprawl. 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. - 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. - 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 ## Workflow
+3 -1
View File
@@ -4,7 +4,8 @@ export {
useLeaveProject, useLeaveProject,
useMigrateProjectToV3, useMigrateProjectToV3,
useRequestProjectAccess, useRequestProjectAccess,
useUpdateGroupWorkspaceRole useUpdateGroupWorkspaceRole,
useUpdateProjectSshConfig
} from "./mutations"; } from "./mutations";
export { export {
useAddIdentityToWorkspace, useAddIdentityToWorkspace,
@@ -14,6 +15,7 @@ export {
useDeleteUserFromWorkspace, useDeleteUserFromWorkspace,
useDeleteWorkspace, useDeleteWorkspace,
useDeleteWsEnvironment, useDeleteWsEnvironment,
useGetProjectSshConfig,
useGetUpgradeProjectStatus, useGetUpgradeProjectStatus,
useGetUserWorkspaceMemberships, useGetUserWorkspaceMemberships,
useGetUserWorkspaces, useGetUserWorkspaces,
+22 -1
View File
@@ -4,7 +4,11 @@ import { apiRequest } from "@app/config/request";
import { userKeys } from "../users/query-keys"; import { userKeys } from "../users/query-keys";
import { workspaceKeys } from "./query-keys"; import { workspaceKeys } from "./query-keys";
import { TUpdateWorkspaceGroupRoleDTO } from "./types"; import {
TProjectSshConfig,
TUpdateProjectSshConfigDTO,
TUpdateWorkspaceGroupRoleDTO
} from "./types";
export const useAddGroupToWorkspace = () => { export const useAddGroupToWorkspace = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -117,3 +121,20 @@ export const useRequestProjectAccess = () => {
} }
}); });
}; };
export const useUpdateProjectSshConfig = () => {
const queryClient = useQueryClient();
return useMutation<TProjectSshConfig, object, TUpdateProjectSshConfigDTO>({
mutationFn: ({ projectId, defaultUserSshCaId, defaultHostSshCaId }) => {
return apiRequest.patch(`/api/v1/workspace/${projectId}/ssh-config`, {
defaultUserSshCaId,
defaultHostSshCaId
});
},
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getProjectSshConfig(projectId)
});
}
});
};
@@ -34,6 +34,7 @@ import {
TListProjectIdentitiesDTO, TListProjectIdentitiesDTO,
ToggleAutoCapitalizationDTO, ToggleAutoCapitalizationDTO,
ToggleDeleteProjectProtectionDTO, ToggleDeleteProjectProtectionDTO,
TProjectSshConfig,
TSearchProjectsDTO, TSearchProjectsDTO,
TUpdateWorkspaceIdentityRoleDTO, TUpdateWorkspaceIdentityRoleDTO,
TUpdateWorkspaceUserRoleDTO, TUpdateWorkspaceUserRoleDTO,
@@ -887,3 +888,17 @@ export const useGetWorkspaceSlackConfig = ({ workspaceId }: { workspaceId: strin
enabled: Boolean(workspaceId) enabled: Boolean(workspaceId)
}); });
}; };
export const useGetProjectSshConfig = (projectId: string) => {
return useQuery({
queryKey: workspaceKeys.getProjectSshConfig(projectId),
queryFn: async () => {
const { data } = await apiRequest.get<TProjectSshConfig>(
`/api/v1/workspace/${projectId}/ssh-config`
);
return data;
},
enabled: Boolean(projectId)
});
};
@@ -69,5 +69,6 @@ export const workspaceKeys = {
projectId: string; projectId: string;
}) => [...workspaceKeys.allWorkspaceSshCertificates(projectId), { offset, limit }] as const, }) => [...workspaceKeys.allWorkspaceSshCertificates(projectId), { offset, limit }] as const,
getWorkspaceSshCertificateTemplates: (projectId: string) => 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
}; };
+15
View File
@@ -184,3 +184,18 @@ export type TSearchProjectsDTO = {
orderBy?: ProjectIdentityOrderBy; orderBy?: ProjectIdentityOrderBy;
orderDirection?: OrderByDirection; 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;
};
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet, useRouterState } from "@tanstack/react-router"; import { Link, Outlet, useRouterState } from "@tanstack/react-router";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
BreadcrumbContainer, BreadcrumbContainer,
Menu, Menu,
@@ -11,7 +12,12 @@ import {
MenuItem, MenuItem,
TBreadcrumbFormat TBreadcrumbFormat
} from "@app/components/v2"; } from "@app/components/v2";
import { useSubscription, useWorkspace } from "@app/context"; import {
ProjectPermissionActions,
ProjectPermissionSub,
useSubscription,
useWorkspace
} from "@app/context";
import { import {
useGetAccessRequestsCount, useGetAccessRequestsCount,
useGetSecretApprovalRequestCount, useGetSecretApprovalRequestCount,
@@ -159,22 +165,31 @@ export const ProjectLayout = () => {
</MenuItem> </MenuItem>
)} )}
</Link> */} </Link> */}
{/* <Link <ProjectPermissionCan
to={`/${ProjectType.SSH}/$projectId/cas` as const} I={ProjectPermissionActions.Read}
params={{ a={ProjectPermissionSub.SshCertificateAuthorities}
projectId: currentWorkspace.id
}}
> >
{({ isActive }) => ( {(isAllowed) =>
<MenuItem isAllowed && (
isSelected={isActive} <Link
icon="certificate-authority" to={`/${ProjectType.SSH}/$projectId/cas` as const}
iconMode="reverse" params={{
> projectId: currentWorkspace.id
Certificate Authorities }}
</MenuItem> >
)} {({ isActive }) => (
</Link> */} <MenuItem
isSelected={isActive}
icon="certificate-authority"
iconMode="reverse"
>
Certificate Authorities
</MenuItem>
)}
</Link>
)
}
</ProjectPermissionCan>
</> </>
)} )}
{isSecretManager && ( {isSecretManager && (
@@ -1,11 +1,12 @@
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { ProjectGeneralTab } from "./components/ProjectGeneralTab"; import { ProjectGeneralTab } from "./components/ProjectGeneralTab";
import { ProjectSshTab } from "./components/ProjectSshTab";
const tabs = [{ name: "General", key: "tab-project-general", Component: ProjectGeneralTab }];
export const SettingsPage = () => { export const SettingsPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -17,19 +18,28 @@ export const SettingsPage = () => {
</Helmet> </Helmet>
<div className="w-full max-w-7xl"> <div className="w-full max-w-7xl">
<PageHeader title={t("settings.project.title")} /> <PageHeader title={t("settings.project.title")} />
<Tabs defaultValue={tabs[0].key}> <Tabs defaultValue="tab-project-general">
<TabList> <TabList>
{tabs.map((tab) => ( <Tab value="tab-project-general">General</Tab>
<Tab value={tab.key} key={tab.key}> <ProjectPermissionCan
{tab.name} I={ProjectPermissionActions.Edit}
</Tab> a={ProjectPermissionSub.Project}
))} >
{(isAllowed) => isAllowed && <Tab value="tab-project-ssh">SSH Settings</Tab>}
</ProjectPermissionCan>
</TabList> </TabList>
{tabs.map(({ key, Component }) => ( <TabPanel value="tab-project-general">
<TabPanel value={key} key={key}> <ProjectGeneralTab />
<Component /> </TabPanel>
</TabPanel> <ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}>
))} {(isAllowed) =>
isAllowed && (
<TabPanel value="tab-project-ssh">
<ProjectSshTab />
</TabPanel>
)
}
</ProjectPermissionCan>
</Tabs> </Tabs>
</div> </div>
</div> </div>
@@ -0,0 +1,9 @@
import { ProjectSshConfigCasSection } from "./components";
export const ProjectSshTab = () => {
return (
<div>
<ProjectSshConfigCasSection />
</div>
);
};
@@ -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<typeof schema>;
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<FormData>({
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 (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-8 text-xl font-semibold">Certificate Authorities</p>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="defaultUserSshCaId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Default User CA"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="min-w-[20rem]"
>
{sshCas?.map(({ id, friendlyName }) => (
<SelectItem value={String(id || "")} key={friendlyName}>
{friendlyName}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="defaultHostSshCaId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Default Host CA"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="min-w-[20rem]"
>
{sshCas?.map(({ id, friendlyName }) => (
<SelectItem value={String(id || "")} key={friendlyName}>
{friendlyName}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isAllowed}
>
Save
</Button>
)}
</ProjectPermissionCan>
</form>
</div>
);
};
@@ -0,0 +1 @@
export { ProjectSshConfigCasSection } from "./ProjectSshConfigCasSection";
@@ -0,0 +1 @@
export { ProjectSshTab } from "./ProjectSshTab";