Begin developing pki subscriber

This commit is contained in:
Tuan Dang
2025-05-06 09:44:57 -07:00
parent d1122886fd
commit cf4f2ea6b1
44 changed files with 1996 additions and 6 deletions

View File

@@ -72,11 +72,13 @@ import { TIdentityTokenAuthServiceFactory } from "@app/services/identity-token-a
import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service";
import { TIntegrationServiceFactory } from "@app/services/integration/integration-service";
import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service";
import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service";
import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service";
import { TOrgServiceFactory } from "@app/services/org/org-service";
import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service";
import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service";
import { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service";
import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service";
import { TProjectServiceFactory } from "@app/services/project/project-service";
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env-service";
@@ -101,7 +103,6 @@ import { TUserServiceFactory } from "@app/services/user/user-service";
import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service";
import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service";
import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service";
import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service";
declare module "@fastify/request-context" {
interface RequestContextData {
@@ -220,6 +221,7 @@ declare module "fastify" {
certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory;
certificateEst: TCertificateEstServiceFactory;
pkiCollection: TPkiCollectionServiceFactory;
pkiSubscriber: TPkiSubscriberServiceFactory;
secretScanning: TSecretScanningServiceFactory;
license: TLicenseServiceFactory;
trustedIp: TTrustedIpServiceFactory;

View File

@@ -209,6 +209,9 @@ import {
TPkiCollections,
TPkiCollectionsInsert,
TPkiCollectionsUpdate,
TPkiSubscribers,
TPkiSubscribersInsert,
TPkiSubscribersUpdate,
TProjectBots,
TProjectBotsInsert,
TProjectBotsUpdate,
@@ -559,6 +562,11 @@ declare module "knex/types/tables" {
TPkiCollectionItemsInsert,
TPkiCollectionItemsUpdate
>;
[TableName.PkiSubscriber]: KnexOriginal.CompositeTableType<
TPkiSubscribers,
TPkiSubscribersInsert,
TPkiSubscribersUpdate
>;
[TableName.UserGroupMembership]: KnexOriginal.CompositeTableType<
TUserGroupMembership,
TUserGroupMembershipInsert,

View File

@@ -0,0 +1,30 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.PkiSubscriber))) {
await knex.schema.createTable(TableName.PkiSubscriber, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.timestamps(true, true, true);
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.uuid("caId").notNullable();
t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE");
t.string("name").notNullable();
t.string("commonName").notNullable();
t.specificType("subjectAlternativeNames", "text[]").notNullable();
t.string("ttl").notNullable();
t.specificType("keyUsages", "text[]").notNullable();
t.specificType("extendedKeyUsages", "text[]").notNullable();
t.unique(["projectId", "name"]);
});
await createOnUpdateTrigger(knex, TableName.PkiSubscriber);
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.PkiSubscriber);
await dropOnUpdateTrigger(knex, TableName.PkiSubscriber);
}

View File

@@ -69,6 +69,7 @@ export * from "./organizations";
export * from "./pki-alerts";
export * from "./pki-collection-items";
export * from "./pki-collections";
export * from "./pki-subscribers";
export * from "./project-bots";
export * from "./project-environments";
export * from "./project-gateways";

View File

@@ -21,6 +21,7 @@ export enum TableName {
CertificateBody = "certificate_bodies",
CertificateSecret = "certificate_secrets",
CertificateTemplate = "certificate_templates",
PkiSubscriber = "pki_subscribers",
PkiAlert = "pki_alerts",
PkiCollection = "pki_collections",
PkiCollectionItem = "pki_collection_items",

View File

@@ -0,0 +1,26 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const PkiSubscribersSchema = z.object({
id: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
projectId: z.string(),
caId: z.string().uuid(),
name: z.string(),
commonName: z.string(),
subjectAlternativeNames: z.string().array(),
ttl: z.string(),
keyUsages: z.string().array(),
extendedKeyUsages: z.string().array()
});
export type TPkiSubscribers = z.infer<typeof PkiSubscribersSchema>;
export type TPkiSubscribersInsert = Omit<z.input<typeof PkiSubscribersSchema>, TImmutableDBKeys>;
export type TPkiSubscribersUpdate = Partial<Omit<z.input<typeof PkiSubscribersSchema>, TImmutableDBKeys>>;

View File

@@ -27,7 +27,7 @@ export const ProjectsSchema = z.object({
description: z.string().nullable().optional(),
type: z.string(),
enforceCapitalization: z.boolean().default(false),
hasDeleteProtection: z.boolean().default(true).nullable().optional()
hasDeleteProtection: z.boolean().default(false).nullable().optional()
});
export type TProjects = z.infer<typeof ProjectsSchema>;

View File

@@ -73,7 +73,7 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const host = await server.services.sshHost.getSshHost({
const host = await server.services.sshHost.getSshHostById({
sshHostId: req.params.sshHostId,
actor: req.permission.type,
actorId: req.permission.id,

View File

@@ -31,6 +31,7 @@ import {
TUpdateSecretSyncDTO
} from "@app/services/secret-sync/secret-sync-types";
import { WorkflowIntegration } from "@app/services/workflow-integration/workflow-integration-types";
import { CertKeyUsage, CertExtendedKeyUsage } from "@app/services/certificate/certificate-types";
import { KmipPermission } from "../kmip/kmip-enum";
import { ApprovalStatus } from "../secret-approval-request/secret-approval-request-types";
@@ -235,6 +236,10 @@ export enum EventType {
GET_PKI_COLLECTION_ITEMS = "get-pki-collection-items",
ADD_PKI_COLLECTION_ITEM = "add-pki-collection-item",
DELETE_PKI_COLLECTION_ITEM = "delete-pki-collection-item",
CREATE_PKI_SUBSCRIBER = "create-pki-subscriber",
UPDATE_PKI_SUBSCRIBER = "update-pki-subscriber",
DELETE_PKI_SUBSCRIBER = "delete-pki-subscriber",
GET_PKI_SUBSCRIBER = "get-pki-subscriber",
CREATE_KMS = "create-kms",
UPDATE_KMS = "update-kms",
DELETE_KMS = "delete-kms",
@@ -1879,6 +1884,48 @@ interface DeletePkiCollectionItem {
};
}
interface CreatePkiSubscriber {
type: EventType.CREATE_PKI_SUBSCRIBER;
metadata: {
pkiSubscriberId: string;
caId: string;
name: string;
commonName: string;
ttl: string;
subjectAlternativeNames: string[];
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
};
}
interface UpdatePkiSubscriber {
type: EventType.UPDATE_PKI_SUBSCRIBER;
metadata: {
pkiSubscriberId: string;
caId?: string;
name?: string;
commonName?: string;
ttl?: string;
subjectAlternativeNames?: string[];
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
};
}
interface DeletePkiSubscriber {
type: EventType.DELETE_PKI_SUBSCRIBER;
metadata: {
pkiSubscriberId: string;
};
}
interface GetPkiSubscriber {
type: EventType.GET_PKI_SUBSCRIBER;
metadata: {
pkiSubscriberId: string;
};
}
interface CreateKmsEvent {
type: EventType.CREATE_KMS;
metadata: {
@@ -2835,6 +2882,10 @@ export type Event =
| GetPkiCollectionItems
| AddPkiCollectionItem
| DeletePkiCollectionItem
| CreatePkiSubscriber
| UpdatePkiSubscriber
| DeletePkiSubscriber
| GetPkiSubscriber
| CreateKmsEvent
| UpdateKmsEvent
| DeleteKmsEvent

View File

@@ -79,6 +79,15 @@ export enum ProjectPermissionSshHostActions {
IssueHostCert = "issue-host-cert"
}
export enum ProjectPermissionPkiSubscriberActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
IssueCert = "issue-cert",
SignCert = "sign-cert"
}
export enum ProjectPermissionSecretSyncActions {
Read = "read",
Create = "create",
@@ -135,6 +144,7 @@ export enum ProjectPermissionSub {
SshCertificateTemplates = "ssh-certificate-templates",
SshHosts = "ssh-hosts",
SshHostGroups = "ssh-host-groups",
PkiSubscribers = "pki-subscribers",
PkiAlerts = "pki-alerts",
PkiCollections = "pki-collections",
Kms = "kms",
@@ -241,6 +251,7 @@ export type ProjectPermissionSet =
ProjectPermissionSshHostActions,
ProjectPermissionSub.SshHosts | (ForcedSubject<ProjectPermissionSub.SshHosts> & SshHostSubjectFields)
]
| [ProjectPermissionPkiSubscriberActions, ProjectPermissionSub.PkiSubscribers] // (dangtony98): TODO: update
| [ProjectPermissionActions, ProjectPermissionSub.SshHostGroups]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
@@ -719,6 +730,17 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.SshHosts
);
can(
[
ProjectPermissionPkiSubscriberActions.Read,
ProjectPermissionPkiSubscriberActions.Create,
ProjectPermissionPkiSubscriberActions.Edit,
ProjectPermissionPkiSubscriberActions.Delete,
ProjectPermissionPkiSubscriberActions.IssueCert
],
ProjectPermissionSub.PkiSubscribers
);
can(
[
ProjectPermissionMemberActions.Create,
@@ -977,6 +999,7 @@ const buildMemberPermissionRules = () => {
can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections);
can([ProjectPermissionPkiSubscriberActions.Read], ProjectPermissionSub.PkiSubscribers);
can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificates);
can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates);

View File

@@ -324,7 +324,7 @@ export const sshHostServiceFactory = ({
return host;
};
const getSshHost = async ({ sshHostId, actorId, actorAuthMethod, actor, actorOrgId }: TGetSshHostDTO) => {
const getSshHostById = async ({ sshHostId, actorId, actorAuthMethod, actor, actorOrgId }: TGetSshHostDTO) => {
const host = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId);
if (!host) {
throw new NotFoundError({
@@ -616,7 +616,7 @@ export const sshHostServiceFactory = ({
createSshHost,
updateSshHost,
deleteSshHost,
getSshHost,
getSshHostById,
issueSshHostUserCert,
issueSshHostHostCert,
getSshHostUserCaPk,

View File

@@ -45,6 +45,7 @@ export enum ApiDocsTags {
PkiCertificateTemplates = "PKI Certificate Templates",
PkiCertificateCollections = "PKI Certificate Collections",
PkiAlerting = "PKI Alerting",
PkiSubscribers = "PKI Subscribers",
SshCertificates = "SSH Certificates",
SshCertificateAuthorities = "SSH Certificate Authorities",
SshCertificateTemplates = "SSH Certificate Templates",
@@ -595,6 +596,9 @@ export const PROJECTS = {
commonName: "The common name of the certificate to filter by.",
offset: "The offset to start from. If you enter 10, it will start from the 10th certificate.",
limit: "The number of certificates to return."
},
LIST_PKI_SUBSCRIBERS: {
projectId: "The ID of the project to list PKI subscribers for."
}
} as const;
@@ -1686,6 +1690,46 @@ export const ALERTS = {
}
};
export const PKI_SUBSCRIBERS = {
GET: {
subscriberId: "The ID of the PKI subscriber to get."
},
CREATE: {
projectId: "The ID of the project to create the PKI subscriber in.",
caId: "The ID of the CA that will issue certificates for the PKI subscriber.",
name: "The name of the PKI subscriber.",
commonName: "The common name (CN) to be used on certificates issued for this subscriber.",
ttl: "The time to live for the certificates issued for this subscriber such as 1m, 1h, 1d, 1y, ...",
subjectAlternativeNames:
"A list of Subject Alternative Names (SANs) to be used on certificates issued for this subscriber; these can be host names or email addresses.",
keyUsages: "The key usage extension to be used on certificates issued for this subscriber.",
extendedKeyUsages: "The extended key usage extension to be used on certificates issued for this subscriber."
},
UPDATE: {
subscriberId: "The ID of the PKI subscriber to update.",
caId: "The ID of the CA that will issue certificates for the PKI subscriber to update to.",
name: "The name of the PKI subscriber to update to.",
commonName: "The common name (CN) to be used on certificates issued for this subscriber to update to.",
ttl: "The time to live for the certificates issued for this subscriber such as 1m, 1h, 1d, 1y, ...",
subjectAlternativeNames:
"A comma-delimited list of Subject Alternative Names (SANs) to be used on certificates issued for this subscriber; these can be host names or email addresses.",
keyUsages: "The key usage extension to be used on certificates issued for this subscriber to update to.",
extendedKeyUsages:
"The extended key usage extension to be used on certificates issued for this subscriber to update to."
},
DELETE: {
subscriberId: "The ID of the PKI subscriber to delete."
},
ISSUE_CERT: {
subscriberId: "The ID of the PKI subscriber to issue the certificate for.",
certificate: "The issued certificate.",
issuingCaCertificate: "The certificate of the issuing CA.",
certificateChain: "The certificate chain of the issued certificate.",
privateKey: "The private key of the issued certificate.",
serialNumber: "The serial number of the issued certificate."
}
};
export const PKI_COLLECTIONS = {
CREATE: {
projectId: "The ID of the project to create the PKI collection in.",

View File

@@ -194,6 +194,8 @@ import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-servic
import { pkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal";
import { pkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal";
import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service";
import { pkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
import { pkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service";
import { projectDALFactory } from "@app/services/project/project-dal";
import { projectQueueFactory } from "@app/services/project/project-queue";
import { projectServiceFactory } from "@app/services/project/project-service";
@@ -816,6 +818,7 @@ export const registerRoutes = async (
const pkiAlertDAL = pkiAlertDALFactory(db);
const pkiCollectionDAL = pkiCollectionDALFactory(db);
const pkiCollectionItemDAL = pkiCollectionItemDALFactory(db);
const pkiSubscriberDAL = pkiSubscriberDALFactory(db);
const certificateService = certificateServiceFactory({
certificateDAL,
@@ -945,6 +948,11 @@ export const registerRoutes = async (
projectDAL
});
const pkiSubscriberService = pkiSubscriberServiceFactory({
pkiSubscriberDAL,
permissionService
});
const projectTemplateService = projectTemplateServiceFactory({
licenseService,
permissionService,
@@ -1042,6 +1050,7 @@ export const registerRoutes = async (
projectRoleDAL,
folderDAL,
licenseService,
pkiSubscriberDAL,
certificateAuthorityDAL,
certificateDAL,
pkiAlertDAL,
@@ -1717,6 +1726,7 @@ export const registerRoutes = async (
certificateEst: certificateEstService,
pkiAlert: pkiAlertService,
pkiCollection: pkiCollectionService,
pkiSubscriber: pkiSubscriberService,
secretScanning: secretScanningService,
license: licenseService,
trustedIp: trustedIpService,

View File

@@ -26,11 +26,13 @@ import { registerIdentityUaRouter } from "./identity-universal-auth-router";
import { registerIntegrationAuthRouter } from "./integration-auth-router";
import { registerIntegrationRouter } from "./integration-router";
import { registerInviteOrgRouter } from "./invite-org-router";
import { registerMicrosoftTeamsRouter } from "./microsoft-teams-router";
import { registerOrgAdminRouter } from "./org-admin-router";
import { registerOrgRouter } from "./organization-router";
import { registerPasswordRouter } from "./password-router";
import { registerPkiAlertRouter } from "./pki-alert-router";
import { registerPkiCollectionRouter } from "./pki-collection-router";
import { registerPkiSubscriberRouter } from "./pki-subscriber-router";
import { registerProjectEnvRouter } from "./project-env-router";
import { registerProjectKeyRouter } from "./project-key-router";
import { registerProjectMembershipRouter } from "./project-membership-router";
@@ -47,7 +49,6 @@ import { registerUserEngagementRouter } from "./user-engagement-router";
import { registerUserRouter } from "./user-router";
import { registerWebhookRouter } from "./webhook-router";
import { registerWorkflowIntegrationRouter } from "./workflow-integration-router";
import { registerMicrosoftTeamsRouter } from "./microsoft-teams-router";
export const registerV1Routes = async (server: FastifyZodProvider) => {
await server.register(registerSsoRouter, { prefix: "/sso" });
@@ -103,6 +104,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
await pkiRouter.register(registerCertificateTemplateRouter, { prefix: "/certificate-templates" });
await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" });
await pkiRouter.register(registerPkiCollectionRouter, { prefix: "/collections" });
await pkiRouter.register(registerPkiSubscriberRouter, { prefix: "/subscribers" });
},
{ prefix: "/pki" }
);

View File

@@ -0,0 +1,381 @@
import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags, PKI_SUBSCRIBERS } from "@app/lib/api-docs";
import { ms } from "@app/lib/ms";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types";
import { validateAltNameField } from "@app/services/certificate-authority/certificate-authority-validators";
import { sanitizedPkiSubscriber } from "@app/services/pki-subscriber/pki-subscriber-schema";
export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/:subscriberId",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiSubscribers],
description: "Get PKI Subscriber",
params: z.object({
subscriberId: z.string().describe(PKI_SUBSCRIBERS.GET.subscriberId)
}),
response: {
200: sanitizedPkiSubscriber
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const subscriber = await server.services.pkiSubscriber.getPkiSubscriberById({
subscriberId: req.params.subscriberId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: subscriber.projectId,
event: {
type: EventType.GET_PKI_SUBSCRIBER,
metadata: {
pkiSubscriberId: subscriber.id
}
}
});
return subscriber;
}
});
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiSubscribers],
description: "Create PKI Subscriber",
body: z.object({
projectId: z.string().trim().describe(PKI_SUBSCRIBERS.CREATE.projectId),
caId: z
.string()
.trim()
.uuid("CA ID must be a valid UUID")
.min(1, "CA ID is required")
.describe(PKI_SUBSCRIBERS.CREATE.caId),
name: slugSchema({ min: 1, max: 64, field: "name" }).describe(PKI_SUBSCRIBERS.CREATE.name),
commonName: z.string().trim().min(1).describe(PKI_SUBSCRIBERS.CREATE.commonName),
ttl: z
.string()
.trim()
.refine((val) => ms(val) > 0, "TTL must be a positive number")
.describe(PKI_SUBSCRIBERS.CREATE.ttl),
subjectAlternativeNames: validateAltNameField
.array()
.default([])
.transform((arr) => Array.from(new Set(arr)))
.describe(PKI_SUBSCRIBERS.CREATE.subjectAlternativeNames),
keyUsages: z
.nativeEnum(CertKeyUsage)
.array()
.default([CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT])
.transform((arr) => Array.from(new Set(arr)))
.describe(PKI_SUBSCRIBERS.CREATE.keyUsages),
extendedKeyUsages: z
.nativeEnum(CertExtendedKeyUsage)
.array()
.default([])
.transform((arr) => Array.from(new Set(arr)))
.describe(PKI_SUBSCRIBERS.CREATE.extendedKeyUsages)
}),
response: {
200: sanitizedPkiSubscriber
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const subscriber = await server.services.pkiSubscriber.createPkiSubscriber({
...req.body,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: subscriber.projectId,
event: {
type: EventType.CREATE_PKI_SUBSCRIBER,
metadata: {
pkiSubscriberId: subscriber.id,
caId: subscriber.caId,
name: subscriber.name,
commonName: subscriber.commonName,
ttl: subscriber.ttl,
subjectAlternativeNames: subscriber.subjectAlternativeNames,
keyUsages: subscriber.keyUsages as CertKeyUsage[],
extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[]
}
}
});
return subscriber;
}
});
server.route({
method: "PATCH",
url: "/:subscriberId",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
hide: false,
tags: [ApiDocsTags.PkiSubscribers],
description: "Update PKI Subscriber",
params: z.object({
subscriberId: z.string().trim().describe(PKI_SUBSCRIBERS.UPDATE.subscriberId)
}),
body: z.object({
caId: z
.string()
.trim()
.uuid("CA ID must be a valid UUID")
.min(1, "CA ID is required")
.optional()
.describe(PKI_SUBSCRIBERS.UPDATE.caId),
name: slugSchema({ min: 1, max: 64, field: "name" }).describe(PKI_SUBSCRIBERS.UPDATE.name).optional(),
commonName: z.string().trim().min(1).describe(PKI_SUBSCRIBERS.UPDATE.commonName).optional(),
subjectAlternativeNames: validateAltNameField
.array()
.optional()
.describe(PKI_SUBSCRIBERS.UPDATE.subjectAlternativeNames),
ttl: z
.string()
.trim()
.refine((val) => ms(val) > 0, "TTL must be a positive number")
.optional()
.describe(PKI_SUBSCRIBERS.UPDATE.ttl),
keyUsages: z
.nativeEnum(CertKeyUsage)
.array()
.transform((arr) => Array.from(new Set(arr)))
.optional()
.describe(PKI_SUBSCRIBERS.UPDATE.keyUsages),
extendedKeyUsages: z
.nativeEnum(CertExtendedKeyUsage)
.array()
.transform((arr) => Array.from(new Set(arr)))
.optional()
.describe(PKI_SUBSCRIBERS.UPDATE.extendedKeyUsages)
}),
response: {
200: sanitizedPkiSubscriber
}
},
handler: async (req) => {
const subscriber = await server.services.pkiSubscriber.updatePkiSubscriber({
subscriberId: req.params.subscriberId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.body
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: subscriber.projectId,
event: {
type: EventType.UPDATE_PKI_SUBSCRIBER,
metadata: {
pkiSubscriberId: subscriber.id,
caId: subscriber.caId,
name: subscriber.name,
commonName: subscriber.commonName,
ttl: subscriber.ttl,
subjectAlternativeNames: subscriber.subjectAlternativeNames,
keyUsages: subscriber.keyUsages as CertKeyUsage[],
extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[]
}
}
});
return subscriber;
}
});
server.route({
method: "DELETE",
url: "/:subscriberId",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiSubscribers],
description: "Delete PKI Subscriber",
params: z.object({
subscriberId: z.string().describe(PKI_SUBSCRIBERS.DELETE.subscriberId)
}),
response: {
200: sanitizedPkiSubscriber
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const subscriber = await server.services.pkiSubscriber.deletePkiSubscriber({
subscriberId: req.params.subscriberId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: subscriber.projectId,
event: {
type: EventType.DELETE_PKI_SUBSCRIBER,
metadata: {
pkiSubscriberId: subscriber.id
}
}
});
return subscriber;
}
});
server.route({
method: "POST",
url: "/:subscriberId/issue-certificate",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT]),
schema: {
hide: false,
tags: [ApiDocsTags.PkiSubscribers],
description: "Issue certificate",
params: z.object({
subscriberId: z.string().describe(PKI_SUBSCRIBERS.ISSUE_CERT.subscriberId)
}),
response: {
200: z.object({
certificate: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.certificate),
issuingCaCertificate: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.issuingCaCertificate),
certificateChain: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.certificateChain),
privateKey: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.privateKey),
serialNumber: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.serialNumber)
})
}
},
handler: async (req) => {
// TODO: reuse issueCertFromCa fn (or not since we are adding support for external CAs?)
// const { serialNumber, signedPublicKey, privateKey, publicKey, keyAlgorithm, host, principals } =
// await server.services.pkiSubscriber.issuePkiSubscriberCertificate({
// subscriberId: req.params.subscriberId,
// actor: req.permission.type,
// actorId: req.permission.id,
// actorAuthMethod: req.permission.authMethod,
// actorOrgId: req.permission.orgId
// });
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
event: {
type: EventType.ISSUE_SSH_HOST_USER_CERT,
metadata: {
sshHostId: req.params.sshHostId,
hostname: host.hostname,
loginUser: req.body.loginUser,
principals,
ttl: host.userCertTtl
}
}
});
return {
serialNumber,
signedKey: signedPublicKey,
privateKey,
publicKey,
keyAlgorithm
};
}
});
server.route({
method: "POST",
url: "/:subscriberId/sign-certificate",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT]),
schema: {
hide: false,
tags: [ApiDocsTags.PkiSubscribers],
description: "Sign certificate",
params: z.object({
subscriberId: z.string().describe(PKI_SUBSCRIBERS.ISSUE_CERT.subscriberId)
}),
body: z.object({
csr: z.string().trim().min(1).
}),
response: {
200: z.object({
certificate: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.certificate),
issuingCaCertificate: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.issuingCaCertificate),
certificateChain: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.certificateChain),
serialNumber: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.serialNumber)
})
}
},
handler: async (req) => {
// TODO: reuse issueCertFromCa fn (or not since we are adding support for external CAs?)
const { serialNumber, signedPublicKey, privateKey, publicKey, keyAlgorithm, host, principals } =
await server.services.pkiSubscriber.issuePkiSubscriberCertificate({
subscriberId: req.params.subscriberId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
// await server.services.auditLog.createAuditLog({
// ...req.auditLogInfo,
// orgId: req.permission.orgId,
// event: {
// type: EventType.ISSUE_SSH_HOST_USER_CERT,
// metadata: {
// sshHostId: req.params.sshHostId,
// hostname: host.hostname,
// loginUser: req.body.loginUser,
// principals,
// ttl: host.userCertTtl
// }
// }
// });
return {
serialNumber,
signedKey: signedPublicKey,
publicKey,
keyAlgorithm
};
}
});
};

View File

@@ -16,6 +16,7 @@ import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificat
import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema";
import { LoginMappingSource } from "@app/ee/services/ssh-host/ssh-host-types";
import { sanitizedSshHostGroup } from "@app/ee/services/ssh-host-group/ssh-host-group-schema";
import { sanitizedPkiSubscriber } from "@app/services/pki-subscriber/pki-subscriber-schema";
import { ApiDocsTags, PROJECTS } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
@@ -488,6 +489,36 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "GET",
url: "/:projectId/pki-subscribers",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
projectId: z.string().trim().describe(PROJECTS.LIST_PKI_SUBSCRIBERS.projectId)
}),
response: {
200: z.object({
subscribers: z.array(sanitizedPkiSubscriber)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const subscribers = await server.services.project.listProjectPkiSubscribers({
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
actor: req.permission.type,
projectId: req.params.projectId
});
return { subscribers };
}
});
server.route({
method: "GET",
url: "/:projectId/certificate-templates",

View File

@@ -10,6 +10,18 @@ const isValidDate = (dateString: string) => {
export const validateCaDateField = z.string().trim().refine(isValidDate, { message: "Invalid date format" });
export const validateAltNameField = z
.string()
.trim()
.refine(
(name) => {
return isFQDN(name) || z.string().email().safeParse(name).success || isValidIp(name);
},
{
message: "SAN must be a valid hostname, email address, or IP address"
}
);
export const validateAltNamesField = z
.string()
.trim()

View File

@@ -0,0 +1,13 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TPkiSubscriberDALFactory = ReturnType<typeof pkiSubscriberDALFactory>;
export const pkiSubscriberDALFactory = (db: TDbClient) => {
const pkiSubscriberOrm = ormify(db, TableName.PkiSubscriber);
return {
...pkiSubscriberOrm
};
};

View File

@@ -0,0 +1,13 @@
import { PkiSubscribersSchema } from "@app/db/schemas";
export const sanitizedPkiSubscriber = PkiSubscribersSchema.pick({
id: true,
projectId: true,
caId: true,
name: true,
commonName: true,
subjectAlternativeNames: true,
ttl: true,
keyUsages: true,
extendedKeyUsages: true
});

View File

@@ -0,0 +1,341 @@
import { ForbiddenError } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import { ActionProjectType } from "@app/db/schemas";
import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { ms } from "@app/lib/ms";
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
import { getCaCredentials, keyAlgorithmToAlgCfg } from "@app/services/certificate-authority/certificate-authority-fns";
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
import {
TCreatePkiSubscriberDTO,
TDeletePkiSubscriberDTO,
TGetPkiSubscriberByIdDTO,
TIssuePkiSubscriberCertDTO,
TSignPkiSubscriberCertDTO,
TUpdatePkiSubscriberDTO
} from "./pki-subscriber-types";
type TPkiSubscriberServiceFactoryDep = {
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "create" | "findById" | "updateById" | "deleteById">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
certificateAuthorityCrlDAL: Pick<TCertificateAuthorityCrlDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "decryptWithKmsKey">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TPkiSubscriberServiceFactory = ReturnType<typeof pkiSubscriberServiceFactory>;
// TODO: bind subscribers to CA
export const pkiSubscriberServiceFactory = ({
pkiSubscriberDAL,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
certificateAuthorityCrlDAL,
projectDAL,
kmsService,
permissionService
}: TPkiSubscriberServiceFactoryDep) => {
const createPkiSubscriber = async ({
name,
commonName,
caId, // (dangtony98) consider by CA name instead (newly-introduced field)
ttl,
subjectAlternativeNames,
keyUsages,
extendedKeyUsages,
projectId,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TCreatePkiSubscriberDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
// (dangtony98): TODO: make permission more granular
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiSubscriberActions.Read,
ProjectPermissionSub.PkiSubscribers
);
const newSubscriber = await pkiSubscriberDAL.create({
caId,
projectId,
name,
commonName,
ttl,
subjectAlternativeNames,
keyUsages,
extendedKeyUsages
});
return newSubscriber;
};
const getPkiSubscriberById = async ({
subscriberId,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TGetPkiSubscriberByIdDTO) => {
const subscriber = await pkiSubscriberDAL.findById(subscriberId);
if (!subscriber) throw new NotFoundError({ message: `PKI subscriber with ID '${subscriberId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: subscriber.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
// (dangtony98): TODO: make permission more granular
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiSubscriberActions.Read,
ProjectPermissionSub.PkiSubscribers
);
return subscriber;
};
const updatePkiSubscriber = async ({
subscriberId,
name,
commonName,
caId,
ttl,
subjectAlternativeNames,
keyUsages,
extendedKeyUsages,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TUpdatePkiSubscriberDTO) => {
const subscriber = await pkiSubscriberDAL.findById(subscriberId);
if (!subscriber) throw new NotFoundError({ message: `PKI subscriber with ID '${subscriberId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: subscriber.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
// (dangtony98): TODO: make permission more granular
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiSubscriberActions.Edit,
ProjectPermissionSub.PkiSubscribers
);
const updatedSubscriber = await pkiSubscriberDAL.updateById(subscriberId, {
caId,
name,
commonName,
ttl,
subjectAlternativeNames,
keyUsages,
extendedKeyUsages
});
return updatedSubscriber;
};
const deletePkiSubscriber = async ({
subscriberId,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TDeletePkiSubscriberDTO) => {
const subscriber = await pkiSubscriberDAL.findById(subscriberId);
if (!subscriber) throw new NotFoundError({ message: `PKI subscriber with ID '${subscriberId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: subscriber.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
// (dangtony98): TODO: make permission more granular
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiSubscriberActions.Delete,
ProjectPermissionSub.PkiSubscribers
);
await pkiSubscriberDAL.deleteById(subscriberId);
return subscriber;
};
const issuePkiSubscriberCert = async ({
subscriberId,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TIssuePkiSubscriberCertDTO) => {
const subscriber = await pkiSubscriberDAL.findById(subscriberId);
if (!subscriber) throw new NotFoundError({ message: `PKI subscriber with ID '${subscriberId}' not found` });
const ca = await certificateAuthorityDAL.findById(subscriber.caId);
if (!ca) throw new NotFoundError({ message: `CA with ID '${subscriber.caId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: ca.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
// (dangtony98): TODO: make permission more granular
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiSubscriberActions.IssueCert,
ProjectPermissionSub.PkiSubscribers
);
if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" });
if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" });
if (ca.requireTemplateForIssuance) {
throw new BadRequestError({ message: "Certificate template is required for issuance" });
}
const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId);
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectDAL,
kmsService
});
const kmsDecryptor = await kmsService.decryptWithKmsKey({
kmsId: certificateManagerKmsId
});
const decryptedCaCert = await kmsDecryptor({
cipherTextBlob: caCert.encryptedCertificate
});
const caCertObj = new x509.X509Certificate(decryptedCaCert);
const notBeforeDate = new Date();
const notAfterDate = new Date(new Date().getTime() + ms(subscriber.ttl));
const caCertNotBeforeDate = new Date(caCertObj.notBefore);
const caCertNotAfterDate = new Date(caCertObj.notAfter);
// check not before constraint
if (notBeforeDate < caCertNotBeforeDate) {
throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" });
}
// check not after constraint
if (notAfterDate > caCertNotAfterDate) {
throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" });
}
const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm);
const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({
name: `CN=${subscriber.commonName}`,
keys: leafKeys,
signingAlgorithm: alg,
extensions: [
// eslint-disable-next-line no-bitwise
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment)
],
attributes: [new x509.ChallengePasswordAttribute("password")]
});
const { caPrivateKey, caSecret } = await getCaCredentials({
caId: ca.id,
certificateAuthorityDAL,
certificateAuthoritySecretDAL,
projectDAL,
kmsService
});
const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id });
const appCfg = getConfig();
const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`;
const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`;
const extensions: x509.Extension[] = [
new x509.BasicConstraintsExtension(false),
new x509.CRLDistributionPointsExtension([distributionPointUrl]),
await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false),
await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey),
new x509.AuthorityInfoAccessExtension({
caIssuers: new x509.GeneralName("url", caIssuerUrl)
}),
new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy
];
};
const signPkiSubscriberCert = async ({
subscriberId,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TSignPkiSubscriberCertDTO) => {
const subscriber = await pkiSubscriberDAL.findById(subscriberId);
if (!subscriber) throw new NotFoundError({ message: `PKI subscriber with ID '${subscriberId}' not found` });
const ca = await certificateAuthorityDAL.findById(subscriber.caId);
if (!ca) throw new NotFoundError({ message: `CA with ID '${subscriber.caId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: ca.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
// (dangtony98): TODO: make permission more granular
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiSubscriberActions.SignCert,
ProjectPermissionSub.PkiSubscribers
);
};
return {
createPkiSubscriber,
getPkiSubscriberById,
updatePkiSubscriber,
deletePkiSubscriber,
issuePkiSubscriberCert,
signPkiSubscriberCert
};
};

View File

@@ -0,0 +1,40 @@
import { TProjectPermission } from "@app/lib/types";
import { CertExtendedKeyUsage, CertKeyUsage } from "../certificate/certificate-types";
export type TCreatePkiSubscriberDTO = {
caId: string;
name: string;
commonName: string;
ttl: string;
subjectAlternativeNames: string[];
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
} & TProjectPermission;
export type TGetPkiSubscriberByIdDTO = {
subscriberId: string;
} & Omit<TProjectPermission, "projectId">;
export type TUpdatePkiSubscriberDTO = {
subscriberId: string;
caId?: string;
name?: string;
commonName?: string;
ttl?: string;
subjectAlternativeNames?: string[];
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
} & Omit<TProjectPermission, "projectId">;
export type TDeletePkiSubscriberDTO = {
subscriberId: string;
} & Omit<TProjectPermission, "projectId">;
export type TIssuePkiSubscriberCertDTO = {
subscriberId: string;
} & Omit<TProjectPermission, "projectId">;
export type TSignPkiSubscriberCertDTO = {
subscriberId: string;
} & Omit<TProjectPermission, "projectId">;

View File

@@ -14,6 +14,7 @@ import { throwIfMissingSecretReadValueOrDescribePermission } from "@app/ee/servi
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
ProjectPermissionActions,
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSecretActions,
ProjectPermissionSshHostActions,
ProjectPermissionSub
@@ -34,6 +35,7 @@ import { groupBy } from "@app/lib/fn";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TProjectPermission } from "@app/lib/types";
import { TQueueServiceFactory } from "@app/queue";
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
import { ActorType } from "../auth/auth-type";
import { TCertificateDALFactory } from "../certificate/certificate-dal";
@@ -85,6 +87,7 @@ import {
TListProjectCasDTO,
TListProjectCertificateTemplatesDTO,
TListProjectCertsDTO,
TListProjectPkiSubscribersDTO,
TListProjectsDTO,
TListProjectSshCasDTO,
TListProjectSshCertificatesDTO,
@@ -144,6 +147,7 @@ type TProjectServiceFactoryDep = {
"findById" | "findByIdWithWorkflowIntegrationDetails"
>;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "find">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "find">;
certificateDAL: Pick<TCertificateDALFactory, "find" | "countCertificatesInProject">;
certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "getCertTemplatesByProjectId">;
@@ -206,6 +210,7 @@ export const projectServiceFactory = ({
certificateTemplateDAL,
pkiCollectionDAL,
pkiAlertDAL,
pkiSubscriberDAL,
sshCertificateAuthorityDAL,
sshCertificateAuthoritySecretDAL,
sshCertificateDAL,
@@ -1048,6 +1053,54 @@ export const projectServiceFactory = ({
};
};
/**
* Return list of PKI subscribers for project
*/
const listProjectPkiSubscribers = async ({
actorId,
actorOrgId,
actorAuthMethod,
actor,
projectId
}: TListProjectPkiSubscribersDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
const allowedSubscribers = [];
// (dangtony98): room to optimize
const subscribers = await pkiSubscriberDAL.find({ projectId });
for (const subscriber of subscribers) {
try {
// (dangtony98): Add more granular permissions
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiSubscriberActions.Read,
ProjectPermissionSub.PkiSubscribers
);
// ForbiddenError.from(permission).throwUnlessCan(
// ProjectPermissionSshHostActions.Read,
// subject(ProjectPermissionSub.SshHosts, {
// hostname: host.hostname
// })
// );
allowedSubscribers.push(subscriber);
} catch {
// intentionally ignore subscribers where user lacks access
}
}
return allowedSubscribers;
};
/**
* Return list of certificate templates for project
*/
@@ -1921,6 +1974,7 @@ export const projectServiceFactory = ({
listProjectSshCas,
listProjectSshHosts,
listProjectSshHostGroups,
listProjectPkiSubscribers,
listProjectSshCertificates,
listProjectSshCertificateTemplates,
updateVersionLimit,

View File

@@ -155,6 +155,7 @@ export type TListProjectCertificateTemplatesDTO = TProjectPermission;
export type TListProjectSshCasDTO = TProjectPermission;
export type TListProjectSshHostsDTO = TProjectPermission;
export type TListProjectSshCertificateTemplatesDTO = TProjectPermission;
export type TListProjectPkiSubscribersDTO = TProjectPermission;
export type TListProjectSshCertificatesDTO = {
offset: number;
limit: number;

View File

@@ -8,5 +8,6 @@ export {
ProjectPermissionIdentityActions,
ProjectPermissionKmipActions,
ProjectPermissionMemberActions,
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSub
} from "./types";

View File

@@ -87,6 +87,14 @@ export enum ProjectPermissionSshHostActions {
IssueHostCert = "issue-host-cert"
}
export enum ProjectPermissionPkiSubscriberActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
IssueCert = "issue-cert"
}
export enum ProjectPermissionSecretRotationActions {
Read = "read",
ReadGeneratedCredentials = "read-generated-credentials",
@@ -178,6 +186,7 @@ export enum ProjectPermissionSub {
SshHostGroups = "ssh-host-groups",
PkiAlerts = "pki-alerts",
PkiCollections = "pki-collections",
PkiSubscribers = "pki-subscribers",
Kms = "kms",
Cmek = "cmek",
SecretSyncs = "secret-syncs",
@@ -277,6 +286,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionSshHostActions, ProjectPermissionSub.SshHosts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
| [ProjectPermissionPkiSubscriberActions, ProjectPermissionSub.PkiSubscribers]
| [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Project]
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Project]

View File

@@ -16,6 +16,7 @@ export {
ProjectPermissionIdentityActions,
ProjectPermissionKmipActions,
ProjectPermissionMemberActions,
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSub,
useProjectPermission
} from "./ProjectPermissionContext";

View File

@@ -27,6 +27,7 @@ export * from "./orgAdmin";
export * from "./organization";
export * from "./pkiAlerts";
export * from "./pkiCollections";
export * from "./pkiSubscriber";
export * from "./projectUserAdditionalPrivilege";
export * from "./rateLimit";
export * from "./roles";

View File

@@ -0,0 +1,6 @@
export {
useCreatePkiSubscriber,
useDeletePkiSubscriber,
useUpdatePkiSubscriber
} from "./mutations";
export { useGetPkiSubscriberById } from "./queries";

View File

@@ -0,0 +1,61 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/query-keys";
import {
TCreatePkiSubscriberDTO,
TDeletePkiSubscriberDTO,
TPkiSubscriber,
TUpdatePkiSubscriberDTO
} from "./types";
export const useCreatePkiSubscriber = () => {
const queryClient = useQueryClient();
return useMutation<TPkiSubscriber, object, TCreatePkiSubscriberDTO>({
mutationFn: async (body) => {
const { data: subscriber } = await apiRequest.post("/api/v1/pki/subscribers", body);
return subscriber;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getWorkspacePkiSubscribers(projectId)
});
}
});
};
export const useUpdatePkiSubscriber = () => {
const queryClient = useQueryClient();
return useMutation<TPkiSubscriber, object, TUpdatePkiSubscriberDTO>({
mutationFn: async ({ subscriberId, ...body }) => {
const { data: subscriber } = await apiRequest.patch(
`/api/v1/pki/subscribers/${subscriberId}`,
body
);
return subscriber;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getWorkspacePkiSubscribers(projectId)
});
}
});
};
export const useDeletePkiSubscriber = () => {
const queryClient = useQueryClient();
return useMutation<TPkiSubscriber, object, TDeletePkiSubscriberDTO>({
mutationFn: async ({ subscriberId }) => {
const { data: subscriber } = await apiRequest.delete(
`/api/v1/pki/subscribers/${subscriberId}`
);
return subscriber;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getWorkspacePkiSubscribers(projectId)
});
}
});
};

View File

@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TPkiSubscriber } from "./types";
export const pkiSubscriberKeys = {
getPkiSubscriberById: (subscriberId: string) => [{ subscriberId }, "pki-subscriber"] as const
};
export const useGetPkiSubscriberById = (subscriberId: string) => {
return useQuery({
queryKey: pkiSubscriberKeys.getPkiSubscriberById(subscriberId),
queryFn: async () => {
const { data: pkiSubscriber } = await apiRequest.get<TPkiSubscriber>(
`/api/v1/pki/subscribers/${subscriberId}`
);
return pkiSubscriber;
},
enabled: Boolean(subscriberId)
});
};

View File

@@ -0,0 +1,39 @@
import { CertExtendedKeyUsage, CertKeyUsage } from "../certificates/enums";
export type TPkiSubscriber = {
id: string;
projectId: string;
caId: string;
name: string;
commonName: string;
ttl: string;
subjectAlternativeNames: string[];
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
};
export type TCreatePkiSubscriberDTO = {
projectId: string;
caId: string;
name: string;
commonName: string;
ttl: string;
subjectAlternativeNames: string[];
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
};
export type TUpdatePkiSubscriberDTO = {
subscriberId: string;
caId?: string;
name?: string;
commonName?: string;
ttl?: string;
subjectAlternativeNames?: string[];
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
};
export type TDeletePkiSubscriberDTO = {
subscriberId: string;
};

View File

@@ -20,6 +20,7 @@ export type TSshHost = {
hostCertTtl: string;
loginMappings: TLoginMapping[];
};
export type TCreateSshHostDTO = {
projectId: string;
hostname: string;

View File

@@ -35,6 +35,7 @@ export {
useListWorkspaceGroups,
useListWorkspacePkiAlerts,
useListWorkspacePkiCollections,
useListWorkspacePkiSubscribers,
useListWorkspaceSshCas,
useListWorkspaceSshCertificates,
useListWorkspaceSshCertificateTemplates,

View File

@@ -14,6 +14,7 @@ import { IntegrationAuth } from "../integrationAuth/types";
import { TIntegration } from "../integrations/types";
import { TPkiAlert } from "../pkiAlerts/types";
import { TPkiCollection } from "../pkiCollections/types";
import { TPkiSubscriber } from "../pkiSubscriber/types";
import { EncryptedSecret } from "../secrets/types";
import { TSshCertificate, TSshCertificateAuthority } from "../sshCa/types";
import { TSshCertificateTemplate } from "../sshCertificateTemplates/types";
@@ -874,6 +875,21 @@ export const useListWorkspaceSshHosts = (projectId: string) => {
});
};
export const useListWorkspacePkiSubscribers = (projectId: string) => {
return useQuery({
queryKey: workspaceKeys.getWorkspacePkiSubscribers(projectId),
queryFn: async () => {
const {
data: { subscribers }
} = await apiRequest.get<{ subscribers: TPkiSubscriber[] }>(
`/api/v2/workspace/${projectId}/pki-subscribers`
);
return subscribers;
},
enabled: Boolean(projectId)
});
};
export const useListWorkspaceSshHostGroups = (projectId: string) => {
return useQuery({
queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId),

View File

@@ -51,6 +51,8 @@ export const workspaceKeys = {
}) => [...workspaceKeys.forWorkspaceCertificates(slug), { offset, limit }] as const,
getWorkspacePkiAlerts: (workspaceId: string) =>
[{ workspaceId }, "workspace-pki-alerts"] as const,
getWorkspacePkiSubscribers: (projectId: string) =>
[{ projectId }, "workspace-pki-subscribers"] as const,
getWorkspacePkiCollections: (workspaceId: string) =>
[{ workspaceId }, "workspace-pki-collections"] as const,
getWorkspaceCertificateTemplates: (workspaceId: string) =>

View File

@@ -103,6 +103,20 @@ export const ProjectLayout = () => {
)}
{isCertManager && (
<>
<Link
to={
`/${ProjectType.CertificateManager}/$projectId/subscribers` as const
}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="certificate-authority">
Subscribers
</MenuItem>
)}
</Link>
<Link
to={`/${ProjectType.CertificateManager}/$projectId/overview` as const}
params={{

View File

@@ -0,0 +1,25 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { PkiSubscriberSection } from "./components";
export const PkiSubscribersPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "PKI Subscribers" })}</title>
</Helmet>
<div className="h-full bg-bunker-800">
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader title="Subscribers" description="Manage your PKI subscribers." />
<PkiSubscriberSection />
</div>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,435 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
Checkbox,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
CaStatus,
useCreatePkiSubscriber,
useGetPkiSubscriberById,
useListWorkspaceCas,
useListWorkspacePkiSubscribers,
useUpdatePkiSubscriber
} from "@app/hooks/api";
import {
EXTENDED_KEY_USAGES_OPTIONS,
KEY_USAGES_OPTIONS
} from "@app/hooks/api/certificates/constants";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["pkiSubscriber"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["pkiSubscriber"]>, state?: boolean) => void;
};
const schema = z
.object({
name: z.string().trim().min(1, "Name is required"),
caId: z.string().min(1, "Issuing CA is required"),
commonName: z.string().trim().min(1, "Common Name is required"),
subjectAlternativeNames: z.string(),
ttl: z.string().trim(),
keyUsages: z.object({
[CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
[CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(),
[CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(),
[CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(),
[CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(),
[CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(),
[CertKeyUsage.CRL_SIGN]: z.boolean().optional(),
[CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(),
[CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional()
}),
extendedKeyUsages: z.object({
[CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(),
[CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional()
})
})
.required();
export type FormData = z.infer<typeof schema>;
export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { data: subscribers } = useListWorkspacePkiSubscribers(projectId);
const { data: cas } = useListWorkspaceCas({
projectSlug: currentWorkspace?.slug ?? "",
status: CaStatus.ACTIVE
});
const { data: pkiSubscriber } = useGetPkiSubscriberById(
(popUp?.pkiSubscriber?.data as { subscriberId: string })?.subscriberId || ""
);
const { mutateAsync: createMutateAsync } = useCreatePkiSubscriber();
const { mutateAsync: updateMutateAsync } = useUpdatePkiSubscriber();
const {
control,
handleSubmit,
reset,
setValue,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
caId: "",
commonName: "",
subjectAlternativeNames: "",
ttl: "",
keyUsages: {
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
[CertKeyUsage.KEY_ENCIPHERMENT]: true
},
extendedKeyUsages: {}
}
});
useEffect(() => {
if (pkiSubscriber) {
reset({
name: pkiSubscriber.name,
caId: pkiSubscriber.caId || "",
commonName: pkiSubscriber.commonName,
subjectAlternativeNames: pkiSubscriber.subjectAlternativeNames.join(", ") || "",
ttl: pkiSubscriber.ttl || "",
keyUsages: Object.fromEntries((pkiSubscriber.keyUsages || []).map((name) => [name, true])),
extendedKeyUsages: Object.fromEntries(
(pkiSubscriber.extendedKeyUsages || []).map((name) => [name, true])
)
});
} else {
reset({
name: "",
caId: "",
commonName: "",
subjectAlternativeNames: "",
ttl: "",
keyUsages: {
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
[CertKeyUsage.KEY_ENCIPHERMENT]: true
},
extendedKeyUsages: {}
});
}
}, [pkiSubscriber, reset]);
useEffect(() => {
if (cas?.length) {
setValue("caId", cas[0].id);
}
}, [cas, setValue]);
const onFormSubmit = async ({
name,
caId,
commonName,
subjectAlternativeNames,
ttl,
keyUsages,
extendedKeyUsages
}: FormData) => {
try {
if (!projectId) return;
if (!caId) {
createNotification({
text: "Please select an Issuing CA",
type: "error"
});
return;
}
console.log("onFormSubmitArgs: ", {
name,
caId,
commonName,
subjectAlternativeNames,
ttl,
keyUsages,
extendedKeyUsages
});
// Check if there is already a different subscriber with the same name
const existingNames =
subscribers?.filter((s) => s.id !== pkiSubscriber?.id).map((s) => s.name) || [];
if (existingNames.includes(name.trim())) {
createNotification({
text: "A subscriber with this name already exists.",
type: "error"
});
return;
}
const keyUsagesList = Object.entries(keyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertKeyUsage);
const extendedKeyUsagesList = Object.entries(extendedKeyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertExtendedKeyUsage);
const subjectAlternativeNamesList = subjectAlternativeNames
.split(",")
.map((san) => san.trim())
.filter(Boolean);
if (pkiSubscriber) {
await updateMutateAsync({
subscriberId: pkiSubscriber.id,
name,
caId,
commonName,
subjectAlternativeNames: subjectAlternativeNamesList,
ttl,
keyUsages: keyUsagesList,
extendedKeyUsages: extendedKeyUsagesList
});
} else {
await createMutateAsync({
projectId,
name,
caId,
commonName,
subjectAlternativeNames: subjectAlternativeNamesList,
ttl,
keyUsages: keyUsagesList,
extendedKeyUsages: extendedKeyUsagesList
});
}
reset();
handlePopUpToggle("pkiSubscriber", false);
createNotification({
text: `Successfully ${pkiSubscriber ? "updated" : "added"} PKI subscriber`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${pkiSubscriber ? "update" : "add"} PKI subscriber`,
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.pkiSubscriber?.isOpen}
onOpenChange={(isOpen) => {
reset();
handlePopUpToggle("pkiSubscriber", isOpen);
}}
>
<ModalContent title={`${pkiSubscriber ? "Update" : "Add"} PKI Subscriber`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
{pkiSubscriber && (
<FormControl label="Subscriber ID">
<Input value={pkiSubscriber.id} isDisabled className="bg-white/[0.07]" />
</FormControl>
)}
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="web-service" />
</FormControl>
)}
/>
<Controller
control={control}
name="caId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Issuing CA"
errorText={error?.message}
isError={Boolean(error)}
isRequired
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(cas || []).map(({ id, dn }) => (
<SelectItem value={id} key={`ca-${id}`}>
{dn}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="commonName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Common Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="web.example.com" />
</FormControl>
)}
/>
<Controller
control={control}
name="subjectAlternativeNames"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Subject Alternative Names (SANs)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="app1.example.com, app2.example.com, ..." />
</FormControl>
)}
/>
<Controller
control={control}
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="TTL"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="2 days, 1d, 2h, 1y, ..." />
</FormControl>
)}
/>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="key-usages" className="data-[state=open]:border-none">
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
<div className="order-1 ml-3">Key Usage</div>
</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="keyUsages"
render={({ field: { onChange, value }, fieldState: { error } }) => {
return (
<FormControl
label="Key Usage"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="mb-7 mt-2 grid grid-cols-2 gap-2">
{KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={optionValue}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</div>
</FormControl>
);
}}
/>
<Controller
control={control}
name="extendedKeyUsages"
render={({ field: { onChange, value }, fieldState: { error } }) => {
return (
<FormControl
label="Extended Key Usage"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="mb-7 mt-2 grid grid-cols-2 gap-2">
{EXTENDED_KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={optionValue}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</div>
</FormControl>
);
}}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="mt-4 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{pkiSubscriber ? "Update" : "Add"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("pkiSubscriber", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,93 @@
import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionPkiSubscriberActions, ProjectPermissionSub } from "@app/context";
import { useDeletePkiSubscriber } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { PkiSubscriberModal } from "./PkiSubscriberModal";
import { PkiSubscribersTable } from "./PkiSubscribersTable";
export const PkiSubscriberSection = () => {
const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"pkiSubscriber",
"deletePkiSubscriber"
] as const);
const onRemovePkiSubscriberSubmit = async (subscriberId: string) => {
try {
const subscriber = await deletePkiSubscriber({ subscriberId });
createNotification({
text: `Successfully deleted PKI subscriber: ${subscriber.name}`,
type: "success"
});
handlePopUpClose("deletePkiSubscriber");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete PKI subscriber",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Subscribers</p>
<div className="flex w-full justify-end">
<a
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/pki"
>
<span className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</a>
<ProjectPermissionCan
I={ProjectPermissionPkiSubscriberActions.Create}
a={ProjectPermissionSub.PkiSubscribers}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("pkiSubscriber")}
isDisabled={!isAllowed}
className="ml-4"
>
Add Subscriber
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
<PkiSubscribersTable handlePopUpOpen={handlePopUpOpen} />
<PkiSubscriberModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deletePkiSubscriber.isOpen}
title="Are you sure you want to remove the PKI subscriber?"
onChange={(isOpen) => handlePopUpToggle("deletePkiSubscriber", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemovePkiSubscriberSubmit(
(popUp?.deletePkiSubscriber?.data as { subscriberId: string })?.subscriberId
)
}
/>
</div>
);
};

View File

@@ -0,0 +1,129 @@
import { faEllipsis, faPencil, faServer, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import {
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSub,
useWorkspace
} from "@app/context";
import { useListWorkspacePkiSubscribers } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deletePkiSubscriber", "pkiSubscriber"]>,
data?: object
) => void;
};
export const PkiSubscribersTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data, isPending } = useListWorkspacePkiSubscribers(currentWorkspace?.id || "");
return (
<div>
<TableContainer>
<Table className="w-full table-fixed">
<THead>
<Tr>
<Th>Name</Th>
<Th>Common Name</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="pki-subscribers" />}
{!isPending &&
data &&
data.length > 0 &&
data.map((subscriber) => {
return (
<Tr className="h-10" key={`pki-subscriber-${subscriber.id}`}>
<Td>{subscriber.name}</Td>
<Td>{subscriber.commonName}</Td>
<Td className="text-right align-middle">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionPkiSubscriberActions.Edit}
a={ProjectPermissionSub.PkiSubscribers}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("pkiSubscriber", {
subscriberId: subscriber.id
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faPencil} />}
>
Edit Subscriber
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionPkiSubscriberActions.Delete}
a={ProjectPermissionSub.PkiSubscribers}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deletePkiSubscriber", {
subscriberId: subscriber.id
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Subscriber
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && data?.length === 0 && (
<EmptyState title="No PKI subscribers have been added" icon={faServer} />
)}
</TableContainer>
</div>
);
};

View File

@@ -0,0 +1 @@
export { PkiSubscriberSection } from "./PkiSubscriberSection";

View File

@@ -0,0 +1,18 @@
import { createFileRoute } from "@tanstack/react-router";
import { PkiSubscribersPage } from "./PkiSubscribersPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers"
)({
component: PkiSubscribersPage
});
function RouteComponent() {
return (
<div>
Hello
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers"!
</div>
);
}

View File

@@ -91,6 +91,7 @@ import { Route as organizationSettingsPageOauthCallbackPageRouteImport } from '.
import { Route as kmsSettingsPageRouteImport } from './pages/kms/SettingsPage/route'
import { Route as kmsOverviewPageRouteImport } from './pages/kms/OverviewPage/route'
import { Route as kmsKmipPageRouteImport } from './pages/kms/KmipPage/route'
import { Route as certManagerPkiSubscribersPageRouteImport } from './pages/cert-manager/PkiSubscribersPage/route'
import { Route as certManagerSettingsPageRouteImport } from './pages/cert-manager/SettingsPage/route'
import { Route as certManagerCertificatesPageRouteImport } from './pages/cert-manager/CertificatesPage/route'
import { Route as certManagerCertificateAuthoritiesPageRouteImport } from './pages/cert-manager/CertificateAuthoritiesPage/route'
@@ -904,6 +905,13 @@ const kmsKmipPageRouteRoute = kmsKmipPageRouteImport.update({
getParentRoute: () => kmsLayoutRoute,
} as any)
const certManagerPkiSubscribersPageRouteRoute =
certManagerPkiSubscribersPageRouteImport.update({
id: '/subscribers',
path: '/subscribers',
getParentRoute: () => certManagerLayoutRoute,
} as any)
const certManagerSettingsPageRouteRoute =
certManagerSettingsPageRouteImport.update({
id: '/settings',
@@ -2184,6 +2192,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof certManagerSettingsPageRouteImport
parentRoute: typeof certManagerLayoutImport
}
'/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers': {
id: '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers'
path: '/subscribers'
fullPath: '/cert-manager/$projectId/subscribers'
preLoaderRoute: typeof certManagerPkiSubscribersPageRouteImport
parentRoute: typeof certManagerLayoutImport
}
'/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/kmip': {
id: '/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/kmip'
path: '/kmip'
@@ -3221,6 +3236,7 @@ interface certManagerLayoutRouteChildren {
certManagerCertificateAuthoritiesPageRouteRoute: typeof certManagerCertificateAuthoritiesPageRouteRoute
certManagerCertificatesPageRouteRoute: typeof certManagerCertificatesPageRouteRoute
certManagerSettingsPageRouteRoute: typeof certManagerSettingsPageRouteRoute
certManagerPkiSubscribersPageRouteRoute: typeof certManagerPkiSubscribersPageRouteRoute
projectAccessControlPageRouteCertManagerRoute: typeof projectAccessControlPageRouteCertManagerRoute
certManagerCertAuthDetailsByIDPageRouteRoute: typeof certManagerCertAuthDetailsByIDPageRouteRoute
projectIdentityDetailsByIDPageRouteCertManagerRoute: typeof projectIdentityDetailsByIDPageRouteCertManagerRoute
@@ -3235,6 +3251,8 @@ const certManagerLayoutRouteChildren: certManagerLayoutRouteChildren = {
certManagerCertificateAuthoritiesPageRouteRoute,
certManagerCertificatesPageRouteRoute: certManagerCertificatesPageRouteRoute,
certManagerSettingsPageRouteRoute: certManagerSettingsPageRouteRoute,
certManagerPkiSubscribersPageRouteRoute:
certManagerPkiSubscribersPageRouteRoute,
projectAccessControlPageRouteCertManagerRoute:
projectAccessControlPageRouteCertManagerRoute,
certManagerCertAuthDetailsByIDPageRouteRoute:
@@ -3905,6 +3923,7 @@ export interface FileRoutesByFullPath {
'/cert-manager/$projectId/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute
'/cert-manager/$projectId/overview': typeof certManagerCertificatesPageRouteRoute
'/cert-manager/$projectId/settings': typeof certManagerSettingsPageRouteRoute
'/cert-manager/$projectId/subscribers': typeof certManagerPkiSubscribersPageRouteRoute
'/kms/$projectId/kmip': typeof kmsKmipPageRouteRoute
'/kms/$projectId/overview': typeof kmsOverviewPageRouteRoute
'/kms/$projectId/settings': typeof kmsSettingsPageRouteRoute
@@ -4084,6 +4103,7 @@ export interface FileRoutesByTo {
'/cert-manager/$projectId/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute
'/cert-manager/$projectId/overview': typeof certManagerCertificatesPageRouteRoute
'/cert-manager/$projectId/settings': typeof certManagerSettingsPageRouteRoute
'/cert-manager/$projectId/subscribers': typeof certManagerPkiSubscribersPageRouteRoute
'/kms/$projectId/kmip': typeof kmsKmipPageRouteRoute
'/kms/$projectId/overview': typeof kmsOverviewPageRouteRoute
'/kms/$projectId/settings': typeof kmsSettingsPageRouteRoute
@@ -4280,6 +4300,7 @@ export interface FileRoutesById {
'/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/overview': typeof certManagerCertificatesPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/settings': typeof certManagerSettingsPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers': typeof certManagerPkiSubscribersPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/kmip': typeof kmsKmipPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/overview': typeof kmsOverviewPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/settings': typeof kmsSettingsPageRouteRoute
@@ -4469,6 +4490,7 @@ export interface FileRouteTypes {
| '/cert-manager/$projectId/certificate-authorities'
| '/cert-manager/$projectId/overview'
| '/cert-manager/$projectId/settings'
| '/cert-manager/$projectId/subscribers'
| '/kms/$projectId/kmip'
| '/kms/$projectId/overview'
| '/kms/$projectId/settings'
@@ -4647,6 +4669,7 @@ export interface FileRouteTypes {
| '/cert-manager/$projectId/certificate-authorities'
| '/cert-manager/$projectId/overview'
| '/cert-manager/$projectId/settings'
| '/cert-manager/$projectId/subscribers'
| '/kms/$projectId/kmip'
| '/kms/$projectId/overview'
| '/kms/$projectId/settings'
@@ -4841,6 +4864,7 @@ export interface FileRouteTypes {
| '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificate-authorities'
| '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/overview'
| '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/settings'
| '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers'
| '/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/kmip'
| '/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/overview'
| '/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/settings'
@@ -5368,6 +5392,7 @@ export const routeTree = rootRoute
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificate-authorities",
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/overview",
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/settings",
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers",
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/access-management",
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caId",
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId",
@@ -5438,6 +5463,10 @@ export const routeTree = rootRoute
"filePath": "cert-manager/SettingsPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout"
},
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers": {
"filePath": "cert-manager/PkiSubscribersPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout"
},
"/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/kmip": {
"filePath": "kms/KmipPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout"

View File

@@ -284,6 +284,7 @@ const secretManagerIntegrationsRedirect = route("/integrations", [
const certManagerRoutes = route("/cert-manager/$projectId", [
layout("cert-manager-layout", "cert-manager/layout.tsx", [
route("/subscribers", "cert-manager/PkiSubscribersPage/route.tsx"),
route("/overview", "cert-manager/CertificatesPage/route.tsx"),
route("/certificate-authorities", "cert-manager/CertificateAuthoritiesPage/route.tsx"),
route("/alerting", "cert-manager/AlertingPage/route.tsx"),