mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-24 19:27:23 +00:00
🛡️ 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:
parent
ca17a7d055
commit
b6eba41e29
3 changed files with 90 additions and 5 deletions
|
|
@ -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' }));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue