diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index b89137ec6..64cd7c936 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -49,6 +49,7 @@ import { TWebhookPayloads } from "@app/services/webhook/webhook-types"; import { WorkflowIntegration } from "@app/services/workflow-integration/workflow-integration-types"; import { KmipPermission } from "../kmip/kmip-enum"; +import { AcmeChallengeType, AcmeIdentifierType } from "../pki-acme/pki-acme-schemas"; import { ApprovalStatus } from "../secret-approval-request/secret-approval-request-types"; export type TListProjectAuditLogDTO = { @@ -78,7 +79,9 @@ export type TCreateAuditLogDTO = { | ScimClientActor | PlatformActor | UnknownUserActor - | KmipClientActor; + | KmipClientActor + | AcmeProfileActor + | AcmeAccountActor; orgId?: string; projectId?: string; } & BaseAuthData; @@ -574,7 +577,18 @@ export enum EventType { APPROVAL_REQUEST_CANCEL = "approval-request-cancel", APPROVAL_REQUEST_GRANT_LIST = "approval-request-grant-list", APPROVAL_REQUEST_GRANT_GET = "approval-request-grant-get", - APPROVAL_REQUEST_GRANT_REVOKE = "approval-request-grant-revoke" + APPROVAL_REQUEST_GRANT_REVOKE = "approval-request-grant-revoke", + + // PKI ACME + CREATE_ACME_ACCOUNT = "create-acme-account", + RETRIEVE_ACME_ACCOUNT = "retrieve-acme-account", + CREATE_ACME_ORDER = "create-acme-order", + FINALIZE_ACME_ORDER = "finalize-acme-order", + DOWNLOAD_ACME_CERTIFICATE = "download-acme-certificate", + RESPOND_TO_ACME_CHALLENGE = "respond-to-acme-challenge", + PASS_ACME_CHALLENGE = "pass-acme-challenge", + ATTEMPT_ACME_CHALLENGE = "attempt-acme-challenge", + FAIL_ACME_CHALLENGE = "fail-acme-challenge" } export const filterableSecretEvents: EventType[] = [ @@ -615,6 +629,15 @@ interface KmipClientActorMetadata { name: string; } +interface AcmeProfileActorMetadata { + profileId: string; +} + +interface AcmeAccountActorMetadata { + profileId: string; + accountId: string; +} + interface UnknownUserActorMetadata {} export interface UserActor { @@ -652,7 +675,25 @@ export interface ScimClientActor { metadata: ScimClientActorMetadata; } -export type Actor = UserActor | ServiceActor | IdentityActor | ScimClientActor | PlatformActor | KmipClientActor; +export interface AcmeProfileActor { + type: ActorType.ACME_PROFILE; + metadata: AcmeProfileActorMetadata; +} + +export interface AcmeAccountActor { + type: ActorType.ACME_ACCOUNT; + metadata: AcmeAccountActorMetadata; +} + +export type Actor = + | UserActor + | ServiceActor + | IdentityActor + | ScimClientActor + | PlatformActor + | KmipClientActor + | AcmeProfileActor + | AcmeAccountActor; interface GetSecretsEvent { type: EventType.GET_SECRETS; @@ -4368,6 +4409,84 @@ interface ApprovalRequestGrantRevokeEvent { }; } +interface CreateAcmeAccountEvent { + type: EventType.CREATE_ACME_ACCOUNT; + metadata: { + accountId: string; + publicKeyThumbprint: string; + emails?: string[]; + }; +} + +interface RetrieveAcmeAccountEvent { + type: EventType.RETRIEVE_ACME_ACCOUNT; + metadata: { + accountId: string; + publicKeyThumbprint: string; + }; +} + +interface CreateAcmeOrderEvent { + type: EventType.CREATE_ACME_ORDER; + metadata: { + orderId: string; + identifiers: Array<{ + type: AcmeIdentifierType; + value: string; + }>; + }; +} + +interface FinalizeAcmeOrderEvent { + type: EventType.FINALIZE_ACME_ORDER; + metadata: { + orderId: string; + csr: string; + }; +} + +interface DownloadAcmeCertificateEvent { + type: EventType.DOWNLOAD_ACME_CERTIFICATE; + metadata: { + orderId: string; + }; +} + +interface RespondToAcmeChallengeEvent { + type: EventType.RESPOND_TO_ACME_CHALLENGE; + metadata: { + challengeId: string; + type: AcmeChallengeType; + }; +} +interface PassedAcmeChallengeEvent { + type: EventType.PASS_ACME_CHALLENGE; + metadata: { + challengeId: string; + type: AcmeChallengeType; + }; +} + +interface AttemptAcmeChallengeEvent { + type: EventType.ATTEMPT_ACME_CHALLENGE; + metadata: { + challengeId: string; + type: AcmeChallengeType; + retryCount: number; + errorMessage: string; + }; +} + +interface FailAcmeChallengeEvent { + type: EventType.FAIL_ACME_CHALLENGE; + metadata: { + challengeId: string; + type: AcmeChallengeType; + retryCount: number; + errorMessage: string; + }; +} + export type Event = | CreateSubOrganizationEvent | UpdateSubOrganizationEvent @@ -4768,4 +4887,13 @@ export type Event = | ApprovalRequestCancelEvent | ApprovalRequestGrantListEvent | ApprovalRequestGrantGetEvent - | ApprovalRequestGrantRevokeEvent; + | ApprovalRequestGrantRevokeEvent + | CreateAcmeAccountEvent + | RetrieveAcmeAccountEvent + | CreateAcmeOrderEvent + | FinalizeAcmeOrderEvent + | DownloadAcmeCertificateEvent + | RespondToAcmeChallengeEvent + | PassedAcmeChallengeEvent + | AttemptAcmeChallengeEvent + | FailAcmeChallengeEvent; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index 74cbd1466..abc3a654b 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -122,6 +122,11 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { const result = await (tx || db)(TableName.PkiAcmeChallenge) .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) .join(TableName.PkiAcmeAccount, `${TableName.PkiAcmeAuth}.accountId`, `${TableName.PkiAcmeAccount}.id`) + .join( + TableName.PkiCertificateProfile, + `${TableName.PkiAcmeAccount}.profileId`, + `${TableName.PkiCertificateProfile}.id` + ) .select( selectAllTableCols(TableName.PkiAcmeChallenge), db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"), @@ -131,7 +136,9 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { db.ref("identifierValue").withSchema(TableName.PkiAcmeAuth).as("authIdentifierValue"), db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("authExpiresAt"), db.ref("id").withSchema(TableName.PkiAcmeAccount).as("accountId"), - db.ref("publicKeyThumbprint").withSchema(TableName.PkiAcmeAccount).as("accountPublicKeyThumbprint") + db.ref("publicKeyThumbprint").withSchema(TableName.PkiAcmeAccount).as("accountPublicKeyThumbprint"), + db.ref("profileId").withSchema(TableName.PkiAcmeAccount).as("profileId"), + db.ref("projectId").withSchema(TableName.PkiCertificateProfile).as("projectId") ) // For all challenges, acquire update lock on the auth to avoid race conditions .forUpdate(TableName.PkiAcmeAuth) @@ -149,6 +156,8 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { authExpiresAt, accountId, accountPublicKeyThumbprint, + profileId, + projectId, ...challenge } = result; return { @@ -161,7 +170,11 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { expiresAt: authExpiresAt, account: { id: accountId, - publicKeyThumbprint: accountPublicKeyThumbprint + publicKeyThumbprint: accountPublicKeyThumbprint, + project: { + id: projectId + }, + profileId } } }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index 7379d7ece..06ad69236 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -5,7 +5,9 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { isPrivateIp } from "@app/lib/ip/ipRange"; import { logger } from "@app/lib/logger"; +import { ActorType } from "@app/services/auth/auth-type"; +import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; import { AcmeConnectionError, @@ -25,10 +27,12 @@ type TPkiAcmeChallengeServiceFactoryDep = { | "markAsInvalidCascadeById" | "updateById" >; + auditLogService: Pick; }; export const pkiAcmeChallengeServiceFactory = ({ - acmeChallengeDAL + acmeChallengeDAL, + auditLogService }: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => { const appCfg = getConfig(); const markChallengeAsReady = async (challengeId: string): Promise => { @@ -113,7 +117,25 @@ export const pkiAcmeChallengeServiceFactory = ({ } logger.info({ challengeId }, "ACME challenge response is correct, marking challenge as valid"); await acmeChallengeDAL.markAsValidCascadeById(challengeId); + await auditLogService.createAuditLog({ + projectId: challenge.auth.account.project.id, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId: challenge.auth.account.profileId, + accountId: challenge.auth.account.id + } + }, + event: { + type: EventType.PASS_ACME_CHALLENGE, + metadata: { + challengeId, + type: challenge.type as AcmeChallengeType + } + } + }); } catch (exp) { + let finalAttempt = false; if (retryCount >= 2) { logger.error( exp, @@ -121,35 +143,59 @@ export const pkiAcmeChallengeServiceFactory = ({ ); // This is the last attempt to validate the challenge response, if it fails, we mark the challenge as invalid await acmeChallengeDAL.markAsInvalidCascadeById(challengeId); + finalAttempt = true; } - // Properly type and inspect the error - if (axios.isAxiosError(exp)) { - const axiosError = exp as AxiosError; - const errorCode = axiosError.code; - const errorMessage = axiosError.message; + try { + // Properly type and inspect the error + if (axios.isAxiosError(exp)) { + const axiosError = exp as AxiosError; + const errorCode = axiosError.code; + const errorMessage = axiosError.message; - if (errorCode === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) { - throw new AcmeConnectionError({ message: "Connection refused" }); + if (errorCode === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) { + throw new AcmeConnectionError({ message: "Connection refused" }); + } + if (errorCode === "ENOTFOUND" || errorMessage.includes("ENOTFOUND")) { + throw new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); + } + if (errorCode === "ECONNRESET" || errorMessage.includes("ECONNRESET")) { + throw new AcmeConnectionError({ message: "Connection reset by peer" }); + } + if (errorCode === "ECONNABORTED" || errorMessage.includes("timeout")) { + logger.error(exp, "Connection timed out while validating ACME challenge response"); + throw new AcmeConnectionError({ message: "Connection timed out" }); + } + logger.error(exp, "Unknown error validating ACME challenge response"); + throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); } - if (errorCode === "ENOTFOUND" || errorMessage.includes("ENOTFOUND")) { - throw new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); - } - if (errorCode === "ECONNRESET" || errorMessage.includes("ECONNRESET")) { - throw new AcmeConnectionError({ message: "Connection reset by peer" }); - } - if (errorCode === "ECONNABORTED" || errorMessage.includes("timeout")) { - logger.error(exp, "Connection timed out while validating ACME challenge response"); - throw new AcmeConnectionError({ message: "Connection timed out" }); + if (exp instanceof Error) { + logger.error(exp, "Error validating ACME challenge response"); + throw exp; } logger.error(exp, "Unknown error validating ACME challenge response"); throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); + } catch (outterExp) { + await auditLogService.createAuditLog({ + projectId: challenge.auth.account.project.id, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId: challenge.auth.account.profileId, + accountId: challenge.auth.account.id + } + }, + event: { + type: finalAttempt ? EventType.FAIL_ACME_CHALLENGE : EventType.ATTEMPT_ACME_CHALLENGE, + metadata: { + challengeId, + type: challenge.type as AcmeChallengeType, + retryCount, + errorMessage: exp instanceof Error ? exp.message : "Unknown error" + } + } + }); + throw outterExp; } - if (exp instanceof Error) { - logger.error(exp, "Error validating ACME challenge response"); - throw exp; - } - logger.error(exp, "Unknown error validating ACME challenge response"); - throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); } }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts index c86f0efd1..57582655a 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -47,6 +47,7 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; +import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; @@ -136,6 +137,7 @@ type TPkiAcmeServiceFactoryDep = { certificateTemplateV2Service: Pick; acmeChallengeService: Pick; pkiAcmeQueueService: Pick; + auditLogService: Pick; }; export const pkiAcmeServiceFactory = ({ @@ -159,7 +161,8 @@ export const pkiAcmeServiceFactory = ({ certificateV3Service, certificateTemplateV2Service, acmeChallengeService, - pkiAcmeQueueService + pkiAcmeQueueService, + auditLogService }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { const validateAcmeProfile = async (profileId: string): Promise => { const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); @@ -446,6 +449,23 @@ export const pkiAcmeServiceFactory = ({ throw new AcmeExternalAccountRequiredError({ message: "External account binding is required" }); } if (existingAccount) { + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_PROFILE, + metadata: { + profileId: profile.id + } + }, + event: { + type: EventType.RETRIEVE_ACME_ACCOUNT, + metadata: { + accountId: existingAccount.id, + publicKeyThumbprint + } + } + }); + return { status: 200, body: { @@ -518,7 +538,25 @@ export const pkiAcmeServiceFactory = ({ publicKeyThumbprint, emails: contact ?? [] }); - // TODO: create audit log here + + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_PROFILE, + metadata: { + profileId: profile.id + } + }, + event: { + type: EventType.CREATE_ACME_ACCOUNT, + metadata: { + accountId: newAccount.id, + publicKeyThumbprint: newAccount.publicKeyThumbprint, + emails: newAccount.emails + } + } + }); + return { status: 201, body: { @@ -647,7 +685,26 @@ export const pkiAcmeServiceFactory = ({ })), tx ); - // TODO: create audit log here + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId: account.profileId, + accountId: account.id + } + }, + event: { + type: EventType.CREATE_ACME_ORDER, + metadata: { + orderId: createdOrder.id, + identifiers: authorizations.map((auth) => ({ + type: auth.identifierType as AcmeIdentifierType, + value: auth.identifierValue + })) + } + } + }); return { ...createdOrder, authorizations, account }; }); @@ -875,6 +932,23 @@ export const pkiAcmeServiceFactory = ({ throw error; } order = updatedOrder; + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId, + accountId + } + }, + event: { + type: EventType.FINALIZE_ACME_ORDER, + metadata: { + orderId: updatedOrder.id, + csr: updatedOrder.csr! + } + } + }); } else if (order.status !== AcmeOrderStatus.Valid) { throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" }); } @@ -930,6 +1004,24 @@ export const pkiAcmeServiceFactory = ({ const certLeaf = certObj.toString("pem").trim().replace("\n", "\r\n"); const certChain = certificateChain.trim().replace("\n", "\r\n"); + + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId, + accountId + } + }, + event: { + type: EventType.DOWNLOAD_ACME_CERTIFICATE, + metadata: { + orderId + } + } + }); + return { status: 200, body: @@ -1012,6 +1104,7 @@ export const pkiAcmeServiceFactory = ({ authzId: string; challengeId: string; }): Promise> => { + const profile = await validateAcmeProfile(profileId); const result = await acmeChallengeDAL.findByAccountAuthAndChallengeId(accountId, authzId, challengeId); if (!result) { throw new NotFoundError({ message: "ACME challenge not found" }); @@ -1019,6 +1112,23 @@ export const pkiAcmeServiceFactory = ({ await acmeChallengeService.markChallengeAsReady(challengeId); await pkiAcmeQueueService.queueChallengeValidation(challengeId); const challenge = (await acmeChallengeDAL.findByIdForChallengeValidation(challengeId))!; + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId, + accountId + } + }, + event: { + type: EventType.RESPOND_TO_ACME_CHALLENGE, + metadata: { + challengeId, + type: challenge.type as AcmeChallengeType + } + } + }); return { status: 200, body: { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 6611f4a59..3bc6b22e7 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2303,7 +2303,8 @@ export const registerRoutes = async ( }); const acmeChallengeService = pkiAcmeChallengeServiceFactory({ - acmeChallengeDAL + acmeChallengeDAL, + auditLogService }); const pkiAcmeQueueService = await pkiAcmeQueueServiceFactory({ @@ -2332,7 +2333,8 @@ export const registerRoutes = async ( certificateV3Service, certificateTemplateV2Service, acmeChallengeService, - pkiAcmeQueueService + pkiAcmeQueueService, + auditLogService }); const pkiSubscriberService = pkiSubscriberServiceFactory({ diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 9e692dcd3..f93f511dd 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -41,6 +41,7 @@ export enum ActorType { // would extend to AWS, Azure, ... IDENTITY = "identity", Machine = "machine", SCIM_CLIENT = "scimClient", + ACME_PROFILE = "acmeProfile", ACME_ACCOUNT = "acmeAccount", UNKNOWN_USER = "unknownUser" } diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index d2e977605..560520f25 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -1,4 +1,6 @@ import { + AcmeAccountActor, + AcmeProfileActor, IdentityActor, KmipClientActor, PlatformActor, @@ -60,6 +62,8 @@ export type TSecretModifiedEvent = { | ScimClientActor | PlatformActor | UnknownUserActor + | AcmeAccountActor + | AcmeProfileActor | KmipClientActor; }; }; diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index b3be5a6fd..72b57d159 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -4,6 +4,8 @@ export enum ActorType { USER = "user", SERVICE = "service", IDENTITY = "identity", + ACME_PROFILE = "acmeProfile", + ACME_ACCOUNT = "acmeAccount", UNKNOWN_USER = "unknownUser" } diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index fc15b5064..6e0eb22f3 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -38,6 +38,13 @@ interface KmipClientActorMetadata { name: string; } +interface AcmeAccountActorMetadata { + profileId: string; + accountId: string; +} +interface AcmeProfileActorMetadata { + profileId: string; +} interface UserActor { type: ActorType.USER; metadata: UserActorMetadata; @@ -67,13 +74,25 @@ export interface UnknownUserActor { type: ActorType.UNKNOWN_USER; } +export interface AcmeProfileActor { + type: ActorType.ACME_PROFILE; + metadata: AcmeProfileActorMetadata; +} + +export interface AcmeAccountActor { + type: ActorType.ACME_ACCOUNT; + metadata: AcmeAccountActorMetadata; +} + export type Actor = | UserActor | ServiceActor | IdentityActor | PlatformActor | UnknownUserActor - | KmipClientActor; + | KmipClientActor + | AcmeProfileActor + | AcmeAccountActor; interface GetSecretsEvent { type: EventType.GET_SECRETS; diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsTableRow.tsx index 8463c303a..90882d43d 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsTableRow.tsx @@ -68,6 +68,12 @@ export const LogsTableRow = ({ auditLog, rowNumber, timezone }: Props) => { {auditLog.actor.type === ActorType.IDENTITY && ( )} + {auditLog.actor.type === ActorType.ACME_PROFILE && ( + + )} + {auditLog.actor.type === ActorType.ACME_ACCOUNT && ( + + )}