Make PR review adjustments, ssh ca public key endpoint, ssh cert template status

This commit is contained in:
Tuan Dang
2024-12-08 21:23:00 -08:00
parent ec1ce3dc06
commit 42249726d4
23 changed files with 380 additions and 96 deletions
@@ -34,6 +34,7 @@ export async function up(knex: Knex): Promise<void> {
t.timestamps(true, true, true); t.timestamps(true, true, true);
t.uuid("sshCaId").notNullable(); t.uuid("sshCaId").notNullable();
t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE");
t.string("status").notNullable(); // active / disabled
t.string("name").notNullable(); t.string("name").notNullable();
t.string("ttl").notNullable(); t.string("ttl").notNullable();
t.string("maxTTL").notNullable(); t.string("maxTTL").notNullable();
@@ -51,7 +52,7 @@ export async function up(knex: Knex): Promise<void> {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.timestamps(true, true, true); t.timestamps(true, true, true);
t.uuid("sshCaId").notNullable(); t.uuid("sshCaId").notNullable();
t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL");
t.uuid("sshCertificateTemplateId"); t.uuid("sshCertificateTemplateId");
t.foreign("sshCertificateTemplateId") t.foreign("sshCertificateTemplateId")
.references("id") .references("id")
@@ -65,7 +66,7 @@ export async function up(knex: Knex): Promise<void> {
t.datetime("notBefore").notNullable(); t.datetime("notBefore").notNullable();
t.datetime("notAfter").notNullable(); t.datetime("notAfter").notNullable();
}); });
await createOnUpdateTrigger(knex, TableName.SshCertificateTemplate); await createOnUpdateTrigger(knex, TableName.SshCertificate);
} }
} }
@@ -12,6 +12,7 @@ export const SshCertificateTemplatesSchema = z.object({
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
sshCaId: z.string().uuid(), sshCaId: z.string().uuid(),
status: z.string(),
name: z.string(), name: z.string(),
ttl: z.string(), ttl: z.string(),
maxTTL: z.string(), maxTTL: z.string(),
@@ -109,6 +109,30 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => {
} }
}); });
server.route({
method: "GET",
url: "/:sshCaId/public-key",
config: {
rateLimit: readLimit
},
schema: {
description: "Get public key of SSH CA",
params: z.object({
sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.GET_PUBLIC_KEY.sshCaId)
}),
response: {
200: z.string()
}
},
handler: async (req) => {
const publicKey = await server.services.sshCertificateAuthority.getSshCaPublicKey({
caId: req.params.sshCaId
});
return publicKey;
}
});
server.route({ server.route({
method: "PATCH", method: "PATCH",
url: "/:sshCaId", url: "/:sshCaId",
@@ -123,10 +147,7 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => {
}), }),
body: z.object({ body: z.object({
friendlyName: z.string().optional().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.friendlyName), friendlyName: z.string().optional().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.friendlyName),
status: z status: z.nativeEnum(SshCaStatus).optional().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.status)
.enum([SshCaStatus.ACTIVE, SshCaStatus.DISABLED])
.optional()
.describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.status)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -4,6 +4,7 @@ import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema";
import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types";
import { import {
isValidHostPattern, isValidHostPattern,
isValidUserPattern isValidUserPattern
@@ -136,6 +137,7 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro
}, },
schema: { schema: {
body: z.object({ body: z.object({
status: z.nativeEnum(SshCertTemplateStatus).optional(),
name: z name: z
.string() .string()
.min(1) .min(1)
@@ -191,6 +193,7 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro
event: { event: {
type: EventType.UPDATE_SSH_CERTIFICATE_TEMPLATE, type: EventType.UPDATE_SSH_CERTIFICATE_TEMPLATE,
metadata: { metadata: {
status: certificateTemplate.status as SshCertTemplateStatus,
certificateTemplateId: certificateTemplate.id, certificateTemplateId: certificateTemplate.id,
sshCaId: certificateTemplate.sshCaId, sshCaId: certificateTemplate.sshCaId,
name: certificateTemplate.name, name: certificateTemplate.name,
@@ -3,6 +3,7 @@ import {
TUpdateProjectTemplateDTO TUpdateProjectTemplateDTO
} from "@app/ee/services/project-template/project-template-types"; } from "@app/ee/services/project-template/project-template-types";
import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-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 { SymmetricEncryption } from "@app/lib/crypto/cipher";
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
import { ActorType } from "@app/services/auth/auth-type"; import { ActorType } from "@app/services/auth/auth-type";
@@ -1238,6 +1239,7 @@ interface UpdateSshCertificateTemplate {
certificateTemplateId: string; certificateTemplateId: string;
sshCaId: string; sshCaId: string;
name: string; name: string;
status: SshCertTemplateStatus;
ttl: string; ttl: string;
maxTTL: string; maxTTL: string;
allowedUsers: string[]; allowedUsers: string[];
@@ -3,6 +3,7 @@ import { SshCertificateTemplatesSchema } from "@app/db/schemas";
export const sanitizedSshCertificateTemplate = SshCertificateTemplatesSchema.pick({ export const sanitizedSshCertificateTemplate = SshCertificateTemplatesSchema.pick({
id: true, id: true,
sshCaId: true, sshCaId: true,
status: true,
name: true, name: true,
ttl: true, ttl: true,
maxTTL: true, maxTTL: true,
@@ -8,6 +8,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TSshCertificateAuthorityDALFactory } from "../ssh/ssh-certificate-authority-dal"; import { TSshCertificateAuthorityDALFactory } from "../ssh/ssh-certificate-authority-dal";
import { TSshCertificateTemplateDALFactory } from "./ssh-certificate-template-dal"; import { TSshCertificateTemplateDALFactory } from "./ssh-certificate-template-dal";
import { import {
SshCertTemplateStatus,
TCreateSshCertTemplateDTO, TCreateSshCertTemplateDTO,
TDeleteSshCertTemplateDTO, TDeleteSshCertTemplateDTO,
TGetSshCertTemplateDTO, TGetSshCertTemplateDTO,
@@ -15,7 +16,10 @@ import {
} from "./ssh-certificate-template-types"; } from "./ssh-certificate-template-types";
type TSshCertificateTemplateServiceFactoryDep = { type TSshCertificateTemplateServiceFactoryDep = {
sshCertificateTemplateDAL: TSshCertificateTemplateDALFactory; sshCertificateTemplateDAL: Pick<
TSshCertificateTemplateDALFactory,
"transaction" | "getByName" | "create" | "updateById" | "deleteById" | "getById"
>;
sshCertificateAuthorityDAL: Pick<TSshCertificateAuthorityDALFactory, "findById">; sshCertificateAuthorityDAL: Pick<TSshCertificateAuthorityDALFactory, "findById">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
}; };
@@ -62,36 +66,45 @@ export const sshCertificateTemplateServiceFactory = ({
OrgPermissionSubjects.SshCertificateTemplates OrgPermissionSubjects.SshCertificateTemplates
); );
const existingTemplate = await sshCertificateTemplateDAL.getByName(name, ca.orgId);
if (existingTemplate) {
throw new BadRequestError({
message: `SSH certificate template with name ${name} already exists`
});
}
if (ms(ttl) > ms(maxTTL)) { if (ms(ttl) > ms(maxTTL)) {
throw new BadRequestError({ throw new BadRequestError({
message: "TTL cannot be greater than max TTL" message: "TTL cannot be greater than max TTL"
}); });
} }
const certificateTemplate = await sshCertificateTemplateDAL.create({ const newCertificateTemplate = await sshCertificateTemplateDAL.transaction(async (tx) => {
sshCaId, const existingTemplate = await sshCertificateTemplateDAL.getByName(name, ca.orgId, tx);
name, if (existingTemplate) {
ttl, throw new BadRequestError({
maxTTL, message: `SSH certificate template with name ${name} already exists`
allowUserCertificates, });
allowHostCertificates, }
allowedUsers,
allowedHosts, const certificateTemplate = await sshCertificateTemplateDAL.create(
allowCustomKeyIds {
sshCaId,
name,
ttl,
maxTTL,
allowUserCertificates,
allowHostCertificates,
allowedUsers,
allowedHosts,
allowCustomKeyIds,
status: SshCertTemplateStatus.ACTIVE
},
tx
);
return certificateTemplate;
}); });
return { certificateTemplate, ca }; return { certificateTemplate: newCertificateTemplate, ca };
}; };
const updateSshCertTemplate = async ({ const updateSshCertTemplate = async ({
id, id,
status,
name, name,
ttl, ttl,
maxTTL, maxTTL,
@@ -125,34 +138,43 @@ export const sshCertificateTemplateServiceFactory = ({
OrgPermissionSubjects.SshCertificateTemplates OrgPermissionSubjects.SshCertificateTemplates
); );
if (name) { const updatedCertificateTemplate = await sshCertificateTemplateDAL.transaction(async (tx) => {
const existingTemplate = await sshCertificateTemplateDAL.getByName(name, actorOrgId); if (name) {
if (existingTemplate && existingTemplate.id !== id) { const existingTemplate = await sshCertificateTemplateDAL.getByName(name, actorOrgId, 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({ throw new BadRequestError({
message: `SSH certificate template with name ${name} already exists` message: "TTL cannot be greater than max TTL"
}); });
} }
}
if (ms(ttl || certTemplate.ttl) > ms(maxTTL || certTemplate.maxTTL)) { const certificateTemplate = await sshCertificateTemplateDAL.updateById(
throw new BadRequestError({ id,
message: "TTL cannot be greater than max TTL" {
}); status,
} name,
ttl,
maxTTL,
allowUserCertificates,
allowHostCertificates,
allowedUsers,
allowedHosts,
allowCustomKeyIds
},
tx
);
const certificateTemplate = await sshCertificateTemplateDAL.updateById(id, { return certificateTemplate;
name,
ttl,
maxTTL,
allowUserCertificates,
allowHostCertificates,
allowedUsers,
allowedHosts,
allowCustomKeyIds
}); });
return { return {
certificateTemplate, certificateTemplate: updatedCertificateTemplate,
orgId: certTemplate.orgId orgId: certTemplate.orgId
}; };
}; };
@@ -1,5 +1,10 @@
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
export enum SshCertTemplateStatus {
ACTIVE = "active",
DISABLED = "disabled"
}
export type TCreateSshCertTemplateDTO = { export type TCreateSshCertTemplateDTO = {
sshCaId: string; sshCaId: string;
name: string; name: string;
@@ -14,6 +19,7 @@ export type TCreateSshCertTemplateDTO = {
export type TUpdateSshCertTemplateDTO = { export type TUpdateSshCertTemplateDTO = {
id: string; id: string;
status?: SshCertTemplateStatus;
name?: string; name?: string;
ttl?: string; ttl?: string;
maxTTL?: string; maxTTL?: string;
@@ -8,5 +8,7 @@ export const sanitizedSshCertificate = SshCertificatesSchema.pick({
certType: true, certType: true,
publicKey: true, publicKey: true,
principals: true, principals: true,
keyId: true keyId: true,
notBefore: true,
notAfter: true
}); });
@@ -53,7 +53,9 @@ export const createSshKeyPair = (keyAlgorithm: CertKeyAlgorithm, comment: string
keyBits = "384"; keyBits = "384";
break; break;
default: default:
throw new Error("Failed to produce SSH CA key pair generation command due to unrecognized key algorithm"); throw new BadRequestError({
message: "Failed to produce SSH CA key pair generation command due to unrecognized key algorithm"
});
} }
execSync(`ssh-keygen -t ${keyType} -b ${keyBits} -f ${privateKeyFile} -N '' -C "${comment}"`); execSync(`ssh-keygen -t ${keyType} -b ${keyBits} -f ${privateKeyFile} -N '' -C "${comment}"`);
@@ -200,7 +202,7 @@ export const validateSshCertificatePrincipals = (
* @param ttl - The TTL to validate * @param ttl - The TTL to validate
* @returns The TTL (in seconds) to use for issuing the SSH certificate * @returns The TTL (in seconds) to use for issuing the SSH certificate
*/ */
export const validateSshCertificateTtl = (template: TSshCertificateTemplates, ttl: string | undefined) => { export const validateSshCertificateTtl = (template: TSshCertificateTemplates, ttl?: string) => {
if (!ttl) { if (!ttl) {
// use default template ttl // use default template ttl
return ms(template.ttl) / 1000; return ms(template.ttl) / 1000;
@@ -249,6 +251,8 @@ export const createSshCert = ({ caPrivateKey, userPublicKey, keyId, principals,
const command = `ssh-keygen ${certOptions}`; const command = `ssh-keygen ${certOptions}`;
console.log("executing command", command);
// Execute the signing process // Execute the signing process
execSync(command); execSync(command);
@@ -9,6 +9,7 @@ import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certific
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { SshCertTemplateStatus } from "../ssh-certificate-template/ssh-certificate-template-types";
import { import {
createSshCert, createSshCert,
createSshKeyPair, createSshKeyPair,
@@ -23,6 +24,7 @@ import {
TDeleteSshCaDTO, TDeleteSshCaDTO,
TGetSshCaCertificateTemplatesDTO, TGetSshCaCertificateTemplatesDTO,
TGetSshCaDTO, TGetSshCaDTO,
TGetSshCaPublicKeyDTO,
TIssueSshCredsDTO, TIssueSshCredsDTO,
TSignSshKeyDTO, TSignSshKeyDTO,
TUpdateSshCaDTO TUpdateSshCaDTO
@@ -147,6 +149,30 @@ export const sshCertificateAuthorityServiceFactory = ({
return { ...ca, publicKey }; 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 });
// decrypt secret
const orgKmsKeyId = await kmsService.getOrgKmsKeyId(ca.orgId);
const kmsDecryptor = await kmsService.decryptWithKmsKey({
kmsId: orgKmsKeyId
});
const decryptedCaPrivateKey = await kmsDecryptor({
cipherTextBlob: sshCaSecret.encryptedPrivateKey
});
const publicKey = getSshPublicKey(decryptedCaPrivateKey.toString("utf-8"));
return publicKey;
};
/** /**
* Update SSH CA with id [caId] * Update SSH CA with id [caId]
* Note: Used to enable/disable CA * Note: Used to enable/disable CA
@@ -259,6 +285,12 @@ export const sshCertificateAuthorityServiceFactory = ({
}); });
} }
if (sshCertificateTemplate.status === SshCertTemplateStatus.DISABLED) {
throw new BadRequestError({
message: "SSH certificate template is disabled"
});
}
// validate if the requested [certType] is allowed under the template configuration // validate if the requested [certType] is allowed under the template configuration
validateSshCertificateType(sshCertificateTemplate, certType); validateSshCertificateType(sshCertificateTemplate, certType);
@@ -359,6 +391,12 @@ export const sshCertificateAuthorityServiceFactory = ({
}); });
} }
if (sshCertificateTemplate.status === SshCertTemplateStatus.DISABLED) {
throw new BadRequestError({
message: "SSH certificate template is disabled"
});
}
// validate if the requested [certType] is allowed under the template configuration // validate if the requested [certType] is allowed under the template configuration
validateSshCertificateType(sshCertificateTemplate, certType); validateSshCertificateType(sshCertificateTemplate, certType);
@@ -445,6 +483,7 @@ export const sshCertificateAuthorityServiceFactory = ({
signSshKey, signSshKey,
createSshCa, createSshCa,
getSshCaById, getSshCaById,
getSshCaPublicKey,
updateSshCaById, updateSshCaById,
deleteSshCaById, deleteSshCaById,
getSshCaCertificateTemplates getSshCaCertificateTemplates
@@ -20,6 +20,10 @@ export type TGetSshCaDTO = {
caId: string; caId: string;
} & Omit<TOrgPermission, "orgId">; } & Omit<TOrgPermission, "orgId">;
export type TGetSshCaPublicKeyDTO = {
caId: string;
};
export type TUpdateSshCaDTO = { export type TUpdateSshCaDTO = {
caId: string; caId: string;
friendlyName?: string; friendlyName?: string;
+3
View File
@@ -1154,6 +1154,9 @@ export const SSH_CERTIFICATE_AUTHORITIES = {
GET: { GET: {
sshCaId: "The ID of the SSH CA to get." sshCaId: "The ID of the SSH CA to get."
}, },
GET_PUBLIC_KEY: {
sshCaId: "The ID of the SSH CA to get the public key for."
},
UPDATE: { UPDATE: {
sshCaId: "The ID of the SSH CA to update.", sshCaId: "The ID of the SSH CA to update.",
friendlyName: "A friendly name for the SSH CA to update to.", friendlyName: "A friendly name for the SSH CA to update to.",
@@ -425,7 +425,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
response: { response: {
200: z.object({ 200: z.object({
certificates: z.array(sanitizedSshCertificate), certificates: z.array(sanitizedSshCertificate),
totalCount: z.number() // TODO totalCount: z.number()
}) })
} }
}, },
+2 -1
View File
@@ -1,4 +1,5 @@
import { SshCaStatus } from "@app/hooks/api/ssh-ca"; import { SshCaStatus } from "@app/hooks/api/ssh-ca";
import { SshCertTemplateStatus } from "@app/hooks/api/sshCertificateTemplates";
import { CaStatus, CaType } from "./enums"; import { CaStatus, CaType } from "./enums";
@@ -13,7 +14,7 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = {
[CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate"
}; };
export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus) => { export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus | SshCertTemplateStatus) => {
switch (status) { switch (status) {
case CaStatus.ACTIVE: case CaStatus.ACTIVE:
return "success"; return "success";
+2
View File
@@ -10,6 +10,8 @@ export type TSshCertificate = {
publicKey: string; publicKey: string;
principals: string[]; principals: string[];
keyId: string; keyId: string;
notBefore: string;
notAfter: string;
}; };
export type TSshCertificateAuthority = { export type TSshCertificateAuthority = {
@@ -1,5 +1,7 @@
export { export {
useCreateSshCertTemplate, useCreateSshCertTemplate,
useDeleteSshCertTemplate, useDeleteSshCertTemplate,
useUpdateSshCertTemplate} from "./mutations"; useUpdateSshCertTemplate
} from "./mutations";
export { useGetSshCertTemplate } from "./queries"; export { useGetSshCertTemplate } from "./queries";
export * from "./types";
@@ -1,6 +1,12 @@
export enum SshCertTemplateStatus {
ACTIVE = "active",
DISABLED = "disabled"
}
export type TSshCertificateTemplate = { export type TSshCertificateTemplate = {
id: string; id: string;
sshCaId: string; sshCaId: string;
status: SshCertTemplateStatus;
name: string; name: string;
ttl: string; ttl: string;
maxTTL: string; maxTTL: string;
@@ -25,6 +31,7 @@ export type TCreateSshCertificateTemplateDTO = {
export type TUpdateSshCertificateTemplateDTO = { export type TUpdateSshCertificateTemplateDTO = {
id: string; id: string;
status?: SshCertTemplateStatus;
name?: string; name?: string;
ttl?: string; ttl?: string;
maxTTL?: string; maxTTL?: string;
@@ -14,7 +14,12 @@ import {
SelectItem SelectItem
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
import { useIssueSshCreds, useListOrgSshCertificateTemplates, useSignSshKey } from "@app/hooks/api"; import {
SshCertTemplateStatus,
useGetSshCertTemplate,
useIssueSshCreds,
useListOrgSshCertificateTemplates,
useSignSshKey} from "@app/hooks/api";
import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants";
import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums";
import { SshCertType } from "@app/hooks/api/ssh-ca/constants"; import { SshCertType } from "@app/hooks/api/ssh-ca/constants";
@@ -22,14 +27,8 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
import { SshCertificateContent } from "./SshCertificateContent"; import { SshCertificateContent } from "./SshCertificateContent";
/**
* // NOTE (dangtony98): current UI only supports SSH certificate
* issuance via /issue endpoint but should extend to also support
* /sign endpoint as this is already supported in the backend
*/
const schema = z.object({ const schema = z.object({
templateName: z.string(), templateId: z.string(),
publicKey: z.string().optional(), publicKey: z.string().optional(),
keyAlgorithm: z.enum([ keyAlgorithm: z.enum([
CertKeyAlgorithm.RSA_2048, CertKeyAlgorithm.RSA_2048,
@@ -72,7 +71,11 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
const { mutateAsync: signSshKey } = useSignSshKey(); const { mutateAsync: signSshKey } = useSignSshKey();
const { mutateAsync: issueSshCreds } = useIssueSshCreds(); const { mutateAsync: issueSshCreds } = useIssueSshCreds();
const popUpData = popUp?.sshCertificate?.data as { sshCaId: string; templateName: string }; const popUpData = popUp?.sshCertificate?.data as {
sshCaId: string;
templateName: string;
templateId: string;
};
const { data: templatesData } = useListOrgSshCertificateTemplates({ const { data: templatesData } = useListOrgSshCertificateTemplates({
orgId: currentOrg?.id || "" orgId: currentOrg?.id || ""
@@ -83,7 +86,8 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
handleSubmit, handleSubmit,
reset, reset,
formState: { isSubmitting }, formState: { isSubmitting },
setValue setValue,
watch
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
@@ -92,16 +96,18 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
} }
}); });
const templateId = watch("templateId");
const { data: templateData } = useGetSshCertTemplate(templateId);
useEffect(() => { useEffect(() => {
if (popUpData) { if (popUpData) {
setValue("templateName", popUpData.templateName); setValue("templateId", popUpData.templateId);
} else if (templatesData && templatesData.certificateTemplates.length > 0) { } else if (templatesData && templatesData.certificateTemplates.length > 0) {
setValue("templateName", templatesData.certificateTemplates[0].name); setValue("templateId", templatesData.certificateTemplates[0].id);
} }
}, [popUpData]); }, [popUpData]);
const onFormSubmit = async ({ const onFormSubmit = async ({
templateName,
keyAlgorithm, keyAlgorithm,
certType, certType,
publicKey: existingPublicKey, publicKey: existingPublicKey,
@@ -110,10 +116,12 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
keyId keyId
}: FormData) => { }: FormData) => {
try { try {
if (!templateData) return;
switch (operation) { switch (operation) {
case SshCertificateOperation.SIGN_SSH_KEY: { case SshCertificateOperation.SIGN_SSH_KEY: {
const { serialNumber, signedKey } = await signSshKey({ const { serialNumber, signedKey } = await signSshKey({
templateName, templateName: templateData.name,
publicKey: existingPublicKey, publicKey: existingPublicKey,
certType, certType,
principals: principals.split(",").map((user) => user.trim()), principals: principals.split(",").map((user) => user.trim()),
@@ -129,7 +137,7 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
} }
case SshCertificateOperation.ISSUE_SSH_CREDS: { case SshCertificateOperation.ISSUE_SSH_CREDS: {
const { serialNumber, publicKey, privateKey, signedKey } = await issueSshCreds({ const { serialNumber, publicKey, privateKey, signedKey } = await issueSshCreds({
templateName, templateName: templateData.name,
keyAlgorithm, keyAlgorithm,
certType, certType,
principals: principals.split(",").map((user) => user.trim()), principals: principals.split(",").map((user) => user.trim()),
@@ -179,7 +187,7 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<Controller <Controller
control={control} control={control}
name="templateName" name="templateId"
defaultValue="" defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl <FormControl
@@ -195,11 +203,13 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
className="w-full" className="w-full"
isDisabled={Boolean(popUpData?.sshCaId)} isDisabled={Boolean(popUpData?.sshCaId)}
> >
{(templatesData?.certificateTemplates || []).map(({ id, name }) => ( {(templatesData?.certificateTemplates || [])
<SelectItem value={name} key={`ssh-cert-template-${id}`}> .filter((template) => template.status === SshCertTemplateStatus.ACTIVE)
{name} .map(({ id, name }) => (
</SelectItem> <SelectItem value={id} key={`ssh-cert-template-${id}`}>
))} {name}
</SelectItem>
))}
</Select> </Select>
</FormControl> </FormControl>
)} )}
@@ -235,8 +245,12 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
onValueChange={(e) => onChange(e)} onValueChange={(e) => onChange(e)}
className="w-full" className="w-full"
> >
<SelectItem value={SshCertType.USER}>User</SelectItem> {templateData && templateData.allowUserCertificates && (
<SelectItem value={SshCertType.HOST}>Host</SelectItem> <SelectItem value={SshCertType.USER}>User</SelectItem>
)}
{templateData && templateData.allowHostCertificates && (
<SelectItem value={SshCertType.HOST}>Host</SelectItem>
)}
</Select> </Select>
</FormControl> </FormControl>
)} )}
@@ -308,15 +322,17 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl> </FormControl>
)} )}
/> />
<Controller {templateData && templateData.allowCustomKeyIds && (
control={control} <Controller
name="keyId" control={control}
render={({ field, fieldState: { error } }) => ( name="keyId"
<FormControl label="Key ID" isError={Boolean(error)} errorText={error?.message}> render={({ field, fieldState: { error } }) => (
<Input {...field} placeholder="12345678" /> <FormControl label="Key ID" isError={Boolean(error)} errorText={error?.message}>
</FormControl> <Input {...field} placeholder="12345678" />
)} </FormControl>
/> )}
/>
)}
<div className="mt-4 flex items-center"> <div className="mt-4 flex items-center">
<Button <Button
className="mr-4" className="mr-4"
@@ -6,7 +6,10 @@ import { OrgPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, IconButton } from "@app/components/v2"; import { DeleteActionModal, IconButton } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useDeleteSshCertTemplate } from "@app/hooks/api"; import {
SshCertTemplateStatus,
useDeleteSshCertTemplate,
useUpdateSshCertTemplate} from "@app/hooks/api";
import { SshCertificateModal } from "./SshCertificateModal"; import { SshCertificateModal } from "./SshCertificateModal";
import { SshCertificateTemplateModal } from "./SshCertificateTemplateModal"; import { SshCertificateTemplateModal } from "./SshCertificateTemplateModal";
@@ -19,12 +22,14 @@ type Props = {
export const SshCertificateTemplatesSection = ({ caId }: Props) => { export const SshCertificateTemplatesSection = ({ caId }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"sshCertificateTemplate", "sshCertificateTemplate",
"sshCertificateTemplateStatus",
"sshCertificate", "sshCertificate",
"deleteSshCertificateTemplate", "deleteSshCertificateTemplate",
"upgradePlan" "upgradePlan"
] as const); ] as const);
const { mutateAsync: deleteSshCertTemplate } = useDeleteSshCertTemplate(); const { mutateAsync: deleteSshCertTemplate } = useDeleteSshCertTemplate();
const { mutateAsync: updateSshCertTemplate } = useUpdateSshCertTemplate();
const onRemoveSshCertificateTemplateSubmit = async (id: string) => { const onRemoveSshCertificateTemplateSubmit = async (id: string) => {
try { try {
@@ -47,6 +52,35 @@ export const SshCertificateTemplatesSection = ({ caId }: Props) => {
} }
}; };
const onUpdateSshCaStatus = async ({
certTemplateId,
status
}: {
certTemplateId: string;
status: SshCertTemplateStatus;
}) => {
try {
await updateSshCertTemplate({ id: certTemplateId, status });
await createNotification({
text: `Successfully ${
status === SshCertTemplateStatus.ACTIVE ? "enabled" : "disabled"
} SSH certificate template`,
type: "success"
});
handlePopUpClose("sshCertificateTemplateStatus");
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${
status === SshCertTemplateStatus.ACTIVE ? "enabled" : "disabled"
} SSH certificate template`,
type: "error"
});
}
};
return ( return (
<div className="h-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="h-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4"> <div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
@@ -90,6 +124,37 @@ export const SshCertificateTemplatesSection = ({ caId }: Props) => {
) )
} }
/> />
<DeleteActionModal
isOpen={popUp.sshCertificateTemplateStatus.isOpen}
title={`Are you sure want to ${
(popUp?.sshCertificateTemplateStatus?.data as { status: string })?.status ===
SshCertTemplateStatus.ACTIVE
? "enable"
: "disable"
} this certificate template?`}
subTitle={
(popUp?.sshCertificateTemplateStatus?.data as { status: string })?.status ===
SshCertTemplateStatus.ACTIVE
? "This action will allow certificate issuance under this template again."
: "This action will prevent certificate issuance under this template."
}
onChange={(isOpen) => handlePopUpToggle("sshCertificateTemplateStatus", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onUpdateSshCaStatus(
popUp?.sshCertificateTemplateStatus?.data as {
certTemplateId: string;
status: SshCertTemplateStatus;
}
)
}
buttonText={
(popUp?.sshCertificateTemplateStatus?.data as { status: string })?.status ===
SshCertTemplateStatus.ACTIVE
? "Enable"
: "Disable"
}
/>
</div> </div>
); );
}; };
@@ -1,9 +1,16 @@
import { faCertificate, faEllipsis, faFileAlt, faTrash } from "@fortawesome/free-solid-svg-icons"; import {
faBan,
faCertificate,
faEllipsis,
faFileAlt,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan } from "@app/components/permissions";
import { import {
Badge,
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
@@ -20,19 +27,28 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { useGetSshCaCertTemplates } from "@app/hooks/api"; import { SshCertTemplateStatus,useGetSshCaCertTemplates } from "@app/hooks/api";
import { caStatusToNameMap, getCaStatusBadgeVariant } from "@app/hooks/api/ca/constants";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = { type Props = {
sshCaId: string; sshCaId: string;
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState< popUpName: keyof UsePopUpState<
["sshCertificateTemplate", "sshCertificate", "deleteSshCertificateTemplate", "upgradePlan"] [
"sshCertificateTemplate",
"sshCertificateTemplateStatus",
"sshCertificate",
"deleteSshCertificateTemplate",
"upgradePlan"
]
>, >,
data?: { data?: {
id?: string; id?: string;
name?: string; name?: string;
sshCaId?: string; sshCaId?: string;
certTemplateId?: string;
status?: SshCertTemplateStatus;
templateName?: string; templateName?: string;
} }
) => void; ) => void;
@@ -40,7 +56,6 @@ type Props = {
export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props) => { export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props) => {
const { data, isLoading } = useGetSshCaCertTemplates(sshCaId); const { data, isLoading } = useGetSshCaCertTemplates(sshCaId);
return ( return (
<div> <div>
<TableContainer> <TableContainer>
@@ -48,6 +63,7 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props
<THead> <THead>
<Tr> <Tr>
<Th>Name</Th> <Th>Name</Th>
<Th>Status</Th>
<Th /> <Th />
</Tr> </Tr>
</THead> </THead>
@@ -58,6 +74,11 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props
return ( return (
<Tr className="h-10" key={`certificate-${certificateTemplate.id}`}> <Tr className="h-10" key={`certificate-${certificateTemplate.id}`}>
<Td>{certificateTemplate.name}</Td> <Td>{certificateTemplate.name}</Td>
<Td>
<Badge variant={getCaStatusBadgeVariant(certificateTemplate.status)}>
{caStatusToNameMap[certificateTemplate.status]}
</Badge>
</Td>
<Td className="flex justify-end"> <Td className="flex justify-end">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg"> <DropdownMenuTrigger asChild className="rounded-lg">
@@ -68,6 +89,36 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1"> <DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.SshCertificateTemplates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("sshCertificateTemplateStatus", {
certTemplateId: certificateTemplate.id,
status:
certificateTemplate.status === SshCertTemplateStatus.ACTIVE
? SshCertTemplateStatus.DISABLED
: SshCertTemplateStatus.ACTIVE
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faBan} />}
>
{`${
certificateTemplate.status === SshCertTemplateStatus.ACTIVE
? "Disable"
: "Enable"
} Template`}
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan <OrgPermissionCan
I={OrgPermissionActions.Edit} I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.SshCertificateTemplates} a={OrgPermissionSubjects.SshCertificateTemplates}
@@ -83,7 +134,7 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props
<FontAwesomeIcon icon={faCertificate} size="sm" className="mr-1" /> <FontAwesomeIcon icon={faCertificate} size="sm" className="mr-1" />
} }
> >
Issue SSH Certificate Issue Certificate
</DropdownMenuItem> </DropdownMenuItem>
</OrgPermissionCan> </OrgPermissionCan>
<OrgPermissionCan <OrgPermissionCan
@@ -1,7 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { faCertificate } from "@fortawesome/free-solid-svg-icons"; import { faCertificate } from "@fortawesome/free-solid-svg-icons";
import { format } from "date-fns";
import { import {
Badge,
EmptyState, EmptyState,
Pagination, Pagination,
Table, Table,
@@ -15,7 +17,8 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
import { useListOrgSshCertificates } from "@app/hooks/api"; import { useListOrgSshCertificates } from "@app/hooks/api";
import { sshCertTypeToNameMap } from "@app/hooks/api/ssh-ca/constants";
import { getSshCertStatusBadgeDetails } from "./SshCertificatesTable.utils";
const PER_PAGE_INIT = 25; const PER_PAGE_INIT = 25;
@@ -30,27 +33,38 @@ export const SshCertificatesTable = () => {
limit: perPage limit: perPage
}); });
console.log("SSH Certificates Table data: ", data);
return ( return (
<TableContainer> <TableContainer>
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Serial Number</Th>
<Th>Certificate Type</Th>
<Th>Principals</Th> <Th>Principals</Th>
<Th>Status</Th>
<Th>Not Before</Th>
<Th>Not After</Th>
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isLoading && <TableSkeleton columns={4} innerKey="org-ssh-certificates" />} {isLoading && <TableSkeleton columns={4} innerKey="org-ssh-certificates" />}
{!isLoading && {!isLoading &&
data?.certificates?.map((certificate) => { data?.certificates?.map((certificate) => {
const { variant, label } = getSshCertStatusBadgeDetails(certificate.notAfter);
return ( return (
<Tr className="h-10" key={`certificate-${certificate.id}`}> <Tr className="h-10" key={`certificate-${certificate.id}`}>
<Td>{certificate.serialNumber}</Td>
<Td>{sshCertTypeToNameMap[certificate.certType]}</Td>
<Td>{certificate.principals.join(", ")}</Td> <Td>{certificate.principals.join(", ")}</Td>
<Td>
<Badge variant={variant}>{label}</Badge>
</Td>
<Td>
{certificate.notBefore
? format(new Date(certificate.notBefore), "yyyy-MM-dd")
: "-"}
</Td>
<Td>
{certificate.notAfter
? format(new Date(certificate.notAfter), "yyyy-MM-dd")
: "-"}
</Td>
</Tr> </Tr>
); );
})} })}
@@ -0,0 +1,17 @@
export const getSshCertStatusBadgeDetails = (notAfter: string) => {
const currentDate = new Date().getTime();
const notAfterDate = new Date(notAfter).getTime();
let variant: "success" | "primary" | "danger" = "success";
let label = "Active";
if (notAfterDate > currentDate) {
variant = "success";
label = "Active";
} else {
variant = "danger";
label = "Expired";
}
return { variant, label };
};