mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 09:35:37 +00:00
Merge remote-tracking branch 'origin/main' into feat/resource-metadata
This commit is contained in:
@@ -2,9 +2,14 @@ import {
|
||||
TCreateProjectTemplateDTO,
|
||||
TUpdateProjectTemplateDTO
|
||||
} from "@app/ee/services/project-template/project-template-types";
|
||||
import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types";
|
||||
import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types";
|
||||
import { SymmetricEncryption } from "@app/lib/crypto/cipher";
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { TCreateAppConnectionDTO, TUpdateAppConnectionDTO } from "@app/services/app-connection/app-connection-types";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
|
||||
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types";
|
||||
import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
|
||||
import { PkiItemType } from "@app/services/pki-collection/pki-collection-types";
|
||||
@@ -143,6 +148,17 @@ export enum EventType {
|
||||
SECRET_APPROVAL_REQUEST = "secret-approval-request",
|
||||
SECRET_APPROVAL_CLOSED = "secret-approval-closed",
|
||||
SECRET_APPROVAL_REOPENED = "secret-approval-reopened",
|
||||
SIGN_SSH_KEY = "sign-ssh-key",
|
||||
ISSUE_SSH_CREDS = "issue-ssh-creds",
|
||||
CREATE_SSH_CA = "create-ssh-certificate-authority",
|
||||
GET_SSH_CA = "get-ssh-certificate-authority",
|
||||
UPDATE_SSH_CA = "update-ssh-certificate-authority",
|
||||
DELETE_SSH_CA = "delete-ssh-certificate-authority",
|
||||
GET_SSH_CA_CERTIFICATE_TEMPLATES = "get-ssh-certificate-authority-certificate-templates",
|
||||
CREATE_SSH_CERTIFICATE_TEMPLATE = "create-ssh-certificate-template",
|
||||
UPDATE_SSH_CERTIFICATE_TEMPLATE = "update-ssh-certificate-template",
|
||||
DELETE_SSH_CERTIFICATE_TEMPLATE = "delete-ssh-certificate-template",
|
||||
GET_SSH_CERTIFICATE_TEMPLATE = "get-ssh-certificate-template",
|
||||
CREATE_CA = "create-certificate-authority",
|
||||
GET_CA = "get-certificate-authority",
|
||||
UPDATE_CA = "update-certificate-authority",
|
||||
@@ -208,7 +224,12 @@ export enum EventType {
|
||||
CREATE_PROJECT_TEMPLATE = "create-project-template",
|
||||
UPDATE_PROJECT_TEMPLATE = "update-project-template",
|
||||
DELETE_PROJECT_TEMPLATE = "delete-project-template",
|
||||
APPLY_PROJECT_TEMPLATE = "apply-project-template"
|
||||
APPLY_PROJECT_TEMPLATE = "apply-project-template",
|
||||
GET_APP_CONNECTIONS = "get-app-connections",
|
||||
GET_APP_CONNECTION = "get-app-connection",
|
||||
CREATE_APP_CONNECTION = "create-app-connection",
|
||||
UPDATE_APP_CONNECTION = "update-app-connection",
|
||||
DELETE_APP_CONNECTION = "delete-app-connection"
|
||||
}
|
||||
|
||||
interface UserActorMetadata {
|
||||
@@ -1206,6 +1227,117 @@ interface SecretApprovalRequest {
|
||||
};
|
||||
}
|
||||
|
||||
interface SignSshKey {
|
||||
type: EventType.SIGN_SSH_KEY;
|
||||
metadata: {
|
||||
certificateTemplateId: string;
|
||||
certType: SshCertType;
|
||||
principals: string[];
|
||||
ttl: string;
|
||||
keyId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface IssueSshCreds {
|
||||
type: EventType.ISSUE_SSH_CREDS;
|
||||
metadata: {
|
||||
certificateTemplateId: string;
|
||||
keyAlgorithm: CertKeyAlgorithm;
|
||||
certType: SshCertType;
|
||||
principals: string[];
|
||||
ttl: string;
|
||||
keyId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateSshCa {
|
||||
type: EventType.CREATE_SSH_CA;
|
||||
metadata: {
|
||||
sshCaId: string;
|
||||
friendlyName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetSshCa {
|
||||
type: EventType.GET_SSH_CA;
|
||||
metadata: {
|
||||
sshCaId: string;
|
||||
friendlyName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateSshCa {
|
||||
type: EventType.UPDATE_SSH_CA;
|
||||
metadata: {
|
||||
sshCaId: string;
|
||||
friendlyName: string;
|
||||
status: SshCaStatus;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteSshCa {
|
||||
type: EventType.DELETE_SSH_CA;
|
||||
metadata: {
|
||||
sshCaId: string;
|
||||
friendlyName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetSshCaCertificateTemplates {
|
||||
type: EventType.GET_SSH_CA_CERTIFICATE_TEMPLATES;
|
||||
metadata: {
|
||||
sshCaId: string;
|
||||
friendlyName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateSshCertificateTemplate {
|
||||
type: EventType.CREATE_SSH_CERTIFICATE_TEMPLATE;
|
||||
metadata: {
|
||||
certificateTemplateId: string;
|
||||
sshCaId: string;
|
||||
name: string;
|
||||
ttl: string;
|
||||
maxTTL: string;
|
||||
allowedUsers: string[];
|
||||
allowedHosts: string[];
|
||||
allowUserCertificates: boolean;
|
||||
allowHostCertificates: boolean;
|
||||
allowCustomKeyIds: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetSshCertificateTemplate {
|
||||
type: EventType.GET_SSH_CERTIFICATE_TEMPLATE;
|
||||
metadata: {
|
||||
certificateTemplateId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateSshCertificateTemplate {
|
||||
type: EventType.UPDATE_SSH_CERTIFICATE_TEMPLATE;
|
||||
metadata: {
|
||||
certificateTemplateId: string;
|
||||
sshCaId: string;
|
||||
name: string;
|
||||
status: SshCertTemplateStatus;
|
||||
ttl: string;
|
||||
maxTTL: string;
|
||||
allowedUsers: string[];
|
||||
allowedHosts: string[];
|
||||
allowUserCertificates: boolean;
|
||||
allowHostCertificates: boolean;
|
||||
allowCustomKeyIds: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteSshCertificateTemplate {
|
||||
type: EventType.DELETE_SSH_CERTIFICATE_TEMPLATE;
|
||||
metadata: {
|
||||
certificateTemplateId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateCa {
|
||||
type: EventType.CREATE_CA;
|
||||
metadata: {
|
||||
@@ -1742,6 +1874,39 @@ interface ApplyProjectTemplateEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface GetAppConnectionsEvent {
|
||||
type: EventType.GET_APP_CONNECTIONS;
|
||||
metadata: {
|
||||
app?: AppConnection;
|
||||
count: number;
|
||||
connectionIds: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface GetAppConnectionEvent {
|
||||
type: EventType.GET_APP_CONNECTION;
|
||||
metadata: {
|
||||
connectionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateAppConnectionEvent {
|
||||
type: EventType.CREATE_APP_CONNECTION;
|
||||
metadata: Omit<TCreateAppConnectionDTO, "credentials"> & { connectionId: string };
|
||||
}
|
||||
|
||||
interface UpdateAppConnectionEvent {
|
||||
type: EventType.UPDATE_APP_CONNECTION;
|
||||
metadata: Omit<TUpdateAppConnectionDTO, "credentials"> & { connectionId: string; credentialsUpdated: boolean };
|
||||
}
|
||||
|
||||
interface DeleteAppConnectionEvent {
|
||||
type: EventType.DELETE_APP_CONNECTION;
|
||||
metadata: {
|
||||
connectionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| GetSecretsEvent
|
||||
| GetSecretEvent
|
||||
@@ -1837,6 +2002,17 @@ export type Event =
|
||||
| SecretApprovalClosed
|
||||
| SecretApprovalRequest
|
||||
| SecretApprovalReopened
|
||||
| SignSshKey
|
||||
| IssueSshCreds
|
||||
| CreateSshCa
|
||||
| GetSshCa
|
||||
| UpdateSshCa
|
||||
| DeleteSshCa
|
||||
| GetSshCaCertificateTemplates
|
||||
| CreateSshCertificateTemplate
|
||||
| UpdateSshCertificateTemplate
|
||||
| GetSshCertificateTemplate
|
||||
| DeleteSshCertificateTemplate
|
||||
| CreateCa
|
||||
| GetCa
|
||||
| UpdateCa
|
||||
@@ -1902,4 +2078,9 @@ export type Event =
|
||||
| CreateProjectTemplateEvent
|
||||
| UpdateProjectTemplateEvent
|
||||
| DeleteProjectTemplateEvent
|
||||
| ApplyProjectTemplateEvent;
|
||||
| ApplyProjectTemplateEvent
|
||||
| GetAppConnectionsEvent
|
||||
| GetAppConnectionEvent
|
||||
| CreateAppConnectionEvent
|
||||
| UpdateAppConnectionEvent
|
||||
| DeleteAppConnectionEvent;
|
||||
|
||||
@@ -49,7 +49,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
||||
},
|
||||
pkiEst: false,
|
||||
enforceMfa: false,
|
||||
projectTemplates: false
|
||||
projectTemplates: false,
|
||||
appConnections: false
|
||||
});
|
||||
|
||||
export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => {
|
||||
|
||||
@@ -67,6 +67,7 @@ export type TFeatureSet = {
|
||||
pkiEst: boolean;
|
||||
enforceMfa: boolean;
|
||||
projectTemplates: false;
|
||||
appConnections: false; // TODO: remove once live
|
||||
};
|
||||
|
||||
export type TOrgPlansTableDTO = {
|
||||
|
||||
@@ -27,7 +27,8 @@ export enum OrgPermissionSubjects {
|
||||
Kms = "kms",
|
||||
AdminConsole = "organization-admin-console",
|
||||
AuditLogs = "audit-logs",
|
||||
ProjectTemplates = "project-templates"
|
||||
ProjectTemplates = "project-templates",
|
||||
AppConnections = "app-connections"
|
||||
}
|
||||
|
||||
export type OrgPermissionSet =
|
||||
@@ -46,6 +47,7 @@ export type OrgPermissionSet =
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.AppConnections]
|
||||
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole];
|
||||
|
||||
const buildAdminPermission = () => {
|
||||
@@ -123,6 +125,11 @@ const buildAdminPermission = () => {
|
||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates);
|
||||
can(OrgPermissionActions.Delete, OrgPermissionSubjects.ProjectTemplates);
|
||||
|
||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections);
|
||||
can(OrgPermissionActions.Create, OrgPermissionSubjects.AppConnections);
|
||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.AppConnections);
|
||||
can(OrgPermissionActions.Delete, OrgPermissionSubjects.AppConnections);
|
||||
|
||||
can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole);
|
||||
|
||||
return rules;
|
||||
@@ -153,6 +160,8 @@ const buildMemberPermission = () => {
|
||||
|
||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
|
||||
|
||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections);
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ export enum ProjectPermissionSub {
|
||||
CertificateAuthorities = "certificate-authorities",
|
||||
Certificates = "certificates",
|
||||
CertificateTemplates = "certificate-templates",
|
||||
SshCertificateAuthorities = "ssh-certificate-authorities",
|
||||
SshCertificates = "ssh-certificates",
|
||||
SshCertificateTemplates = "ssh-certificate-templates",
|
||||
PkiAlerts = "pki-alerts",
|
||||
PkiCollections = "pki-collections",
|
||||
Kms = "kms",
|
||||
@@ -132,6 +135,9 @@ export type ProjectPermissionSet =
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Certificates]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificates]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
|
||||
| [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek]
|
||||
@@ -338,6 +344,28 @@ const GeneralPermissionSchema = [
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z
|
||||
.literal(ProjectPermissionSub.SshCertificateAuthorities)
|
||||
.describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.SshCertificates).describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z
|
||||
.literal(ProjectPermissionSub.SshCertificateTemplates)
|
||||
.describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.PkiAlerts).describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
|
||||
@@ -480,7 +508,10 @@ const buildAdminPermissionRules = () => {
|
||||
ProjectPermissionSub.Certificates,
|
||||
ProjectPermissionSub.CertificateTemplates,
|
||||
ProjectPermissionSub.PkiAlerts,
|
||||
ProjectPermissionSub.PkiCollections
|
||||
ProjectPermissionSub.PkiCollections,
|
||||
ProjectPermissionSub.SshCertificateAuthorities,
|
||||
ProjectPermissionSub.SshCertificates,
|
||||
ProjectPermissionSub.SshCertificateTemplates
|
||||
].forEach((el) => {
|
||||
can(
|
||||
[
|
||||
@@ -665,6 +696,11 @@ 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);
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionCmekActions.Create,
|
||||
@@ -707,6 +743,9 @@ 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);
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
export type TSshCertificateTemplateDALFactory = ReturnType<typeof sshCertificateTemplateDALFactory>;
|
||||
|
||||
export const sshCertificateTemplateDALFactory = (db: TDbClient) => {
|
||||
const sshCertificateTemplateOrm = ormify(db, TableName.SshCertificateTemplate);
|
||||
|
||||
const getById = async (id: string, tx?: Knex) => {
|
||||
try {
|
||||
const certTemplate = await (tx || db.replicaNode())(TableName.SshCertificateTemplate)
|
||||
.join(
|
||||
TableName.SshCertificateAuthority,
|
||||
`${TableName.SshCertificateAuthority}.id`,
|
||||
`${TableName.SshCertificateTemplate}.sshCaId`
|
||||
)
|
||||
.join(TableName.Project, `${TableName.Project}.id`, `${TableName.SshCertificateAuthority}.projectId`)
|
||||
.where(`${TableName.SshCertificateTemplate}.id`, "=", id)
|
||||
.select(selectAllTableCols(TableName.SshCertificateTemplate))
|
||||
.select(
|
||||
db.ref("projectId").withSchema(TableName.SshCertificateAuthority),
|
||||
db.ref("friendlyName").as("caName").withSchema(TableName.SshCertificateAuthority),
|
||||
db.ref("status").as("caStatus").withSchema(TableName.SshCertificateAuthority)
|
||||
)
|
||||
.first();
|
||||
|
||||
return certTemplate;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Get SSH certificate template by ID" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the SSH certificate template named [name] within project with id [projectId]
|
||||
*/
|
||||
const getByName = async (name: string, projectId: string, tx?: Knex) => {
|
||||
try {
|
||||
const certTemplate = await (tx || db.replicaNode())(TableName.SshCertificateTemplate)
|
||||
.join(
|
||||
TableName.SshCertificateAuthority,
|
||||
`${TableName.SshCertificateAuthority}.id`,
|
||||
`${TableName.SshCertificateTemplate}.sshCaId`
|
||||
)
|
||||
.join(TableName.Project, `${TableName.Project}.id`, `${TableName.SshCertificateAuthority}.projectId`)
|
||||
.where(`${TableName.SshCertificateTemplate}.name`, "=", name)
|
||||
.where(`${TableName.Project}.id`, "=", projectId)
|
||||
.select(selectAllTableCols(TableName.SshCertificateTemplate))
|
||||
.select(
|
||||
db.ref("projectId").withSchema(TableName.SshCertificateAuthority),
|
||||
db.ref("friendlyName").as("caName").withSchema(TableName.SshCertificateAuthority),
|
||||
db.ref("status").as("caStatus").withSchema(TableName.SshCertificateAuthority)
|
||||
)
|
||||
.first();
|
||||
|
||||
return certTemplate;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Get SSH certificate template by name" });
|
||||
}
|
||||
};
|
||||
|
||||
return { ...sshCertificateTemplateOrm, getById, getByName };
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SshCertificateTemplatesSchema } from "@app/db/schemas";
|
||||
|
||||
export const sanitizedSshCertificateTemplate = SshCertificateTemplatesSchema.pick({
|
||||
id: true,
|
||||
sshCaId: true,
|
||||
status: true,
|
||||
name: true,
|
||||
ttl: true,
|
||||
maxTTL: true,
|
||||
allowedUsers: true,
|
||||
allowedHosts: true,
|
||||
allowCustomKeyIds: true,
|
||||
allowUserCertificates: true,
|
||||
allowHostCertificates: true
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import ms from "ms";
|
||||
|
||||
import { 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 { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
|
||||
import { TSshCertificateAuthorityDALFactory } from "../ssh/ssh-certificate-authority-dal";
|
||||
import { TSshCertificateTemplateDALFactory } from "./ssh-certificate-template-dal";
|
||||
import {
|
||||
SshCertTemplateStatus,
|
||||
TCreateSshCertTemplateDTO,
|
||||
TDeleteSshCertTemplateDTO,
|
||||
TGetSshCertTemplateDTO,
|
||||
TUpdateSshCertTemplateDTO
|
||||
} from "./ssh-certificate-template-types";
|
||||
|
||||
type TSshCertificateTemplateServiceFactoryDep = {
|
||||
sshCertificateTemplateDAL: Pick<
|
||||
TSshCertificateTemplateDALFactory,
|
||||
"transaction" | "getByName" | "create" | "updateById" | "deleteById" | "getById"
|
||||
>;
|
||||
sshCertificateAuthorityDAL: Pick<TSshCertificateAuthorityDALFactory, "findById">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
};
|
||||
|
||||
export type TSshCertificateTemplateServiceFactory = ReturnType<typeof sshCertificateTemplateServiceFactory>;
|
||||
|
||||
export const sshCertificateTemplateServiceFactory = ({
|
||||
sshCertificateTemplateDAL,
|
||||
sshCertificateAuthorityDAL,
|
||||
permissionService
|
||||
}: TSshCertificateTemplateServiceFactoryDep) => {
|
||||
const createSshCertTemplate = async ({
|
||||
sshCaId,
|
||||
name,
|
||||
ttl,
|
||||
maxTTL,
|
||||
allowUserCertificates,
|
||||
allowHostCertificates,
|
||||
allowedUsers,
|
||||
allowedHosts,
|
||||
allowCustomKeyIds,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TCreateSshCertTemplateDTO) => {
|
||||
const ca = await sshCertificateAuthorityDAL.findById(sshCaId);
|
||||
if (!ca) {
|
||||
throw new NotFoundError({
|
||||
message: `SSH CA with ID ${sshCaId} not found`
|
||||
});
|
||||
}
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
ca.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionSub.SshCertificateTemplates
|
||||
);
|
||||
|
||||
if (ms(ttl) > ms(maxTTL)) {
|
||||
throw new BadRequestError({
|
||||
message: "TTL cannot be greater than max TTL"
|
||||
});
|
||||
}
|
||||
|
||||
const newCertificateTemplate = await sshCertificateTemplateDAL.transaction(async (tx) => {
|
||||
const existingTemplate = await sshCertificateTemplateDAL.getByName(name, ca.projectId, tx);
|
||||
if (existingTemplate) {
|
||||
throw new BadRequestError({
|
||||
message: `SSH certificate template with name ${name} already exists`
|
||||
});
|
||||
}
|
||||
|
||||
const certificateTemplate = await sshCertificateTemplateDAL.create(
|
||||
{
|
||||
sshCaId,
|
||||
name,
|
||||
ttl,
|
||||
maxTTL,
|
||||
allowUserCertificates,
|
||||
allowHostCertificates,
|
||||
allowedUsers,
|
||||
allowedHosts,
|
||||
allowCustomKeyIds,
|
||||
status: SshCertTemplateStatus.ACTIVE
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return certificateTemplate;
|
||||
});
|
||||
|
||||
return { certificateTemplate: newCertificateTemplate, ca };
|
||||
};
|
||||
|
||||
const updateSshCertTemplate = async ({
|
||||
id,
|
||||
status,
|
||||
name,
|
||||
ttl,
|
||||
maxTTL,
|
||||
allowUserCertificates,
|
||||
allowHostCertificates,
|
||||
allowedUsers,
|
||||
allowedHosts,
|
||||
allowCustomKeyIds,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TUpdateSshCertTemplateDTO) => {
|
||||
const certTemplate = await sshCertificateTemplateDAL.getById(id);
|
||||
if (!certTemplate) {
|
||||
throw new NotFoundError({
|
||||
message: `SSH certificate template with ID ${id} not found`
|
||||
});
|
||||
}
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
certTemplate.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.SshCertificateTemplates
|
||||
);
|
||||
|
||||
const updatedCertificateTemplate = await sshCertificateTemplateDAL.transaction(async (tx) => {
|
||||
if (name) {
|
||||
const existingTemplate = await sshCertificateTemplateDAL.getByName(name, certTemplate.projectId, tx);
|
||||
if (existingTemplate && existingTemplate.id !== id) {
|
||||
throw new BadRequestError({
|
||||
message: `SSH certificate template with name ${name} already exists`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (ms(ttl || certTemplate.ttl) > ms(maxTTL || certTemplate.maxTTL)) {
|
||||
throw new BadRequestError({
|
||||
message: "TTL cannot be greater than max TTL"
|
||||
});
|
||||
}
|
||||
|
||||
const certificateTemplate = await sshCertificateTemplateDAL.updateById(
|
||||
id,
|
||||
{
|
||||
status,
|
||||
name,
|
||||
ttl,
|
||||
maxTTL,
|
||||
allowUserCertificates,
|
||||
allowHostCertificates,
|
||||
allowedUsers,
|
||||
allowedHosts,
|
||||
allowCustomKeyIds
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return certificateTemplate;
|
||||
});
|
||||
|
||||
return {
|
||||
certificateTemplate: updatedCertificateTemplate,
|
||||
projectId: certTemplate.projectId
|
||||
};
|
||||
};
|
||||
|
||||
const deleteSshCertTemplate = async ({
|
||||
id,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TDeleteSshCertTemplateDTO) => {
|
||||
const certificateTemplate = await sshCertificateTemplateDAL.getById(id);
|
||||
if (!certificateTemplate) {
|
||||
throw new NotFoundError({
|
||||
message: `SSH certificate template with ID ${id} not found`
|
||||
});
|
||||
}
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
certificateTemplate.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Delete,
|
||||
ProjectPermissionSub.SshCertificateTemplates
|
||||
);
|
||||
|
||||
await sshCertificateTemplateDAL.deleteById(certificateTemplate.id);
|
||||
|
||||
return certificateTemplate;
|
||||
};
|
||||
|
||||
const getSshCertTemplate = async ({ id, actorId, actorAuthMethod, actor, actorOrgId }: TGetSshCertTemplateDTO) => {
|
||||
const certTemplate = await sshCertificateTemplateDAL.getById(id);
|
||||
if (!certTemplate) {
|
||||
throw new NotFoundError({
|
||||
message: `SSH certificate template with ID ${id} not found`
|
||||
});
|
||||
}
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
certTemplate.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionSub.SshCertificateTemplates
|
||||
);
|
||||
|
||||
return certTemplate;
|
||||
};
|
||||
|
||||
return {
|
||||
createSshCertTemplate,
|
||||
updateSshCertTemplate,
|
||||
deleteSshCertTemplate,
|
||||
getSshCertTemplate
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
export enum SshCertTemplateStatus {
|
||||
ACTIVE = "active",
|
||||
DISABLED = "disabled"
|
||||
}
|
||||
|
||||
export type TCreateSshCertTemplateDTO = {
|
||||
sshCaId: string;
|
||||
name: string;
|
||||
ttl: string;
|
||||
maxTTL: string;
|
||||
allowUserCertificates: boolean;
|
||||
allowHostCertificates: boolean;
|
||||
allowedUsers: string[];
|
||||
allowedHosts: string[];
|
||||
allowCustomKeyIds: boolean;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TUpdateSshCertTemplateDTO = {
|
||||
id: string;
|
||||
status?: SshCertTemplateStatus;
|
||||
name?: string;
|
||||
ttl?: string;
|
||||
maxTTL?: string;
|
||||
allowUserCertificates?: boolean;
|
||||
allowHostCertificates?: boolean;
|
||||
allowedUsers?: string[];
|
||||
allowedHosts?: string[];
|
||||
allowCustomKeyIds?: boolean;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetSshCertTemplateDTO = {
|
||||
id: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TDeleteSshCertTemplateDTO = {
|
||||
id: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// Validates usernames or wildcard (*)
|
||||
export const isValidUserPattern = (value: string): boolean => {
|
||||
// Matches valid Linux usernames or a wildcard (*)
|
||||
const userRegex = /^(?:\*|[a-z_][a-z0-9_-]{0,31})$/;
|
||||
return userRegex.test(value);
|
||||
};
|
||||
|
||||
// Validates hostnames, wildcard domains, or IP addresses
|
||||
export const isValidHostPattern = (value: string): boolean => {
|
||||
// Matches FQDNs, wildcard domains (*.example.com), IPv4, and IPv6 addresses
|
||||
const hostRegex =
|
||||
/^(?:\*|\*\.[a-z0-9-]+(?:\.[a-z0-9-]+)*|[a-z0-9-]+(?:\.[a-z0-9-]+)*|\d{1,3}(\.\d{1,3}){3}|([a-fA-F0-9:]+:+)+[a-fA-F0-9]+(?:%[a-zA-Z0-9]+)?)$/;
|
||||
return hostRegex.test(value);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TSshCertificateBodyDALFactory = ReturnType<typeof sshCertificateBodyDALFactory>;
|
||||
|
||||
export const sshCertificateBodyDALFactory = (db: TDbClient) => {
|
||||
const sshCertificateBodyOrm = ormify(db, TableName.SshCertificateBody);
|
||||
return sshCertificateBodyOrm;
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TSshCertificateDALFactory = ReturnType<typeof sshCertificateDALFactory>;
|
||||
|
||||
export const sshCertificateDALFactory = (db: TDbClient) => {
|
||||
const sshCertificateOrm = ormify(db, TableName.SshCertificate);
|
||||
|
||||
const countSshCertificatesInProject = async (projectId: string) => {
|
||||
try {
|
||||
interface CountResult {
|
||||
count: string;
|
||||
}
|
||||
|
||||
const query = db
|
||||
.replicaNode()(TableName.SshCertificate)
|
||||
.join(
|
||||
TableName.SshCertificateAuthority,
|
||||
`${TableName.SshCertificate}.sshCaId`,
|
||||
`${TableName.SshCertificateAuthority}.id`
|
||||
)
|
||||
.join(TableName.Project, `${TableName.SshCertificateAuthority}.projectId`, `${TableName.Project}.id`)
|
||||
.where(`${TableName.Project}.id`, projectId);
|
||||
|
||||
const count = await query.count("*").first();
|
||||
|
||||
return parseInt((count as unknown as CountResult).count || "0", 10);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Count all SSH certificates in project" });
|
||||
}
|
||||
};
|
||||
return {
|
||||
...sshCertificateOrm,
|
||||
countSshCertificatesInProject
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { SshCertificatesSchema } from "@app/db/schemas";
|
||||
|
||||
export const sanitizedSshCertificate = SshCertificatesSchema.pick({
|
||||
id: true,
|
||||
sshCaId: true,
|
||||
sshCertificateTemplateId: true,
|
||||
serialNumber: true,
|
||||
certType: true,
|
||||
publicKey: true,
|
||||
principals: true,
|
||||
keyId: true,
|
||||
notBefore: true,
|
||||
notAfter: true
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TSshCertificateAuthorityDALFactory = ReturnType<typeof sshCertificateAuthorityDALFactory>;
|
||||
|
||||
export const sshCertificateAuthorityDALFactory = (db: TDbClient) => {
|
||||
const sshCaOrm = ormify(db, TableName.SshCertificateAuthority);
|
||||
return sshCaOrm;
|
||||
};
|
||||
@@ -0,0 +1,376 @@
|
||||
import { execFile } from "child_process";
|
||||
import crypto from "crypto";
|
||||
import { promises as fs } from "fs";
|
||||
import ms from "ms";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { promisify } from "util";
|
||||
|
||||
import { TSshCertificateTemplates } from "@app/db/schemas";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
|
||||
|
||||
import {
|
||||
isValidHostPattern,
|
||||
isValidUserPattern
|
||||
} from "../ssh-certificate-template/ssh-certificate-template-validators";
|
||||
import { SshCertType, TCreateSshCertDTO } from "./ssh-certificate-authority-types";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/* eslint-disable no-bitwise */
|
||||
export const createSshCertSerialNumber = () => {
|
||||
const randomBytes = crypto.randomBytes(8); // 8 bytes = 64 bits
|
||||
randomBytes[0] &= 0x7f; // Ensure the most significant bit is 0 (to stay within unsigned range)
|
||||
return BigInt(`0x${randomBytes.toString("hex")}`).toString(10); // Convert to decimal
|
||||
};
|
||||
|
||||
/**
|
||||
* Return a pair of SSH CA keys based on the specified key algorithm [keyAlgorithm].
|
||||
* We use this function because the key format generated by `ssh-keygen` is unique.
|
||||
*/
|
||||
export const createSshKeyPair = async (keyAlgorithm: CertKeyAlgorithm) => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-"));
|
||||
const privateKeyFile = path.join(tempDir, "id_key");
|
||||
const publicKeyFile = `${privateKeyFile}.pub`;
|
||||
|
||||
let keyType: string;
|
||||
let keyBits: string;
|
||||
|
||||
switch (keyAlgorithm) {
|
||||
case CertKeyAlgorithm.RSA_2048:
|
||||
keyType = "rsa";
|
||||
keyBits = "2048";
|
||||
break;
|
||||
case CertKeyAlgorithm.RSA_4096:
|
||||
keyType = "rsa";
|
||||
keyBits = "4096";
|
||||
break;
|
||||
case CertKeyAlgorithm.ECDSA_P256:
|
||||
keyType = "ecdsa";
|
||||
keyBits = "256";
|
||||
break;
|
||||
case CertKeyAlgorithm.ECDSA_P384:
|
||||
keyType = "ecdsa";
|
||||
keyBits = "384";
|
||||
break;
|
||||
default:
|
||||
throw new BadRequestError({
|
||||
message: "Failed to produce SSH CA key pair generation command due to unrecognized key algorithm"
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate the SSH key pair
|
||||
// The "-N ''" sets an empty passphrase
|
||||
// The keys are created in the temporary directory
|
||||
await execFileAsync("ssh-keygen", ["-t", keyType, "-b", keyBits, "-f", privateKeyFile, "-N", ""]);
|
||||
|
||||
// Read the generated keys
|
||||
const publicKey = await fs.readFile(publicKeyFile, "utf8");
|
||||
const privateKey = await fs.readFile(privateKeyFile, "utf8");
|
||||
|
||||
return { publicKey, privateKey };
|
||||
} finally {
|
||||
// Cleanup the temporary directory and all its contents
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the SSH public key for the given SSH private key.
|
||||
*/
|
||||
export const getSshPublicKey = async (privateKey: string) => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-"));
|
||||
const privateKeyFile = path.join(tempDir, "id_key");
|
||||
try {
|
||||
await fs.writeFile(privateKeyFile, privateKey, { mode: 0o600 });
|
||||
|
||||
// Run ssh-keygen to extract the public key
|
||||
const { stdout } = await execFileAsync("ssh-keygen", ["-y", "-f", privateKeyFile], { encoding: "utf8" });
|
||||
return stdout.trim();
|
||||
} finally {
|
||||
// Ensure that files and the temporary directory are cleaned up
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the requested SSH certificate type based on the SSH certificate template configuration.
|
||||
*/
|
||||
export const validateSshCertificateType = (template: TSshCertificateTemplates, certType: SshCertType) => {
|
||||
if (!template.allowUserCertificates && certType === SshCertType.USER) {
|
||||
throw new BadRequestError({ message: "Failed to validate user certificate type due to template restriction" });
|
||||
}
|
||||
|
||||
if (!template.allowHostCertificates && certType === SshCertType.HOST) {
|
||||
throw new BadRequestError({ message: "Failed to validate host certificate type due to template restriction" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the requested SSH certificate principals based on the SSH certificate template configuration.
|
||||
*/
|
||||
export const validateSshCertificatePrincipals = (
|
||||
certType: SshCertType,
|
||||
template: TSshCertificateTemplates,
|
||||
principals: string[]
|
||||
) => {
|
||||
/**
|
||||
* Validate and sanitize a principal string
|
||||
*/
|
||||
const validatePrincipal = (principal: string) => {
|
||||
const sanitized = principal.trim();
|
||||
|
||||
// basic checks for empty or control characters
|
||||
if (sanitized.length === 0) {
|
||||
throw new BadRequestError({
|
||||
message: "Principal cannot be an empty string."
|
||||
});
|
||||
}
|
||||
|
||||
if (/\r|\n|\t|\0/.test(sanitized)) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${sanitized}' contains invalid whitespace or control characters.`
|
||||
});
|
||||
}
|
||||
|
||||
// disallow whitespace anywhere
|
||||
if (/\s/.test(sanitized)) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${sanitized}' cannot contain whitespace.`
|
||||
});
|
||||
}
|
||||
|
||||
// restrict allowed characters to letters, digits, dot, underscore, and hyphen
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(sanitized)) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${sanitized}' contains invalid characters. Allowed: alphanumeric, '.', '_', '-'.`
|
||||
});
|
||||
}
|
||||
|
||||
// disallow leading hyphen to avoid potential argument-like inputs
|
||||
if (sanitized.startsWith("-")) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${sanitized}' cannot start with a hyphen.`
|
||||
});
|
||||
}
|
||||
|
||||
// length restriction (adjust as needed)
|
||||
if (sanitized.length > 64) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${sanitized}' is too long.`
|
||||
});
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
// Sanitize and validate all principals using the helper
|
||||
const sanitizedPrincipals = principals.map(validatePrincipal);
|
||||
|
||||
switch (certType) {
|
||||
case SshCertType.USER: {
|
||||
if (template.allowedUsers.length === 0) {
|
||||
throw new BadRequestError({
|
||||
message: "No allowed users are configured in the SSH certificate template."
|
||||
});
|
||||
}
|
||||
|
||||
const allowsAllUsers = template.allowedUsers.includes("*") ?? false;
|
||||
|
||||
sanitizedPrincipals.forEach((principal) => {
|
||||
if (principal === "*") {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '*' is not allowed for user certificates.`
|
||||
});
|
||||
}
|
||||
if (allowsAllUsers && !isValidUserPattern(principal)) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${principal}' does not match a valid user pattern.`
|
||||
});
|
||||
}
|
||||
if (!allowsAllUsers && !template.allowedUsers.includes(principal)) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${principal}' is not in the list of allowed users.`
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case SshCertType.HOST: {
|
||||
if (template.allowedHosts.length === 0) {
|
||||
throw new BadRequestError({
|
||||
message: "No allowed hosts are configured in the SSH certificate template."
|
||||
});
|
||||
}
|
||||
|
||||
const allowsAllHosts = template.allowedHosts.includes("*") ?? false;
|
||||
|
||||
sanitizedPrincipals.forEach((principal) => {
|
||||
if (principal.includes("*")) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${principal}' with wildcards is not allowed for host certificates.`
|
||||
});
|
||||
}
|
||||
if (allowsAllHosts && !isValidHostPattern(principal)) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${principal}' does not match a valid host pattern.`
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!allowsAllHosts &&
|
||||
!template.allowedHosts.some((allowedHost) => {
|
||||
if (allowedHost.startsWith("*.")) {
|
||||
const baseDomain = allowedHost.slice(2); // Remove the leading "*."
|
||||
return principal.endsWith(`.${baseDomain}`);
|
||||
}
|
||||
return principal === allowedHost;
|
||||
})
|
||||
) {
|
||||
throw new BadRequestError({
|
||||
message: `Principal '${principal}' is not in the list of allowed hosts or domains.`
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new BadRequestError({
|
||||
message: "Failed to validate SSH certificate principals due to unrecognized requested certificate type"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the requested SSH certificate TTL based on the SSH certificate template configuration.
|
||||
*/
|
||||
export const validateSshCertificateTtl = (template: TSshCertificateTemplates, ttl?: string) => {
|
||||
if (!ttl) {
|
||||
// use default template ttl
|
||||
return Math.ceil(ms(template.ttl) / 1000);
|
||||
}
|
||||
|
||||
if (ms(ttl) > ms(template.maxTTL)) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed TTL validation due to TTL being greater than configured max TTL on template"
|
||||
});
|
||||
}
|
||||
|
||||
return Math.ceil(ms(ttl) / 1000);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the requested SSH certificate key ID to ensure
|
||||
* that it only contains alphanumeric characters with no spaces.
|
||||
*/
|
||||
export const validateSshCertificateKeyId = (keyId: string) => {
|
||||
const regex = /^[A-Za-z0-9-]+$/;
|
||||
if (!regex.test(keyId)) {
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Failed to validate Key ID because it can only contain alphanumeric characters and hyphens, with no spaces."
|
||||
});
|
||||
}
|
||||
|
||||
if (keyId.length > 50) {
|
||||
throw new BadRequestError({
|
||||
message: "keyId can only be up to 50 characters long."
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the format of the SSH public key
|
||||
*/
|
||||
const validateSshPublicKey = async (publicKey: string) => {
|
||||
const validPrefixes = ["ssh-rsa", "ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"];
|
||||
const startsWithValidPrefix = validPrefixes.some((prefix) => publicKey.startsWith(`${prefix} `));
|
||||
if (!startsWithValidPrefix) {
|
||||
throw new BadRequestError({ message: "Failed to validate SSH public key format: unsupported key type." });
|
||||
}
|
||||
|
||||
// write the key to a temp file and run `ssh-keygen -l -f`
|
||||
// check to see if OpenSSH can read/interpret the public key
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-pubkey-"));
|
||||
const pubKeyFile = path.join(tempDir, "key.pub");
|
||||
|
||||
try {
|
||||
await fs.writeFile(pubKeyFile, publicKey, { mode: 0o600 });
|
||||
await execFileAsync("ssh-keygen", ["-l", "-f", pubKeyFile]);
|
||||
} catch (error) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to validate SSH public key format: could not be parsed."
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an SSH certificate for a user or host.
|
||||
*/
|
||||
export const createSshCert = async ({
|
||||
template,
|
||||
caPrivateKey,
|
||||
clientPublicKey,
|
||||
keyId,
|
||||
principals,
|
||||
requestedTtl,
|
||||
certType
|
||||
}: TCreateSshCertDTO) => {
|
||||
// validate if the requested [certType] is allowed under the template configuration
|
||||
validateSshCertificateType(template, certType);
|
||||
|
||||
// validate if the requested [principals] are valid for the given [certType] under the template configuration
|
||||
validateSshCertificatePrincipals(certType, template, principals);
|
||||
|
||||
// validate if the requested TTL is valid under the template configuration
|
||||
const ttl = validateSshCertificateTtl(template, requestedTtl);
|
||||
|
||||
validateSshCertificateKeyId(keyId);
|
||||
await validateSshPublicKey(clientPublicKey);
|
||||
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-cert-"));
|
||||
|
||||
const publicKeyFile = path.join(tempDir, "user_key.pub");
|
||||
const privateKeyFile = path.join(tempDir, "ca_key");
|
||||
const signedPublicKeyFile = path.join(tempDir, "user_key-cert.pub");
|
||||
|
||||
const serialNumber = createSshCertSerialNumber();
|
||||
|
||||
// Build `ssh-keygen` arguments for signing
|
||||
// Using an array avoids shell injection issues
|
||||
const sshKeygenArgs = [
|
||||
certType === "host" ? "-h" : null, // host certificate if needed
|
||||
"-s",
|
||||
privateKeyFile, // path to SSH CA private key
|
||||
"-I",
|
||||
keyId, // identity (key ID)
|
||||
"-n",
|
||||
principals.join(","), // principals
|
||||
"-V",
|
||||
`+${ttl}s`, // validity (TTL in seconds)
|
||||
"-z",
|
||||
serialNumber, // serial number
|
||||
publicKeyFile // public key file to sign
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
try {
|
||||
// Write public and private keys to the temp directory
|
||||
await fs.writeFile(publicKeyFile, clientPublicKey, { mode: 0o600 });
|
||||
await fs.writeFile(privateKeyFile, caPrivateKey, { mode: 0o600 });
|
||||
|
||||
// Execute the signing process
|
||||
await execFileAsync("ssh-keygen", sshKeygenArgs, { encoding: "utf8" });
|
||||
|
||||
// Read the signed public key from the generated cert file
|
||||
const signedPublicKey = await fs.readFile(signedPublicKeyFile, "utf8");
|
||||
|
||||
return { serialNumber, signedPublicKey, ttl };
|
||||
} finally {
|
||||
// Cleanup the temporary directory and all its contents
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SshCertificateAuthoritiesSchema } from "@app/db/schemas";
|
||||
|
||||
export const sanitizedSshCa = SshCertificateAuthoritiesSchema.pick({
|
||||
id: true,
|
||||
projectId: true,
|
||||
friendlyName: true,
|
||||
status: true,
|
||||
keyAlgorithm: true
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TSshCertificateAuthoritySecretDALFactory = ReturnType<typeof sshCertificateAuthoritySecretDALFactory>;
|
||||
|
||||
export const sshCertificateAuthoritySecretDALFactory = (db: TDbClient) => {
|
||||
const sshCaSecretOrm = ormify(db, TableName.SshCertificateAuthoritySecret);
|
||||
return sshCaSecretOrm;
|
||||
};
|
||||
@@ -0,0 +1,523 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { 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 { TSshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-body-dal";
|
||||
import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal";
|
||||
import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-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 { SshCertTemplateStatus } from "../ssh-certificate-template/ssh-certificate-template-types";
|
||||
import { createSshCert, createSshKeyPair, getSshPublicKey } from "./ssh-certificate-authority-fns";
|
||||
import {
|
||||
SshCaStatus,
|
||||
TCreateSshCaDTO,
|
||||
TDeleteSshCaDTO,
|
||||
TGetSshCaCertificateTemplatesDTO,
|
||||
TGetSshCaDTO,
|
||||
TGetSshCaPublicKeyDTO,
|
||||
TIssueSshCredsDTO,
|
||||
TSignSshKeyDTO,
|
||||
TUpdateSshCaDTO
|
||||
} from "./ssh-certificate-authority-types";
|
||||
|
||||
type TSshCertificateAuthorityServiceFactoryDep = {
|
||||
sshCertificateAuthorityDAL: Pick<
|
||||
TSshCertificateAuthorityDALFactory,
|
||||
"transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne"
|
||||
>;
|
||||
sshCertificateAuthoritySecretDAL: Pick<TSshCertificateAuthoritySecretDALFactory, "create" | "findOne">;
|
||||
sshCertificateTemplateDAL: Pick<TSshCertificateTemplateDALFactory, "find" | "getById">;
|
||||
sshCertificateDAL: Pick<TSshCertificateDALFactory, "create" | "transaction">;
|
||||
sshCertificateBodyDAL: Pick<TSshCertificateBodyDALFactory, "create">;
|
||||
kmsService: Pick<
|
||||
TKmsServiceFactory,
|
||||
"generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "getOrgKmsKeyId" | "createCipherPairWithDataKey"
|
||||
>;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
};
|
||||
|
||||
export type TSshCertificateAuthorityServiceFactory = ReturnType<typeof sshCertificateAuthorityServiceFactory>;
|
||||
|
||||
export const sshCertificateAuthorityServiceFactory = ({
|
||||
sshCertificateAuthorityDAL,
|
||||
sshCertificateAuthoritySecretDAL,
|
||||
sshCertificateTemplateDAL,
|
||||
sshCertificateDAL,
|
||||
sshCertificateBodyDAL,
|
||||
kmsService,
|
||||
permissionService
|
||||
}: TSshCertificateAuthorityServiceFactoryDep) => {
|
||||
/**
|
||||
* Generates a new SSH CA
|
||||
*/
|
||||
const createSshCa = async ({
|
||||
projectId,
|
||||
friendlyName,
|
||||
keyAlgorithm,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TCreateSshCaDTO) => {
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionSub.SshCertificateAuthorities
|
||||
);
|
||||
|
||||
const newCa = await sshCertificateAuthorityDAL.transaction(async (tx) => {
|
||||
const ca = await sshCertificateAuthorityDAL.create(
|
||||
{
|
||||
projectId,
|
||||
friendlyName,
|
||||
status: SshCaStatus.ACTIVE,
|
||||
keyAlgorithm
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm);
|
||||
|
||||
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
await sshCertificateAuthoritySecretDAL.create(
|
||||
{
|
||||
sshCaId: ca.id,
|
||||
encryptedPrivateKey: secretManagerEncryptor({ plainText: Buffer.from(privateKey, "utf8") }).cipherTextBlob
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return { ...ca, publicKey };
|
||||
});
|
||||
|
||||
return newCa;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return SSH CA with id [caId]
|
||||
*/
|
||||
const getSshCaById = async ({ caId, actor, actorId, actorAuthMethod, actorOrgId }: TGetSshCaDTO) => {
|
||||
const ca = await sshCertificateAuthorityDAL.findById(caId);
|
||||
if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` });
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
ca.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionSub.SshCertificateAuthorities
|
||||
);
|
||||
|
||||
const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id });
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: ca.projectId
|
||||
});
|
||||
|
||||
const decryptedCaPrivateKey = secretManagerDecryptor({
|
||||
cipherTextBlob: sshCaSecret.encryptedPrivateKey
|
||||
});
|
||||
|
||||
const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8"));
|
||||
|
||||
return { ...ca, publicKey };
|
||||
};
|
||||
|
||||
/**
|
||||
* Return public key of SSH CA with id [caId]
|
||||
*/
|
||||
const getSshCaPublicKey = async ({ caId }: TGetSshCaPublicKeyDTO) => {
|
||||
const ca = await sshCertificateAuthorityDAL.findById(caId);
|
||||
if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` });
|
||||
|
||||
const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id });
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: ca.projectId
|
||||
});
|
||||
|
||||
const decryptedCaPrivateKey = secretManagerDecryptor({
|
||||
cipherTextBlob: sshCaSecret.encryptedPrivateKey
|
||||
});
|
||||
|
||||
const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8"));
|
||||
|
||||
return publicKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update SSH CA with id [caId]
|
||||
* Note: Used to enable/disable CA
|
||||
*/
|
||||
const updateSshCaById = async ({
|
||||
caId,
|
||||
friendlyName,
|
||||
status,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TUpdateSshCaDTO) => {
|
||||
const ca = await sshCertificateAuthorityDAL.findById(caId);
|
||||
if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` });
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
ca.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.SshCertificateAuthorities
|
||||
);
|
||||
|
||||
const updatedCa = await sshCertificateAuthorityDAL.updateById(caId, { friendlyName, status });
|
||||
|
||||
const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id });
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: ca.projectId
|
||||
});
|
||||
|
||||
const decryptedCaPrivateKey = secretManagerDecryptor({
|
||||
cipherTextBlob: sshCaSecret.encryptedPrivateKey
|
||||
});
|
||||
|
||||
const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8"));
|
||||
|
||||
return { ...updatedCa, publicKey };
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete SSH CA with id [caId]
|
||||
*/
|
||||
const deleteSshCaById = async ({ caId, actor, actorId, actorAuthMethod, actorOrgId }: TDeleteSshCaDTO) => {
|
||||
const ca = await sshCertificateAuthorityDAL.findById(caId);
|
||||
if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` });
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
ca.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Delete,
|
||||
ProjectPermissionSub.SshCertificateAuthorities
|
||||
);
|
||||
|
||||
const deletedCa = await sshCertificateAuthorityDAL.deleteById(caId);
|
||||
|
||||
return deletedCa;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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].
|
||||
*/
|
||||
const issueSshCreds = async ({
|
||||
certificateTemplateId,
|
||||
keyAlgorithm,
|
||||
certType,
|
||||
principals,
|
||||
ttl: requestedTtl,
|
||||
keyId: requestedKeyId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TIssueSshCredsDTO) => {
|
||||
const sshCertificateTemplate = await sshCertificateTemplateDAL.getById(certificateTemplateId);
|
||||
if (!sshCertificateTemplate) {
|
||||
throw new NotFoundError({
|
||||
message: "No SSH certificate template found with specified name"
|
||||
});
|
||||
}
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
sshCertificateTemplate.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionSub.SshCertificates
|
||||
);
|
||||
|
||||
if (sshCertificateTemplate.caStatus === SshCaStatus.DISABLED) {
|
||||
throw new BadRequestError({
|
||||
message: "SSH CA is disabled"
|
||||
});
|
||||
}
|
||||
|
||||
if (sshCertificateTemplate.status === SshCertTemplateStatus.DISABLED) {
|
||||
throw new BadRequestError({
|
||||
message: "SSH certificate template is disabled"
|
||||
});
|
||||
}
|
||||
|
||||
// set [keyId] depending on if [allowCustomKeyIds] is true or false
|
||||
const keyId = sshCertificateTemplate.allowCustomKeyIds
|
||||
? requestedKeyId ?? `${actor}-${actorId}`
|
||||
: `${actor}-${actorId}`;
|
||||
|
||||
const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId });
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: sshCertificateTemplate.projectId
|
||||
});
|
||||
|
||||
const decryptedCaPrivateKey = secretManagerDecryptor({
|
||||
cipherTextBlob: sshCaSecret.encryptedPrivateKey
|
||||
});
|
||||
|
||||
// create user key pair
|
||||
const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm);
|
||||
|
||||
const { serialNumber, signedPublicKey, ttl } = await createSshCert({
|
||||
template: sshCertificateTemplate,
|
||||
caPrivateKey: decryptedCaPrivateKey.toString("utf8"),
|
||||
clientPublicKey: publicKey,
|
||||
keyId,
|
||||
principals,
|
||||
requestedTtl,
|
||||
certType
|
||||
});
|
||||
|
||||
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: sshCertificateTemplate.projectId
|
||||
});
|
||||
|
||||
const encryptedCertificate = secretManagerEncryptor({
|
||||
plainText: Buffer.from(signedPublicKey, "utf8")
|
||||
}).cipherTextBlob;
|
||||
|
||||
await sshCertificateDAL.transaction(async (tx) => {
|
||||
const cert = await sshCertificateDAL.create(
|
||||
{
|
||||
sshCaId: sshCertificateTemplate.sshCaId,
|
||||
sshCertificateTemplateId: sshCertificateTemplate.id,
|
||||
serialNumber,
|
||||
certType,
|
||||
principals,
|
||||
keyId,
|
||||
notBefore: new Date(),
|
||||
notAfter: new Date(Date.now() + ttl * 1000)
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await sshCertificateBodyDAL.create(
|
||||
{
|
||||
sshCertId: cert.id,
|
||||
encryptedCertificate
|
||||
},
|
||||
tx
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
serialNumber,
|
||||
signedPublicKey,
|
||||
privateKey,
|
||||
publicKey,
|
||||
certificateTemplate: sshCertificateTemplate,
|
||||
ttl,
|
||||
keyId
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Return SSH certificate by signing SSH public key [publicKey]
|
||||
* using CA behind SSH certificate template with name [templateName]
|
||||
*/
|
||||
const signSshKey = async ({
|
||||
certificateTemplateId,
|
||||
publicKey,
|
||||
certType,
|
||||
principals,
|
||||
ttl: requestedTtl,
|
||||
keyId: requestedKeyId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TSignSshKeyDTO) => {
|
||||
const sshCertificateTemplate = await sshCertificateTemplateDAL.getById(certificateTemplateId);
|
||||
if (!sshCertificateTemplate) {
|
||||
throw new NotFoundError({
|
||||
message: "No SSH certificate template found with specified name"
|
||||
});
|
||||
}
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
sshCertificateTemplate.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionSub.SshCertificates
|
||||
);
|
||||
|
||||
if (sshCertificateTemplate.caStatus === SshCaStatus.DISABLED) {
|
||||
throw new BadRequestError({
|
||||
message: "SSH CA is disabled"
|
||||
});
|
||||
}
|
||||
|
||||
if (sshCertificateTemplate.status === SshCertTemplateStatus.DISABLED) {
|
||||
throw new BadRequestError({
|
||||
message: "SSH certificate template is disabled"
|
||||
});
|
||||
}
|
||||
|
||||
// set [keyId] depending on if [allowCustomKeyIds] is true or false
|
||||
const keyId = sshCertificateTemplate.allowCustomKeyIds
|
||||
? requestedKeyId ?? `${actor}-${actorId}`
|
||||
: `${actor}-${actorId}`;
|
||||
|
||||
const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId });
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: sshCertificateTemplate.projectId
|
||||
});
|
||||
|
||||
const decryptedCaPrivateKey = secretManagerDecryptor({
|
||||
cipherTextBlob: sshCaSecret.encryptedPrivateKey
|
||||
});
|
||||
|
||||
const { serialNumber, signedPublicKey, ttl } = await createSshCert({
|
||||
template: sshCertificateTemplate,
|
||||
caPrivateKey: decryptedCaPrivateKey.toString("utf8"),
|
||||
clientPublicKey: publicKey,
|
||||
keyId,
|
||||
principals,
|
||||
requestedTtl,
|
||||
certType
|
||||
});
|
||||
|
||||
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: sshCertificateTemplate.projectId
|
||||
});
|
||||
|
||||
const encryptedCertificate = secretManagerEncryptor({
|
||||
plainText: Buffer.from(signedPublicKey, "utf8")
|
||||
}).cipherTextBlob;
|
||||
|
||||
await sshCertificateDAL.transaction(async (tx) => {
|
||||
const cert = await sshCertificateDAL.create(
|
||||
{
|
||||
sshCaId: sshCertificateTemplate.sshCaId,
|
||||
sshCertificateTemplateId: sshCertificateTemplate.id,
|
||||
serialNumber,
|
||||
certType,
|
||||
principals,
|
||||
keyId,
|
||||
notBefore: new Date(),
|
||||
notAfter: new Date(Date.now() + ttl * 1000)
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await sshCertificateBodyDAL.create(
|
||||
{
|
||||
sshCertId: cert.id,
|
||||
encryptedCertificate
|
||||
},
|
||||
tx
|
||||
);
|
||||
});
|
||||
|
||||
return { serialNumber, signedPublicKey, certificateTemplate: sshCertificateTemplate, ttl, keyId };
|
||||
};
|
||||
|
||||
const getSshCaCertificateTemplates = async ({
|
||||
caId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TGetSshCaCertificateTemplatesDTO) => {
|
||||
const ca = await sshCertificateAuthorityDAL.findById(caId);
|
||||
if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` });
|
||||
|
||||
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
ca.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbidOnInvalidProjectType(ProjectType.SSH);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionSub.SshCertificateTemplates
|
||||
);
|
||||
|
||||
const certificateTemplates = await sshCertificateTemplateDAL.find({ sshCaId: caId });
|
||||
|
||||
return {
|
||||
certificateTemplates,
|
||||
ca
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
issueSshCreds,
|
||||
signSshKey,
|
||||
createSshCa,
|
||||
getSshCaById,
|
||||
getSshCaPublicKey,
|
||||
updateSshCaById,
|
||||
deleteSshCaById,
|
||||
getSshCaCertificateTemplates
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { TSshCertificateTemplates } from "@app/db/schemas";
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
|
||||
|
||||
export enum SshCaStatus {
|
||||
ACTIVE = "active",
|
||||
DISABLED = "disabled"
|
||||
}
|
||||
|
||||
export enum SshCertType {
|
||||
USER = "user",
|
||||
HOST = "host"
|
||||
}
|
||||
|
||||
export type TCreateSshCaDTO = {
|
||||
friendlyName: string;
|
||||
keyAlgorithm: CertKeyAlgorithm;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetSshCaDTO = {
|
||||
caId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetSshCaPublicKeyDTO = {
|
||||
caId: string;
|
||||
};
|
||||
|
||||
export type TUpdateSshCaDTO = {
|
||||
caId: string;
|
||||
friendlyName?: string;
|
||||
status?: SshCaStatus;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TDeleteSshCaDTO = {
|
||||
caId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TIssueSshCredsDTO = {
|
||||
certificateTemplateId: string;
|
||||
keyAlgorithm: CertKeyAlgorithm;
|
||||
certType: SshCertType;
|
||||
principals: string[];
|
||||
ttl?: string;
|
||||
keyId?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TSignSshKeyDTO = {
|
||||
certificateTemplateId: string;
|
||||
publicKey: string;
|
||||
certType: SshCertType;
|
||||
principals: string[];
|
||||
ttl?: string;
|
||||
keyId?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetSshCaCertificateTemplatesDTO = {
|
||||
caId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TCreateSshCertDTO = {
|
||||
template: TSshCertificateTemplates;
|
||||
caPrivateKey: string;
|
||||
clientPublicKey: string;
|
||||
keyId: string;
|
||||
principals: string[];
|
||||
requestedTtl?: string;
|
||||
certType: SshCertType;
|
||||
};
|
||||
Reference in New Issue
Block a user