🛡️ feat: Enforce append-only AuditLog at the schema level

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.
This commit is contained in:
Dustin Healy 2026-05-14 10:10:43 -07:00 • committed by Danny Avila
parent ca17a7d055
commit b6eba41e29
3 changed files with 90 additions and 5 deletions

View file

@ -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' }));

View file

@ -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<IAuditLog>(
{
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<IAuditLog>(
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<IAuditLog>(
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<IAuditLog>(
},
},
},
{ 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

View file

@ -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 {