mirror of
https://github.com/docmost/docmost.git
synced 2026-05-18 23:44:24 +08:00
@@ -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}`,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user