From b6eba41e29d644ad89bcc7d50c3d8661810a5f4e Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 14 May 2026 10:10:43 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20feat:=20Enforce=20appen?= =?UTF-8?q?d-only=20AuditLog=20at=20the=20schema=20level?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every field is now marked `immutable: true`, and pre-hooks on the schema reject `updateOne`, `updateMany`, `findOneAndUpdate`, `findOneAndReplace`, `replaceOne`, `deleteOne`, `deleteMany`, `findOneAndDelete`, plus any `save()` against an existing document. `timestamps` is reduced to `{ createdAt: true, updatedAt: false }` since a mutable timestamp would imply mutation is allowed, and `updatedAt` is dropped from `AuditLog` / `IAuditLog`. The methods spec resets state between tests via the raw driver (`AuditLog.collection.deleteMany`), which bypasses the pre-hooks; new specs assert that the model-level update / delete / re-save paths reject with the append-only error and that `updatedAt` is not stamped on new documents. --- .../data-schemas/src/methods/auditLog.spec.ts | 43 ++++++++++++++++++- packages/data-schemas/src/schema/auditLog.ts | 43 ++++++++++++++++++- packages/data-schemas/src/types/auditLog.ts | 9 ++-- 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/packages/data-schemas/src/methods/auditLog.spec.ts b/packages/data-schemas/src/methods/auditLog.spec.ts index 07f8a4310a..a5e23ab08d 100644 --- a/packages/data-schemas/src/methods/auditLog.spec.ts +++ b/packages/data-schemas/src/methods/auditLog.spec.ts @@ -47,7 +47,9 @@ afterAll(async () => { }); beforeEach(async () => { - await AuditLog.deleteMany({}); + // AuditLog enforces append-only via pre-hooks, so reset between tests by + // dropping the collection at the raw driver level (bypasses model hooks). + await AuditLog.collection.deleteMany({}); }); describe('auditLog methods', () => { @@ -174,6 +176,45 @@ describe('auditLog methods', () => { }); }); + describe('append-only enforcement', () => { + it('rejects updateOne against any audit entry', async () => { + const doc = await methods.recordAuditEntry(baseInput()); + await expect( + AuditLog.updateOne({ _id: doc!._id }, { capability: 'tampered' }), + ).rejects.toThrow(/append-only/); + }); + + it('rejects findOneAndUpdate against any audit entry', async () => { + const doc = await methods.recordAuditEntry(baseInput()); + await expect( + AuditLog.findOneAndUpdate({ _id: doc!._id }, { capability: 'tampered' }), + ).rejects.toThrow(/append-only/); + }); + + it('rejects deleteOne against any audit entry', async () => { + const doc = await methods.recordAuditEntry(baseInput()); + await expect(AuditLog.deleteOne({ _id: doc!._id })).rejects.toThrow(/append-only/); + }); + + it('rejects deleteMany', async () => { + await methods.recordAuditEntry(baseInput()); + await expect(AuditLog.deleteMany({})).rejects.toThrow(/append-only/); + }); + + it('rejects a second save() on an existing document', async () => { + const doc = await methods.recordAuditEntry(baseInput()); + const persisted = (await AuditLog.findById(doc!._id))!; + persisted.capability = 'tampered'; + await expect(persisted.save()).rejects.toThrow(/append-only/); + }); + + it('does not stamp updatedAt on new documents', async () => { + const doc = await methods.recordAuditEntry(baseInput()); + const raw = await AuditLog.collection.findOne({ _id: doc!._id }); + expect(raw && Object.prototype.hasOwnProperty.call(raw, 'updatedAt')).toBe(false); + }); + }); + describe('streamAuditLogEntries', () => { it('invokes onEntry for every match without skipping tenant boundaries', async () => { await methods.recordAuditEntry(baseInput({ capability: 'manage:users' })); diff --git a/packages/data-schemas/src/schema/auditLog.ts b/packages/data-schemas/src/schema/auditLog.ts index 4de1a9de64..de310257e2 100644 --- a/packages/data-schemas/src/schema/auditLog.ts +++ b/packages/data-schemas/src/schema/auditLog.ts @@ -2,26 +2,36 @@ import { Schema } from 'mongoose'; import { PrincipalType } from 'librechat-data-provider'; import type { IAuditLog } from '~/types'; +/** + * Append-only by schema contract: every field is `immutable`, every + * update-style operation is short-circuited in the pre-hooks below, and + * `updatedAt` is intentionally not maintained — a mutable timestamp would + * imply mutation is allowed. + */ const auditLogSchema = new Schema( { action: { type: String, enum: ['grant_assigned', 'grant_removed'], required: true, + immutable: true, }, actorId: { type: Schema.Types.ObjectId, ref: 'User', required: true, + immutable: true, }, actorName: { type: String, required: true, + immutable: true, }, targetPrincipalType: { type: String, enum: Object.values(PrincipalType), required: true, + immutable: true, }, /** * Mixed: ObjectId for USER/GROUP principals, role-name string for ROLE. @@ -31,14 +41,17 @@ const auditLogSchema = new Schema( targetPrincipalId: { type: Schema.Types.Mixed, required: true, + immutable: true, }, targetName: { type: String, required: true, + immutable: true, }, capability: { type: String, required: true, + immutable: true, }, /** * Platform-level audit entries MUST omit this field entirely — never set @@ -50,6 +63,7 @@ const auditLogSchema = new Schema( tenantId: { type: String, required: false, + immutable: true, validate: { validator: (v: unknown) => typeof v === 'string' && v.trim().length > 0, message: @@ -57,9 +71,36 @@ const auditLogSchema = new Schema( }, }, }, - { timestamps: true }, + { timestamps: { createdAt: true, updatedAt: false } }, ); +const APPEND_ONLY_MESSAGE = 'AuditLog is append-only — updates and deletes are forbidden'; + +/** Block every query-level update path. */ +auditLogSchema.pre( + ['updateOne', 'updateMany', 'findOneAndUpdate', 'findOneAndReplace', 'replaceOne'], + function (next) { + next(new Error(APPEND_ONLY_MESSAGE)); + }, +); + +/** Block every query-level delete path. */ +auditLogSchema.pre(['deleteOne', 'deleteMany', 'findOneAndDelete'], function (next) { + next(new Error(APPEND_ONLY_MESSAGE)); +}); + +/** + * Document-level `save` is allowed for new docs only. A second save on an + * existing document would mutate it, so reject that case explicitly. + */ +auditLogSchema.pre('save', function (next) { + if (!this.isNew) { + next(new Error(APPEND_ONLY_MESSAGE)); + return; + } + next(); +}); + /** * Primary listing: tenant + newest-first sort with `_id` as a deterministic * tiebreak for offset pagination so adjacent pages never duplicate or skip rows diff --git a/packages/data-schemas/src/types/auditLog.ts b/packages/data-schemas/src/types/auditLog.ts index cd06b79911..271b9e77da 100644 --- a/packages/data-schemas/src/types/auditLog.ts +++ b/packages/data-schemas/src/types/auditLog.ts @@ -2,6 +2,11 @@ import type { Document, Types } from 'mongoose'; import type { PrincipalType } from 'librechat-data-provider'; import type { AdminAuditLogEntry, AuditAction } from './admin'; +/** + * AuditLog is an append-only collection — no `updatedAt`, no mutations. + * See `~/schema/auditLog` for the enforcement (immutable fields plus + * pre-update / pre-delete / pre-save hooks). + */ export type AuditLog = { action: AuditAction; actorId: Types.ObjectId; @@ -13,16 +18,14 @@ export type AuditLog = { capability: string; /** Absent = platform-operator audit; present = tenant-scoped audit */ tenantId?: string; - /** Mongoose auto-managed via { timestamps: true } */ + /** Mongoose auto-managed via `timestamps: { createdAt: true, updatedAt: false }` */ createdAt?: Date; - updatedAt?: Date; }; export type IAuditLog = AuditLog & Document & { _id: Types.ObjectId; createdAt: Date; - updatedAt: Date; }; export interface RecordAuditEntryInput {