mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 21:40:31 +00:00
Add list accessible ssh hosts endpoint
This commit is contained in:
@@ -11,6 +11,69 @@ export type TSshHostDALFactory = ReturnType<typeof sshHostDALFactory>;
|
||||
export const sshHostDALFactory = (db: TDbClient) => {
|
||||
const sshHostOrm = ormify(db, TableName.SshHost);
|
||||
|
||||
const findSshHostsWithPrincipalsAcrossProjects = async (projectIds: string[], principals: string[], tx?: Knex) => {
|
||||
try {
|
||||
const matchingSshHosts = await (tx || db.replicaNode())(TableName.SshHost)
|
||||
.leftJoin(
|
||||
TableName.SshHostLoginMapping,
|
||||
`${TableName.SshHost}.id`,
|
||||
`${TableName.SshHostLoginMapping}.sshHostId`
|
||||
)
|
||||
.whereIn(`${TableName.SshHost}.projectId`, projectIds)
|
||||
.whereRaw(`"${TableName.SshHostLoginMapping}"."allowedPrincipals" && ?::text[]`, [principals])
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.SshHost).as("sshHostId"),
|
||||
db.ref("projectId").withSchema(TableName.SshHost),
|
||||
db.ref("hostname").withSchema(TableName.SshHost),
|
||||
db.ref("userCertTtl").withSchema(TableName.SshHost),
|
||||
db.ref("hostCertTtl").withSchema(TableName.SshHost),
|
||||
db.ref("loginUser").withSchema(TableName.SshHostLoginMapping),
|
||||
db.ref("allowedPrincipals").withSchema(TableName.SshHostLoginMapping),
|
||||
db.ref("userSshCaId").withSchema(TableName.SshHost),
|
||||
db.ref("hostSshCaId").withSchema(TableName.SshHost)
|
||||
)
|
||||
.orderBy(`${TableName.SshHost}.updatedAt`, "desc");
|
||||
|
||||
const grouped = groupBy(matchingSshHosts, (r) => r.sshHostId);
|
||||
return Object.values(grouped).map((hostRows) => {
|
||||
const { sshHostId, hostname, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId, projectId } = hostRows[0];
|
||||
|
||||
const loginMappingGrouped = groupBy(
|
||||
hostRows.filter((r) => r.loginUser),
|
||||
(r) => r.loginUser
|
||||
);
|
||||
|
||||
const loginMappings = Object.entries(loginMappingGrouped)
|
||||
.map(([loginUser, entries]) => {
|
||||
const filteredPrincipals = unique(entries.flatMap((entry) => entry.allowedPrincipals ?? [])).filter(
|
||||
(principal) => principals.includes(principal)
|
||||
);
|
||||
|
||||
if (filteredPrincipals.length === 0) return null;
|
||||
|
||||
return {
|
||||
loginUser,
|
||||
allowedPrincipals: filteredPrincipals
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as { loginUser: string; allowedPrincipals: string[] }[];
|
||||
|
||||
return {
|
||||
id: sshHostId,
|
||||
hostname,
|
||||
projectId,
|
||||
userCertTtl,
|
||||
hostCertTtl,
|
||||
loginMappings,
|
||||
userSshCaId,
|
||||
hostSshCaId
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: `${TableName.SshHost}: FindSshHostsWithPrincipalsAcrossProjects` });
|
||||
}
|
||||
};
|
||||
|
||||
const findSshHostsWithLoginMappings = async (projectId: string, tx?: Knex) => {
|
||||
try {
|
||||
const rows = await (tx || db.replicaNode())(TableName.SshHost)
|
||||
@@ -27,13 +90,15 @@ export const sshHostDALFactory = (db: TDbClient) => {
|
||||
db.ref("userCertTtl").withSchema(TableName.SshHost),
|
||||
db.ref("hostCertTtl").withSchema(TableName.SshHost),
|
||||
db.ref("loginUser").withSchema(TableName.SshHostLoginMapping),
|
||||
db.ref("allowedPrincipals").withSchema(TableName.SshHostLoginMapping)
|
||||
db.ref("allowedPrincipals").withSchema(TableName.SshHostLoginMapping),
|
||||
db.ref("userSshCaId").withSchema(TableName.SshHost),
|
||||
db.ref("hostSshCaId").withSchema(TableName.SshHost)
|
||||
)
|
||||
.orderBy(`${TableName.SshHost}.updatedAt`, "desc");
|
||||
|
||||
const hostsGrouped = groupBy(rows, (r) => r.sshHostId);
|
||||
return Object.values(hostsGrouped).map((hostRows) => {
|
||||
const { sshHostId, hostname, userCertTtl, hostCertTtl } = hostRows[0];
|
||||
const { sshHostId, hostname, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = hostRows[0];
|
||||
|
||||
const loginMappingGrouped = groupBy(
|
||||
hostRows.filter((r) => r.loginUser),
|
||||
@@ -51,7 +116,9 @@ export const sshHostDALFactory = (db: TDbClient) => {
|
||||
hostname,
|
||||
userCertTtl,
|
||||
hostCertTtl,
|
||||
loginMappings
|
||||
loginMappings,
|
||||
userSshCaId,
|
||||
hostSshCaId
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -75,12 +142,14 @@ export const sshHostDALFactory = (db: TDbClient) => {
|
||||
db.ref("userCertTtl").withSchema(TableName.SshHost),
|
||||
db.ref("hostCertTtl").withSchema(TableName.SshHost),
|
||||
db.ref("loginUser").withSchema(TableName.SshHostLoginMapping),
|
||||
db.ref("allowedPrincipals").withSchema(TableName.SshHostLoginMapping)
|
||||
db.ref("allowedPrincipals").withSchema(TableName.SshHostLoginMapping),
|
||||
db.ref("userSshCaId").withSchema(TableName.SshHost),
|
||||
db.ref("hostSshCaId").withSchema(TableName.SshHost)
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const { sshHostId: id, projectId, hostname, userCertTtl, hostCertTtl } = rows[0];
|
||||
const { sshHostId: id, projectId, hostname, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = rows[0];
|
||||
|
||||
const loginMappingGrouped = groupBy(
|
||||
rows.filter((r) => r.loginUser),
|
||||
@@ -98,7 +167,9 @@ export const sshHostDALFactory = (db: TDbClient) => {
|
||||
hostname,
|
||||
userCertTtl,
|
||||
hostCertTtl,
|
||||
loginMappings
|
||||
loginMappings,
|
||||
userSshCaId,
|
||||
hostSshCaId
|
||||
};
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: `${TableName.SshHost}: FindSshHostByIdWithLoginMappings` });
|
||||
@@ -107,6 +178,7 @@ export const sshHostDALFactory = (db: TDbClient) => {
|
||||
|
||||
return {
|
||||
...sshHostOrm,
|
||||
findSshHostsWithPrincipalsAcrossProjects,
|
||||
findSshHostsWithLoginMappings,
|
||||
findSshHostByIdWithLoginMappings
|
||||
};
|
||||
|
||||
@@ -1,24 +1,54 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { ActionProjectType, ProjectType } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal";
|
||||
import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal";
|
||||
import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types";
|
||||
import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal";
|
||||
import { TSshHostLoginMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-mapping-dal";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TProjectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal";
|
||||
import { TUserDALFactory } from "@app/services/user/user-dal";
|
||||
|
||||
import { TCreateSshHostDTO, TDeleteSshHostDTO, TGetSshHostDTO, TUpdateSshHostDTO } from "./ssh-host-types";
|
||||
import { convertActorToPrincipals, createSshCert, createSshKeyPair } from "../ssh/ssh-certificate-authority-fns";
|
||||
import { SshCertType } from "../ssh/ssh-certificate-authority-types";
|
||||
import {
|
||||
TCreateSshHostDTO,
|
||||
TDeleteSshHostDTO,
|
||||
TGetSshHostDTO,
|
||||
TIssueSshCredsFromHostDTO,
|
||||
TListSshHostsDTO,
|
||||
TUpdateSshHostDTO
|
||||
} from "./ssh-host-types";
|
||||
|
||||
type TSshCertificateAuthorityServiceFactoryDep = {
|
||||
userDAL: Pick<TUserDALFactory, "findById">;
|
||||
projectDAL: Pick<TProjectDALFactory, "find">;
|
||||
projectSshConfigDAL: Pick<TProjectSshConfigDALFactory, "findOne">;
|
||||
sshCertificateAuthorityDAL: Pick<TSshCertificateAuthorityDALFactory, "findById">;
|
||||
sshCertificateAuthoritySecretDAL: Pick<TSshCertificateAuthoritySecretDALFactory, "findOne">;
|
||||
sshHostDAL: Pick<
|
||||
TSshHostDALFactory,
|
||||
"transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" | "findSshHostByIdWithLoginMappings"
|
||||
| "transaction"
|
||||
| "create"
|
||||
| "findById"
|
||||
| "updateById"
|
||||
| "deleteById"
|
||||
| "findOne"
|
||||
| "findSshHostByIdWithLoginMappings"
|
||||
| "findSshHostsWithPrincipalsAcrossProjects"
|
||||
>;
|
||||
sshHostLoginMappingDAL: Pick<
|
||||
TSshHostLoginMappingDALFactory,
|
||||
"transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" | "insertMany" | "delete"
|
||||
>;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
};
|
||||
|
||||
export type TSshHostServiceFactory = ReturnType<typeof sshHostServiceFactory>;
|
||||
@@ -29,17 +59,72 @@ export type TSshHostServiceFactory = ReturnType<typeof sshHostServiceFactory>;
|
||||
*/
|
||||
|
||||
export const sshHostServiceFactory = ({
|
||||
projectDAL
|
||||
userDAL,
|
||||
projectDAL,
|
||||
projectSshConfigDAL,
|
||||
sshCertificateAuthorityDAL,
|
||||
sshCertificateAuthoritySecretDAL,
|
||||
sshHostDAL,
|
||||
sshHostLoginMappingDAL,
|
||||
permissionService
|
||||
permissionService,
|
||||
kmsService
|
||||
}: TSshCertificateAuthorityServiceFactoryDep) => {
|
||||
/**
|
||||
* Return list of all SSH hosts that a user has access to across all SSH projects in the organization
|
||||
*/
|
||||
const listSshHosts = async ({ actorId, actorAuthMethod, actor, actorOrgId }: TListSshHostsDTO) => {
|
||||
const sshProjects = await projectDAL.find({
|
||||
orgId: actorOrgId,
|
||||
type: ProjectType.SSH
|
||||
});
|
||||
|
||||
const projectIdsWithAccess: string[] = [];
|
||||
|
||||
for await (const project of sshProjects) {
|
||||
let hasAccess = false;
|
||||
|
||||
try {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: project.id,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.SSH
|
||||
});
|
||||
|
||||
// TODO: consider glob-based permission items
|
||||
hasAccess = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SshHosts);
|
||||
} catch {
|
||||
hasAccess = false;
|
||||
}
|
||||
|
||||
if (hasAccess) {
|
||||
projectIdsWithAccess.push(project.id);
|
||||
}
|
||||
}
|
||||
|
||||
// const principals = await convertActorToPrincipals({
|
||||
// actor,
|
||||
// actorId,
|
||||
// userDAL
|
||||
// });
|
||||
|
||||
const hosts = await sshHostDAL.findSshHostsWithPrincipalsAcrossProjects(projectIdsWithAccess, [
|
||||
"[email protected]" // hardcode for now
|
||||
]);
|
||||
|
||||
return hosts;
|
||||
};
|
||||
|
||||
const createSshHost = async ({
|
||||
projectId,
|
||||
hostname,
|
||||
userCertTtl,
|
||||
hostCertTtl,
|
||||
loginMappings,
|
||||
userSshCaId: requestedUserSshCaId,
|
||||
hostSshCaId: requestedHostSshCaId,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
@@ -56,6 +141,42 @@ export const sshHostServiceFactory = ({
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.SshHosts);
|
||||
|
||||
const resolveSshCaId = async ({
|
||||
requestedId,
|
||||
fallbackId,
|
||||
label
|
||||
}: {
|
||||
requestedId?: string;
|
||||
fallbackId?: string | null;
|
||||
label: "User" | "Host";
|
||||
}) => {
|
||||
const finalId = requestedId ?? fallbackId;
|
||||
if (!finalId) {
|
||||
throw new BadRequestError({ message: `Missing ${label.toLowerCase()} SSH CA` });
|
||||
}
|
||||
|
||||
const ca = await sshCertificateAuthorityDAL.findById(finalId);
|
||||
if (!ca) {
|
||||
throw new BadRequestError({ message: `${label} SSH CA with ID '${finalId}' not found` });
|
||||
}
|
||||
|
||||
return ca.id;
|
||||
};
|
||||
|
||||
const projectSshConfig = await projectSshConfigDAL.findOne({ projectId });
|
||||
|
||||
const userSshCaId = await resolveSshCaId({
|
||||
requestedId: requestedUserSshCaId,
|
||||
fallbackId: projectSshConfig?.defaultUserSshCaId,
|
||||
label: "User"
|
||||
});
|
||||
|
||||
const hostSshCaId = await resolveSshCaId({
|
||||
requestedId: requestedHostSshCaId,
|
||||
fallbackId: projectSshConfig?.defaultHostSshCaId,
|
||||
label: "Host"
|
||||
});
|
||||
|
||||
const newSshHost = await sshHostDAL.transaction(async (tx) => {
|
||||
const existingHost = await sshHostDAL.findOne(
|
||||
{
|
||||
@@ -71,16 +192,14 @@ export const sshHostServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
// attach hosts?
|
||||
|
||||
// create host ssh cas in default bound to default user ca and host ca
|
||||
|
||||
const host = await sshHostDAL.create(
|
||||
{
|
||||
projectId,
|
||||
hostname,
|
||||
userCertTtl,
|
||||
hostCertTtl
|
||||
hostCertTtl,
|
||||
userSshCaId,
|
||||
hostSshCaId
|
||||
},
|
||||
tx
|
||||
);
|
||||
@@ -211,10 +330,88 @@ export const sshHostServiceFactory = ({
|
||||
return host;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return SSH certificate and corresponding new SSH public-private key pair where
|
||||
* SSH public key is signed using CA behind SSH certificate with name [templateName].
|
||||
*
|
||||
* Note: Used for issuing SSH credentials as part of request against a specific SSH Host.
|
||||
*/
|
||||
const issueSshCredsFromHost = async ({
|
||||
sshHostId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TIssueSshCredsFromHostDTO) => {
|
||||
const host = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId);
|
||||
if (!host) {
|
||||
throw new NotFoundError({
|
||||
message: `SSH host with ID ${sshHostId} not found`
|
||||
});
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: host.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.SSH
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHosts);
|
||||
|
||||
// TODO: update permissions
|
||||
|
||||
const keyId = `${actor}-${actorId}`;
|
||||
|
||||
const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: host.userSshCaId });
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: host.projectId
|
||||
});
|
||||
|
||||
const decryptedCaPrivateKey = secretManagerDecryptor({
|
||||
cipherTextBlob: sshCaSecret.encryptedPrivateKey
|
||||
});
|
||||
|
||||
// create user key pair
|
||||
const keyAlgorithm = SshCertKeyAlgorithm.ED25519; // (dangtony98): will support more algorithms in the future
|
||||
const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm);
|
||||
|
||||
const principals = await convertActorToPrincipals({
|
||||
actor,
|
||||
actorId,
|
||||
userDAL
|
||||
});
|
||||
|
||||
const { serialNumber, signedPublicKey, ttl } = await createSshCert({
|
||||
caPrivateKey: decryptedCaPrivateKey.toString("utf8"),
|
||||
clientPublicKey: publicKey,
|
||||
keyId,
|
||||
principals,
|
||||
requestedTtl: host.userCertTtl,
|
||||
certType: SshCertType.USER
|
||||
});
|
||||
|
||||
return {
|
||||
serialNumber,
|
||||
signedPublicKey,
|
||||
privateKey,
|
||||
publicKey,
|
||||
ttl,
|
||||
keyId,
|
||||
keyAlgorithm
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
listSshHosts,
|
||||
createSshHost,
|
||||
updateSshHost,
|
||||
deleteSshHost,
|
||||
getSshHost
|
||||
getSshHost,
|
||||
issueSshCredsFromHost
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
export type TListSshHostsDTO = Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TCreateSshHostDTO = {
|
||||
hostname: string;
|
||||
userCertTtl: string;
|
||||
@@ -8,6 +10,8 @@ export type TCreateSshHostDTO = {
|
||||
loginUser: string;
|
||||
allowedPrincipals: string[];
|
||||
}[];
|
||||
userSshCaId?: string;
|
||||
hostSshCaId?: string;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TUpdateSshHostDTO = {
|
||||
@@ -28,3 +32,7 @@ export type TGetSshHostDTO = {
|
||||
export type TDeleteSshHostDTO = {
|
||||
sshHostId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TIssueSshCredsFromHostDTO = {
|
||||
sshHostId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
Reference in New Issue
Block a user