diff --git a/apps/server/package.json b/apps/server/package.json index d7c48f07..c3b52850 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -44,6 +44,7 @@ "@nestjs-labs/nestjs-ioredis": "^11.0.4", "@nestjs/bullmq": "^11.0.4", "@nestjs/common": "^11.1.9", + "nestjs-cls": "^4.5.0", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.9", "@nestjs/event-emitter": "^3.0.1", diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 56691444..74bc21ae 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { AuditActorInterceptor } from './common/interceptors/audit-actor.interceptor'; import { CoreModule } from './core/core.module'; import { EnvironmentModule } from './integrations/environment/environment.module'; import { CollaborationModule } from './collaboration/collaboration.module'; @@ -18,6 +20,7 @@ import { SecurityModule } from './integrations/security/security.module'; import { TelemetryModule } from './integrations/telemetry/telemetry.module'; import { RedisModule } from '@nestjs-labs/nestjs-ioredis'; import { RedisConfigService } from './integrations/redis/redis-config.service'; +import { ClsModule } from 'nestjs-cls'; const enterpriseModules = []; try { @@ -35,6 +38,10 @@ try { @Module({ imports: [ + ClsModule.forRoot({ + global: true, + middleware: { mount: true }, + }), CoreModule, DatabaseModule, EnvironmentModule, @@ -60,6 +67,12 @@ try { ...enterpriseModules, ], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + { + provide: APP_INTERCEPTOR, + useClass: AuditActorInterceptor, + }, + ], }) export class AppModule {} diff --git a/apps/server/src/common/events/audit-events.ts b/apps/server/src/common/events/audit-events.ts new file mode 100644 index 00000000..d90c52cf --- /dev/null +++ b/apps/server/src/common/events/audit-events.ts @@ -0,0 +1,101 @@ +export const AuditEvent = { + // Workspace Invitations + WORKSPACE_CREATED: 'workspace.created', + WORKSPACE_INVITE_CREATED: 'workspace.invite_created', + WORKSPACE_INVITE_REVOKED: 'workspace.invite_revoked', + + WORKSPACE_INVITE_ACCEPTED: 'workspace.invite_accepted', + + WORKSPACE_USER_CREATED: 'workspace.user_created', + WORKSPACE_USER_DEACTIVATED: 'workspace.user_deactivated', + + WORKSPACE_ALLOWED_DOMAIN_UPDATED: 'workspace.allowed_domain_updated', + WORKSPACE_ICON_CHANGED: 'workspace.icon_changed', + WORKSPACE_NAME_CHANGED: 'workspace.name_changed', + + WORKSPACE_AI_TOGGLED: 'workspace.ai_toggled', + + USER_CREATED: 'user.created', + USER_DELETED: 'user.deleted', + USER_LOGIN: 'user.login', + USER_LOGOUT: 'user.logout', + USER_ROLE_CHANGED: 'user.user_role_changed', + USER_PASSWORD_CHANGED: 'user.password_changed', + USER_PASSWORD_RESET: 'user.reset_password', + USER_PHOTO_CHANGED: 'user.reset_password', + USER_NAME_CHANGED: 'user.name_changed', + USER_EMAIL_CHANGED: 'user.email_changed', + USER_MFA_SETUP: 'user.mfa_setup', + USER_MFA_BACKUP_CODE_GENERATED: 'user.mfa_backup_code_generated', + + // 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', + + // OR SPACE_USER_ADDED: 'space.user_added', + // SPACE_GROUP_ADDED: 'space.group_added', + + // 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', + + // Comments + COMMENT_CREATED: 'comment.created', + COMMENT_UPDATED: 'comment.updated', + COMMENT_DELETED: 'comment.deleted', + COMMENT_RESOLVED: 'comment.resolved', + COMMENT_REOPENED: 'comment.reopened', + + // PAGE + PAGE_CREATED: 'page.created', + PAGE_UPDATED: 'page.updated', + PAGE_TRASHED: 'page.trash', + PAGE_DELETED: 'page.deleted', + PAGE_SHARED: 'page.shared', + + ATTACHMENT_UPLOADED: 'attachment.uploaded', + + PAGE_IMPORTED: 'page.imported', + PAGE_RESTORED: 'page.restored', + PAGE_EXPORTED: 'page.exported', + SPACE_EXPORTED: 'space.imported', + + // SSO EVENTS +} as const; + +export type AuditEventType = (typeof AuditEvent)[keyof typeof AuditEvent]; + +export type ActorType = 'user' | 'system' | 'api_key'; + +export interface AuditLogPayload { + event: AuditEventType; + resourceType: string; + resourceId?: string; + changes?: { + before?: Record; + after?: Record; + }; + metadata?: Record; +} + +export interface AuditLogData extends AuditLogPayload { + workspaceId: string; + actorId?: string; + actorType: ActorType; + ipAddress?: string; + userAgent?: string; +} diff --git a/apps/server/src/common/interceptors/audit-actor.interceptor.ts b/apps/server/src/common/interceptors/audit-actor.interceptor.ts new file mode 100644 index 00000000..c98ceb7f --- /dev/null +++ b/apps/server/src/common/interceptors/audit-actor.interceptor.ts @@ -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 { + const request = context.switchToHttp().getRequest(); + const user = request.user?.user; + + if (user?.id) { + const auditContext = this.cls.get(AUDIT_CONTEXT_KEY); + if (auditContext) { + auditContext.actorId = user.id; + this.cls.set(AUDIT_CONTEXT_KEY, auditContext); + } + } + + return next.handle(); + } +} diff --git a/apps/server/src/common/middlewares/audit-context.middleware.ts b/apps/server/src/common/middlewares/audit-context.middleware.ts new file mode 100644 index 00000000..d58c4353 --- /dev/null +++ b/apps/server/src/common/middlewares/audit-context.middleware.ts @@ -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; + } +} diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index f7f4f785..a38c2683 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -15,7 +15,13 @@ import { SpaceModule } from './space/space.module'; import { GroupModule } from './group/group.module'; import { CaslModule } from './casl/casl.module'; import { DomainMiddleware } from '../common/middlewares/domain.middleware'; +import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware'; import { ShareModule } from './share/share.module'; +import { + AUDIT_SERVICE, + NoopAuditService, +} from '../integrations/audit/audit.service'; +import { ClsMiddleware } from 'nestjs-cls'; @Module({ imports: [ @@ -31,17 +37,31 @@ import { ShareModule } from './share/share.module'; CaslModule, ShareModule, ], + providers: [ + { + provide: AUDIT_SERVICE, + useClass: NoopAuditService, + }, + ], + exports: [AUDIT_SERVICE], }) export class CoreModule implements NestModule { configure(consumer: MiddlewareConsumer) { + const excludedRoutes = [ + { path: 'auth/setup', method: RequestMethod.POST }, + { path: 'health', method: RequestMethod.GET }, + { path: 'health/live', method: RequestMethod.GET }, + { path: 'billing/stripe/webhook', method: RequestMethod.POST }, + ]; + consumer .apply(DomainMiddleware) - .exclude( - { path: 'auth/setup', method: RequestMethod.POST }, - { path: 'health', method: RequestMethod.GET }, - { path: 'health/live', method: RequestMethod.GET }, - { path: 'billing/stripe/webhook', method: RequestMethod.POST }, - ) + .exclude(...excludedRoutes) + .forRoutes('*'); + + consumer + .apply(AuditContextMiddleware) + .exclude(...excludedRoutes) .forRoutes('*'); } } diff --git a/apps/server/src/core/space/services/space-member.service.ts b/apps/server/src/core/space/services/space-member.service.ts index 16ab7c65..e6b89b01 100644 --- a/apps/server/src/core/space/services/space-member.service.ts +++ b/apps/server/src/core/space/services/space-member.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + Inject, Injectable, NotFoundException, } from '@nestjs/common'; @@ -14,6 +15,11 @@ import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto'; import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto'; import { SpaceRole } from '../../../common/helpers/types/permission'; import { PaginationResult } from '@docmost/db/pagination/pagination'; +import { AuditEvent } from '../../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../../integrations/audit/audit.service'; @Injectable() export class SpaceMemberService { @@ -21,6 +27,7 @@ export class SpaceMemberService { private spaceMemberRepo: SpaceMemberRepo, private spaceRepo: SpaceRepo, @InjectKysely() private readonly db: KyselyDB, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} async addUserToSpace( @@ -161,8 +168,43 @@ export class SpaceMemberService { if (membersToAdd.length > 0) { await this.spaceMemberRepo.insertSpaceMember(membersToAdd); - } else { - // either they are already members or do not exist on the workspace + + // Audit log for each member added + for (const user of validUsers) { + this.auditService.log({ + event: AuditEvent.SPACE_MEMBER_ADDED, + resourceType: 'space_members', + resourceId: dto.spaceId, + changes: { + after: { role: dto.role }, + }, + metadata: { + spaceId: dto.spaceId, + spaceName: space.name, + userId: user.id, + userName: user.name, + memberType: 'user', + }, + }); + } + + for (const group of validGroups) { + this.auditService.log({ + event: AuditEvent.SPACE_MEMBER_ADDED, + resourceType: 'space_members', + resourceId: dto.spaceId, + changes: { + after: { role: dto.role }, + }, + metadata: { + spaceId: dto.spaceId, + spaceName: space.name, + groupId: group.id, + groupName: group.name, + memberType: 'group', + }, + }); + } } } @@ -209,6 +251,22 @@ export class SpaceMemberService { spaceMember.id, dto.spaceId, ); + + this.auditService.log({ + event: AuditEvent.SPACE_MEMBER_REMOVED, + resourceType: 'space_member', + resourceId: dto.spaceId, + changes: { + before: { role: spaceMember.role }, + }, + metadata: { + spaceId: dto.spaceId, + spaceName: space.name, + userId: spaceMember.userId, + groupId: spaceMember.groupId, + memberType: spaceMember.userId ? 'user' : 'group', + }, + }); } async updateSpaceMemberRole( @@ -259,6 +317,23 @@ export class SpaceMemberService { spaceMember.id, dto.spaceId, ); + + this.auditService.log({ + event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED, + resourceType: 'space_members', + resourceId: dto.spaceId, + changes: { + before: { role: spaceMember.role }, + after: { role: dto.role }, + }, + metadata: { + spaceId: dto.spaceId, + spaceName: space.name, + userId: spaceMember.userId, + groupId: spaceMember.groupId, + memberType: spaceMember.userId ? 'user' : 'group', + }, + }); } async validateLastAdmin(spaceId: string): Promise { diff --git a/apps/server/src/core/workspace/services/workspace-invitation.service.ts b/apps/server/src/core/workspace/services/workspace-invitation.service.ts index 2defcbba..2f9c346d 100644 --- a/apps/server/src/core/workspace/services/workspace-invitation.service.ts +++ b/apps/server/src/core/workspace/services/workspace-invitation.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + Inject, Injectable, Logger, NotFoundException, @@ -33,6 +34,11 @@ import { validateAllowedEmail, validateSsoEnforcement, } from '../../auth/auth.util'; +import { AuditEvent } from '../../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../../integrations/audit/audit.service'; @Injectable() export class WorkspaceInvitationService { @@ -46,6 +52,7 @@ export class WorkspaceInvitationService { @InjectKysely() private readonly db: KyselyDB, @InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue, private readonly environmentService: EnvironmentService, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} async getInvitations(workspaceId: string, pagination: PaginationOptions) { @@ -179,6 +186,24 @@ export class WorkspaceInvitationService { workspace.hostname, ); }); + + // Audit log for each invitation created + for (const invitation of invites) { + this.auditService.log({ + event: AuditEvent.WORKSPACE_INVITE_CREATED, + resourceType: 'workspace_invitation', + resourceId: invitation.id, + changes: { + after: { + email: invitation.email, + role: invitation.role, + }, + }, + metadata: { + groupIds: invitation.groupIds, + }, + }); + } } } @@ -344,11 +369,32 @@ export class WorkspaceInvitationService { invitationId: string, workspaceId: string, ): Promise { + const invitation = await this.db + .selectFrom('workspaceInvitations') + .select(['id', 'email', 'role']) + .where('id', '=', invitationId) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + await this.db .deleteFrom('workspaceInvitations') .where('id', '=', invitationId) .where('workspaceId', '=', workspaceId) .execute(); + + if (invitation) { + this.auditService.log({ + event: AuditEvent.WORKSPACE_INVITE_REVOKED, + resourceType: 'workspace_invitation', + resourceId: invitation.id, + changes: { + before: { + email: invitation.email, + role: invitation.role, + }, + }, + }); + } } async getInvitationLinkById( diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 1a5e7f8d..bc5808ce 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ForbiddenException, + Inject, Injectable, Logger, NotFoundException, @@ -34,6 +35,11 @@ import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { Queue } from 'bullmq'; import { generateRandomSuffixNumbers } from '../../../common/helpers'; import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers'; +import { AuditEvent } from '../../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../../integrations/audit/audit.service'; @Injectable() export class WorkspaceService { @@ -52,6 +58,7 @@ export class WorkspaceService { @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue, @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} async findById(workspaceId: string) { @@ -428,6 +435,20 @@ export class WorkspaceService { user.id, workspaceId, ); + + this.auditService.log({ + event: AuditEvent.USER_ROLE_CHANGED, + resourceType: 'users', + resourceId: user.id, + changes: { + before: { role: user.role }, + after: { role: newRole }, + }, + metadata: { + userName: user.name, + userEmail: user.email, + }, + }); } async generateHostname( @@ -531,6 +552,19 @@ export class WorkspaceService { .execute(); }); + this.auditService.log({ + event: AuditEvent.USER_DELETED, + resourceType: 'users', + resourceId: user.id, + changes: { + before: { + name: user.name, + email: user.email, + role: user.role, + }, + }, + }); + try { await this.attachmentQueue.add(QueueJob.DELETE_USER_AVATARS, user); } catch (err) { diff --git a/apps/server/src/database/migrations/20260109T120000-audit-logs.ts b/apps/server/src/database/migrations/20260109T120000-audit-logs.ts new file mode 100644 index 00000000..7e1a969e --- /dev/null +++ b/apps/server/src/database/migrations/20260109T120000-audit-logs.ts @@ -0,0 +1,32 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('audit_logs') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.notNull().references('workspaces.id').onDelete('cascade'), + ) + .addColumn('actor_id', 'uuid', (col) => + col.references('users.id').onDelete('set null'), + ) + .addColumn('actor_type', 'varchar', (col) => + col.notNull().defaultTo('user'), + ) + .addColumn('event', 'varchar', (col) => col.notNull()) + .addColumn('resource_type', 'varchar', (col) => col.notNull()) + .addColumn('resource_id', 'uuid') + .addColumn('changes', 'jsonb') + .addColumn('metadata', 'jsonb') + .addColumn('ip_address', sql `inet`) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('audit_logs').execute(); +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index fe5b8fab..05289606 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -3,18 +3,13 @@ * Please do not edit it manually. */ -import type { ColumnType } from 'kysely'; +import type { ColumnType } from "kysely"; -export type Generated = - T extends ColumnType - ? ColumnType - : ColumnType; +export type Generated = T extends ColumnType + ? ColumnType + : ColumnType; -export type Int8 = ColumnType< - string, - bigint | number | string, - bigint | number | string ->; +export type Int8 = ColumnType; export type Json = JsonValue; @@ -32,13 +27,13 @@ export type Timestamp = ColumnType; export interface ApiKeys { createdAt: Generated; + creatorId: string; deletedAt: Timestamp | null; expiresAt: Timestamp | null; id: Generated; lastUsedAt: Timestamp | null; name: string | null; updatedAt: Generated; - creatorId: string; workspaceId: string; } @@ -61,6 +56,20 @@ export interface Attachments { workspaceId: string; } +export interface AuditLogs { + actorId: string | null; + actorType: Generated; + changes: Json | null; + createdAt: Generated; + event: string; + id: Generated; + ipAddress: string | null; + metadata: Json | null; + resourceId: string | null; + resourceType: string; + workspaceId: string; +} + export interface AuthAccounts { authProviderId: string | null; createdAt: Generated; @@ -77,25 +86,25 @@ export interface AuthProviders { createdAt: Generated; creatorId: string | null; deletedAt: Timestamp | null; + groupSync: Generated; id: Generated; isEnabled: Generated; - groupSync: Generated; ldapBaseDn: string | null; ldapBindDn: string | null; ldapBindPassword: string | null; + ldapConfig: Json | null; ldapTlsCaCert: string | null; ldapTlsEnabled: Generated; ldapUrl: string | null; ldapUserAttributes: Json | null; ldapUserSearchFilter: string | null; - ldapConfig: Json | null; - settings: Json | null; name: string; oidcClientId: string | null; oidcClientSecret: string | null; oidcIssuer: string | null; samlCertificate: string | null; samlUrl: string | null; + settings: Json | null; type: string; updatedAt: Generated; workspaceId: string; @@ -225,9 +234,11 @@ export interface Pages { icon: string | null; id: Generated; isLocked: Generated; + isRestricted: Generated; lastUpdatedById: string | null; parentPageId: string | null; position: string | null; + restrictedById: string | null; slugId: string; spaceId: string; textContent: string | null; @@ -298,12 +309,12 @@ export interface Users { deletedAt: Timestamp | null; email: string; emailVerifiedAt: Timestamp | null; + hasGeneratedPassword: Generated; id: Generated; invitedById: string | null; lastActiveAt: Timestamp | null; lastLoginAt: Timestamp | null; locale: string | null; - hasGeneratedPassword: Generated; name: string | null; password: string | null; role: string | null; @@ -363,6 +374,7 @@ export interface Workspaces { export interface DB { apiKeys: ApiKeys; attachments: Attachments; + auditLogs: AuditLogs; authAccounts: AuthAccounts; authProviders: AuthProviders; backlinks: Backlinks; @@ -372,6 +384,7 @@ export interface DB { groups: Groups; groupUsers: GroupUsers; pageHistory: PageHistory; + AuditLog: PagePermissions; pages: Pages; shares: Shares; spaceMembers: SpaceMembers; diff --git a/apps/server/src/database/types/db.interface.ts b/apps/server/src/database/types/db.interface.ts index 969e2059..be66fd8c 100644 --- a/apps/server/src/database/types/db.interface.ts +++ b/apps/server/src/database/types/db.interface.ts @@ -1,47 +1,6 @@ -import { - ApiKeys, - Attachments, - AuthAccounts, - AuthProviders, - Backlinks, - Billing, - Comments, - FileTasks, - Groups, - GroupUsers, - PageHistory, - Pages, - Shares, - SpaceMembers, - Spaces, - UserMfa, - Users, - UserTokens, - WorkspaceInvitations, - Workspaces, -} from '@docmost/db/types/db'; +import { DB } from '@docmost/db/types/db'; import { PageEmbeddings } from '@docmost/db/types/embeddings.types'; -export interface DbInterface { - attachments: Attachments; - authAccounts: AuthAccounts; - authProviders: AuthProviders; - backlinks: Backlinks; - billing: Billing; - comments: Comments; - fileTasks: FileTasks; - groups: Groups; - groupUsers: GroupUsers; +export interface DbInterface extends DB { pageEmbeddings: PageEmbeddings; - pageHistory: PageHistory; - pages: Pages; - shares: Shares; - spaceMembers: SpaceMembers; - spaces: Spaces; - userMfa: UserMfa; - users: Users; - userTokens: UserTokens; - workspaceInvitations: WorkspaceInvitations; - workspaces: Workspaces; - apiKeys: ApiKeys; } diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 7f273dce..e8d1cb41 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -20,6 +20,7 @@ import { FileTasks, UserMfa as _UserMFA, ApiKeys, + AuditLogs, } from './db'; import { PageEmbeddings } from '@docmost/db/types/embeddings.types'; @@ -131,3 +132,8 @@ export type UpdatableApiKey = Updateable>; export type PageEmbedding = Selectable; export type InsertablePageEmbedding = Insertable; export type UpdatablePageEmbedding = Updateable>; + +// Audit Log +export type AuditLog = Selectable; +export type InsertableAuditLog = Insertable; +export type UpdatableAuditLog = Updateable>; diff --git a/apps/server/src/ee b/apps/server/src/ee index 075761c2..741c15eb 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 075761c2d9bcae7adcc3de4b1c5b8f8c3b315878 +Subproject commit 741c15eba36923c76127363ffb218efc8fa8290c diff --git a/apps/server/src/integrations/audit/audit.service.ts b/apps/server/src/integrations/audit/audit.service.ts new file mode 100644 index 00000000..71636c9f --- /dev/null +++ b/apps/server/src/integrations/audit/audit.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { AuditLogPayload, ActorType } from '../../common/events/audit-events'; + +export type IAuditService = { + log(payload: AuditLogPayload): void | Promise; + logWithContext( + payload: AuditLogPayload, + context: { + workspaceId: string; + actorId?: string; + actorType?: ActorType; + ipAddress?: string; + userAgent?: string; + }, + ): void | Promise; + setActorId(actorId: string): void; + setActorType(actorType: ActorType): void; +}; + +export const AUDIT_SERVICE = Symbol('AUDIT_SERVICE'); + +@Injectable() +export class NoopAuditService implements IAuditService { + log(_payload: AuditLogPayload): void { + // No-op: swallow the log when EE module is not available + } + + logWithContext( + _payload: AuditLogPayload, + _context: { + workspaceId: string; + actorId?: string; + actorType?: ActorType; + ipAddress?: string; + userAgent?: string; + }, + ): void { + // No-op: swallow the log when EE module is not available + } + + setActorId(_actorId: string): void { + // No-op + } + + setActorType(_actorType: ActorType): void { + // No-op + } +} diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index 5c7aa29a..482ddfd7 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -6,6 +6,7 @@ export enum QueueName { FILE_TASK_QUEUE = '{file-task-queue}', SEARCH_QUEUE = '{search-queue}', AI_QUEUE = '{ai-queue}', + AUDIT_QUEUE = '{audit-queue}', } export enum QueueJob { @@ -58,4 +59,7 @@ export enum QueueJob { GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings', DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings', + + AUDIT_LOG = 'audit-log', + AUDIT_CLEANUP = 'audit-cleanup', } diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts index 6787e010..afc01391 100644 --- a/apps/server/src/integrations/queue/queue.module.ts +++ b/apps/server/src/integrations/queue/queue.module.ts @@ -73,6 +73,14 @@ import { BacklinksProcessor } from './processors/backlinks.processor'; attempts: 1, }, }), + BullModule.registerQueue({ + name: QueueName.AUDIT_QUEUE, + defaultJobOptions: { + removeOnComplete: true, + removeOnFail: true, + attempts: 3, + }, + }), ], exports: [BullModule], providers: [BacklinksProcessor], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95d7875b..ac052aa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -578,6 +578,9 @@ importers: nanoid: specifier: 3.3.11 version: 3.3.11 + nestjs-cls: + specifier: ^4.5.0 + version: 4.5.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) nestjs-kysely: specifier: ^1.2.0 version: 1.2.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(kysely@0.28.2)(reflect-metadata@0.2.2) @@ -7982,6 +7985,15 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + nestjs-cls@4.5.0: + resolution: {integrity: sha512-oi3GNCc5pnsnVI5WJKMDwmg4NP+JyEw+edlwgepyUba5+RGGtJzpbVaaxXGW1iPbDuQde3/fA8Jdjq9j88BVcQ==} + engines: {node: '>=16'} + peerDependencies: + '@nestjs/common': '> 7.0.0 < 11' + '@nestjs/core': '> 7.0.0 < 11' + reflect-metadata: '*' + rxjs: '>= 7' + nestjs-kysely@1.2.0: resolution: {integrity: sha512-KseCGb0SXCzIYC+Hx3Z3d+kPAfSZCSK6j9UoqUV/gcBCPad9utC7itmoUw0/w5sV+Jf9pc1DKpgClP1IkflA4w==} peerDependencies: @@ -19131,6 +19143,13 @@ snapshots: neo-async@2.6.2: {} + nestjs-cls@4.5.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2): + dependencies: + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + nestjs-kysely@1.2.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(kysely@0.28.2)(reflect-metadata@0.2.2): dependencies: '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)