feat(ee): audit logs (#1977)

feat: clickhouse driver
* sync
* updates
This commit is contained in:
Philip Okugbe
2026-03-01 01:29:03 +00:00
committed by GitHub
parent 85ce0d32bf
commit 69d7532c6c
62 changed files with 2600 additions and 191 deletions
@@ -0,0 +1,136 @@
export const AuditEvent = {
// Workspace
WORKSPACE_CREATED: 'workspace.created',
WORKSPACE_UPDATED: 'workspace.updated',
WORKSPACE_INVITE_CREATED: 'workspace.invite_created',
WORKSPACE_INVITE_RESENT: 'workspace.invite_resent',
WORKSPACE_INVITE_REVOKED: 'workspace.invite_revoked',
// User
USER_CREATED: 'user.created',
USER_DELETED: 'user.deleted',
USER_LOGIN: 'user.login',
USER_LOGOUT: 'user.logout',
USER_ROLE_CHANGED: 'user.role_changed',
USER_PASSWORD_CHANGED: 'user.password_changed',
USER_PASSWORD_RESET: 'user.password_reset',
USER_UPDATED: 'user.updated',
// API Keys
API_KEY_CREATED: 'api_key.created',
API_KEY_UPDATED: 'api_key.updated',
API_KEY_DELETED: 'api_key.deleted',
// Space
SPACE_CREATED: 'space.created',
SPACE_UPDATED: 'space.updated',
SPACE_DELETED: 'space.deleted',
SPACE_MEMBER_ADDED: 'space.member_added',
SPACE_MEMBER_REMOVED: 'space.member_removed',
SPACE_MEMBER_ROLE_CHANGED: 'space.member_role_changed',
// Group
GROUP_CREATED: 'group.created',
GROUP_UPDATED: 'group.updated',
GROUP_DELETED: 'group.deleted',
GROUP_MEMBER_ADDED: 'group.member_added',
GROUP_MEMBER_REMOVED: 'group.member_removed',
// Comment
COMMENT_CREATED: 'comment.created',
COMMENT_DELETED: 'comment.deleted',
// Page
PAGE_CREATED: 'page.created',
PAGE_TRASHED: 'page.trashed',
PAGE_DELETED: 'page.deleted',
PAGE_RESTORED: 'page.restored',
PAGE_MOVED_TO_SPACE: 'page.moved_to_space',
PAGE_DUPLICATED: 'page.duplicated',
// Share
SHARE_CREATED: 'share.created',
SHARE_DELETED: 'share.deleted',
// Import / Export
PAGE_IMPORTED: 'page.imported',
PAGE_EXPORTED: 'page.exported',
SPACE_EXPORTED: 'space.exported',
// SSO provider management
SSO_PROVIDER_CREATED: 'sso.provider_created',
SSO_PROVIDER_UPDATED: 'sso.provider_updated',
SSO_PROVIDER_DELETED: 'sso.provider_deleted',
// MFA
USER_MFA_ENABLED: 'user.mfa_enabled',
USER_MFA_DISABLED: 'user.mfa_disabled',
USER_MFA_BACKUP_CODE_GENERATED: 'user.mfa_backup_code_generated',
// License
LICENSE_ACTIVATED: 'license.activated',
LICENSE_REMOVED: 'license.removed',
// Page permission
PAGE_RESTRICTED: 'page.restricted',
PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
PAGE_PERMISSION_ADDED: 'page.permission_added',
PAGE_PERMISSION_REMOVED: 'page.permission_removed',
// Comment updates / resolve
COMMENT_UPDATED: 'comment.updated',
COMMENT_RESOLVED: 'comment.resolved',
COMMENT_REOPENED: 'comment.reopened',
// Attachment
ATTACHMENT_UPLOADED: 'attachment.uploaded',
// ATTACHMENT_DELETED: 'attachment.deleted',
} as const;
export type AuditEventType = (typeof AuditEvent)[keyof typeof AuditEvent];
export const EXCLUDED_AUDIT_EVENTS: Set<string> = new Set([
// AuditEvent.PAGE_MOVED_TO_SPACE,
//AuditEvent.PAGE_DUPLICATED,
]);
export const AuditResource = {
WORKSPACE: 'workspace',
USER: 'user',
PAGE: 'page',
SPACE: 'space',
SPACE_MEMBER: 'space_member',
GROUP: 'group',
COMMENT: 'comment',
SHARE: 'share',
API_KEY: 'api_key',
SSO_PROVIDER: 'sso_provider',
WORKSPACE_INVITATION: 'workspace_invitation',
ATTACHMENT: 'attachment',
LICENSE: 'license',
} as const;
export type AuditResourceType =
(typeof AuditResource)[keyof typeof AuditResource];
export type ActorType = 'user' | 'system' | 'api_key';
export interface AuditLogPayload {
event: AuditEventType;
resourceType: AuditResourceType;
resourceId?: string;
spaceId?: string;
changes?: {
before?: Record<string, any>;
after?: Record<string, any>;
};
metadata?: Record<string, any>;
}
export interface AuditLogData extends AuditLogPayload {
workspaceId: string;
actorId?: string;
actorType: ActorType;
ipAddress?: string;
userAgent?: string;
}
@@ -0,0 +1,3 @@
export const CacheKey = {
LICENSE_VALID: (workspaceId: string) => `license:valid:${workspaceId}`,
};
+24
View File
@@ -120,6 +120,30 @@ export function normalizePostgresUrl(url: string): string {
return parsed.toString();
}
export function diffAuditTrackedFields(
fields: readonly string[],
dto: Record<string, any>,
before: Record<string, any> | undefined | null,
after: Record<string, any> | undefined | null,
): { before: Record<string, any>; after: Record<string, any> } | null {
const beforeDiff: Record<string, any> = {};
const afterDiff: Record<string, any> = {};
let hasChanges = false;
for (const field of fields) {
if (typeof dto[field] === 'undefined') continue;
const oldVal = JSON.stringify(before?.[field] ?? null);
const newVal = JSON.stringify(after?.[field] ?? null);
if (oldVal !== newVal) {
beforeDiff[field] = before?.[field];
afterDiff[field] = after?.[field];
hasChanges = true;
}
}
return hasChanges ? { before: beforeDiff, after: afterDiff } : null;
}
export function createByteCountingStream(source: Readable) {
let bytesRead = 0;
const stream = new Transform({
@@ -0,0 +1,29 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { ClsService } from 'nestjs-cls';
import { AuditContext, AUDIT_CONTEXT_KEY } from '../middlewares/audit-context.middleware';
@Injectable()
export class AuditActorInterceptor implements NestInterceptor {
constructor(private readonly cls: ClsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const user = request.user?.user;
if (user?.id) {
const auditContext = this.cls.get<AuditContext>(AUDIT_CONTEXT_KEY);
if (auditContext) {
auditContext.actorId = user.id;
this.cls.set(AUDIT_CONTEXT_KEY, auditContext);
}
}
return next.handle();
}
}
@@ -0,0 +1,50 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify';
import { ClsService } from 'nestjs-cls';
export interface AuditContext {
workspaceId: string | null;
actorId: string | null;
actorType: 'user' | 'system' | 'api_key';
ipAddress: string | null;
}
export const AUDIT_CONTEXT_KEY = 'auditContext';
@Injectable()
export class AuditContextMiddleware implements NestMiddleware {
constructor(private readonly cls: ClsService) {}
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void) {
const workspaceId = (req as any).workspaceId ?? null;
const ipAddress = this.extractIpAddress(req);
const auditContext: AuditContext = {
workspaceId,
actorId: null,
actorType: 'user',
ipAddress,
};
this.cls.set(AUDIT_CONTEXT_KEY, auditContext);
next();
}
private extractIpAddress(req: FastifyRequest['raw']): string | null {
const xForwardedFor = req.headers['x-forwarded-for'];
if (xForwardedFor) {
const ips = Array.isArray(xForwardedFor)
? xForwardedFor[0]
: xForwardedFor.split(',')[0];
return ips?.trim() ?? null;
}
const xRealIp = req.headers['x-real-ip'];
if (xRealIp) {
return Array.isArray(xRealIp) ? xRealIp[0] : xRealIp;
}
return (req as any).socket?.remoteAddress ?? null;
}
}