@tumbaland/backend-core 1.38.0 → 1.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apiKeys/ApiKey.d.ts +11 -0
- package/dist/apiKeys/ApiKey.d.ts.map +1 -1
- package/dist/apiKeys/ApiKey.js +14 -1
- package/dist/apiKeys/ApiKey.js.map +1 -1
- package/dist/apiKeys/middleware.d.ts +12 -0
- package/dist/apiKeys/middleware.d.ts.map +1 -1
- package/dist/apiKeys/middleware.js +16 -4
- package/dist/apiKeys/middleware.js.map +1 -1
- package/dist/apiKeys/service.d.ts.map +1 -1
- package/dist/apiKeys/service.js +1 -0
- package/dist/apiKeys/service.js.map +1 -1
- package/dist/apiKeys/types.d.ts +2 -0
- package/dist/apiKeys/types.d.ts.map +1 -1
- package/dist/audit/AuditEvent.d.ts +51 -0
- package/dist/audit/AuditEvent.d.ts.map +1 -0
- package/dist/audit/AuditEvent.js +69 -0
- package/dist/audit/AuditEvent.js.map +1 -0
- package/dist/audit/actor.d.ts +41 -0
- package/dist/audit/actor.d.ts.map +1 -0
- package/dist/audit/actor.js +39 -0
- package/dist/audit/actor.js.map +1 -0
- package/dist/audit/context.d.ts +40 -0
- package/dist/audit/context.d.ts.map +1 -0
- package/dist/audit/context.js +60 -0
- package/dist/audit/context.js.map +1 -0
- package/dist/audit/index.d.ts +12 -0
- package/dist/audit/index.d.ts.map +1 -0
- package/dist/audit/index.js +22 -0
- package/dist/audit/index.js.map +1 -0
- package/dist/audit/plugin.d.ts +23 -0
- package/dist/audit/plugin.d.ts.map +1 -0
- package/dist/audit/plugin.js +226 -0
- package/dist/audit/plugin.js.map +1 -0
- package/dist/audit/reads.d.ts +47 -0
- package/dist/audit/reads.d.ts.map +1 -0
- package/dist/audit/reads.js +94 -0
- package/dist/audit/reads.js.map +1 -0
- package/dist/audit/service.d.ts +49 -0
- package/dist/audit/service.d.ts.map +1 -0
- package/dist/audit/service.js +65 -0
- package/dist/audit/service.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/oauth/models.d.ts.map +1 -1
- package/dist/oauth/models.js +10 -0
- package/dist/oauth/models.js.map +1 -1
- package/dist/oauth/tokens.d.ts +15 -0
- package/dist/oauth/tokens.d.ts.map +1 -1
- package/dist/oauth/tokens.js +5 -1
- package/dist/oauth/tokens.js.map +1 -1
- package/package.json +1 -1
- package/src/apiKeys/ApiKey.test.ts +25 -1
- package/src/apiKeys/ApiKey.ts +15 -0
- package/src/apiKeys/middleware.test.ts +26 -3
- package/src/apiKeys/middleware.ts +32 -5
- package/src/apiKeys/service.test.ts +2 -0
- package/src/apiKeys/service.ts +1 -0
- package/src/apiKeys/types.ts +2 -0
- package/src/audit/AuditEvent.ts +79 -0
- package/src/audit/actor.test.ts +95 -0
- package/src/audit/actor.ts +68 -0
- package/src/audit/context.test.ts +91 -0
- package/src/audit/context.ts +83 -0
- package/src/audit/index.ts +11 -0
- package/src/audit/plugin.test.ts +258 -0
- package/src/audit/plugin.ts +254 -0
- package/src/audit/reads.test.ts +164 -0
- package/src/audit/reads.ts +88 -0
- package/src/audit/service.test.ts +115 -0
- package/src/audit/service.ts +92 -0
- package/src/index.ts +1 -0
- package/src/middleware/authMiddleware.test.ts +3 -1
- package/src/oauth/models.ts +11 -0
- package/src/oauth/tokens.test.ts +2 -0
- package/src/oauth/tokens.ts +20 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
3
|
+
import { actorOf, type Actor } from './actor';
|
|
4
|
+
import { recordRead } from './reads';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The actor, reachable from wherever the write actually happens.
|
|
8
|
+
*
|
|
9
|
+
* Attribution has to come from the request and be recorded at the data layer,
|
|
10
|
+
* and those are far apart: a Mongoose hook knows exactly which fields changed
|
|
11
|
+
* and nothing about who asked. Threading an actor through every service and
|
|
12
|
+
* controller signature to bridge that would touch every function between the
|
|
13
|
+
* two and be silently wrong the first time someone forgot.
|
|
14
|
+
*
|
|
15
|
+
* `AsyncLocalStorage` carries it instead, so the hook reads the actor without
|
|
16
|
+
* anything in between knowing it exists. A write outside a request — a script,
|
|
17
|
+
* a migration — simply finds no actor, which is the truth about it.
|
|
18
|
+
*/
|
|
19
|
+
export interface AuditContext {
|
|
20
|
+
actor: Actor;
|
|
21
|
+
service: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const storage = new AsyncLocalStorage<AuditContext>();
|
|
25
|
+
|
|
26
|
+
export const currentAuditContext = (): AuditContext | undefined => storage.getStore();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run the rest of a request with its actor in scope.
|
|
30
|
+
*
|
|
31
|
+
* Mounted after authentication, since there is no actor before it.
|
|
32
|
+
*/
|
|
33
|
+
export const auditContext =
|
|
34
|
+
(service: string): RequestHandler =>
|
|
35
|
+
(req: Request, _res: Response, next: NextFunction): void => {
|
|
36
|
+
const actor = actorOf(req);
|
|
37
|
+
if (!actor) {
|
|
38
|
+
next();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
storage.run({ actor, service }, next);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Run something with an explicit actor — for jobs and tests. */
|
|
46
|
+
export const withAuditContext = <T>(context: AuditContext, fn: () => T): T =>
|
|
47
|
+
storage.run(context, fn);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The service this process is, for a trail that spans several of them.
|
|
51
|
+
*
|
|
52
|
+
* `SERVICE_NAME` is already how metrics, health and the internal-service clients
|
|
53
|
+
* identify a process, so auditing uses the same one rather than introducing a
|
|
54
|
+
* second name for the same thing.
|
|
55
|
+
*
|
|
56
|
+
* The fallback to the package name matters more here than elsewhere: the trail
|
|
57
|
+
* is filtered by service in the UI, and in local development nothing sets
|
|
58
|
+
* `SERVICE_NAME`, so every row would land under one meaningless label and the
|
|
59
|
+
* filter would look broken. Every service's package is named after it.
|
|
60
|
+
*/
|
|
61
|
+
const serviceName = (): string =>
|
|
62
|
+
process.env.SERVICE_NAME || process.env.npm_package_name || 'unknown-service';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Put a request's actor in scope, and arm the read trail, for everything after.
|
|
66
|
+
*
|
|
67
|
+
* Called from `authenticateAgent` rather than mounted as its own middleware. A
|
|
68
|
+
* separate mount only works on a router that authenticates with
|
|
69
|
+
* `router.use(...)`; routers that authenticate per route — album's photos,
|
|
70
|
+
* finance's tickers — have no actor yet when a router-level middleware runs, so
|
|
71
|
+
* the context would be silently empty for exactly the endpoints most worth
|
|
72
|
+
* auditing. Doing it where authentication happens makes the two inseparable.
|
|
73
|
+
*/
|
|
74
|
+
export function beginAudit(req: Request, res: Response, next: NextFunction): void {
|
|
75
|
+
const actor = actorOf(req);
|
|
76
|
+
if (!actor) {
|
|
77
|
+
next();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
recordRead(req, res, actor, serviceName());
|
|
82
|
+
storage.run({ actor, service: serviceName() }, next);
|
|
83
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { actorOf, describeActor } from './actor';
|
|
2
|
+
export type { Actor, ActorKind } from './actor';
|
|
3
|
+
export { AuditEvent } from './AuditEvent';
|
|
4
|
+
export type { IAuditEvent } from './AuditEvent';
|
|
5
|
+
export { auditContext, beginAudit, currentAuditContext, withAuditContext } from './context';
|
|
6
|
+
export type { AuditContext } from './context';
|
|
7
|
+
export { auditPlugin } from './plugin';
|
|
8
|
+
export type { AuditPluginOptions } from './plugin';
|
|
9
|
+
export { auditReads, countRead, resourceOf } from './reads';
|
|
10
|
+
export { listAuditEvents } from './service';
|
|
11
|
+
export type { AuditQuery, AuditEntry } from './service';
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests against a real MongoDB, not mocks.
|
|
3
|
+
*
|
|
4
|
+
* The whole value of this plugin is that it sees what actually changed, and a
|
|
5
|
+
* mocked Mongoose cannot tell you that: hooks that never fire, a `findOneAndUpdate`
|
|
6
|
+
* that returns whatever the mock was told to, a diff computed against a fixture
|
|
7
|
+
* rather than a document. Every bug worth catching here — a hook on the wrong
|
|
8
|
+
* event, an update whose before-image was read after the write, a delete whose
|
|
9
|
+
* snapshot came back empty — passes a mocked test and fails a real one.
|
|
10
|
+
*/
|
|
11
|
+
import mongoose, { Schema } from 'mongoose';
|
|
12
|
+
import { MongoMemoryServer } from 'mongodb-memory-server';
|
|
13
|
+
|
|
14
|
+
jest.mock('../logging/logger', () => ({
|
|
15
|
+
__esModule: true,
|
|
16
|
+
default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import { AuditEvent } from './AuditEvent';
|
|
20
|
+
import { auditPlugin } from './plugin';
|
|
21
|
+
import { withAuditContext } from './context';
|
|
22
|
+
import type { Actor } from './actor';
|
|
23
|
+
|
|
24
|
+
let mongo: MongoMemoryServer;
|
|
25
|
+
|
|
26
|
+
interface INote {
|
|
27
|
+
title: string;
|
|
28
|
+
hours?: number;
|
|
29
|
+
secret?: string;
|
|
30
|
+
createdBy?: string;
|
|
31
|
+
updatedBy?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const NoteSchema = new Schema<INote>(
|
|
35
|
+
{ title: String, hours: Number, secret: String },
|
|
36
|
+
{ timestamps: true }
|
|
37
|
+
);
|
|
38
|
+
NoteSchema.plugin(auditPlugin, { resource: 'note', redact: ['secret'] });
|
|
39
|
+
const Note = mongoose.model<INote>('AuditTestNote', NoteSchema);
|
|
40
|
+
|
|
41
|
+
const CLAUDE: Actor = {
|
|
42
|
+
kind: 'assistant',
|
|
43
|
+
userId: 'u1',
|
|
44
|
+
label: 'Claude',
|
|
45
|
+
credentialId: 'client-1',
|
|
46
|
+
tenant: 'g1'
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A Mongoose query builds lazily and only runs when it is awaited, so the await
|
|
51
|
+
* has to happen *inside* the context. Awaiting outside it — which reads
|
|
52
|
+
* identically at the call site — starts the query with no actor in scope and
|
|
53
|
+
* silently records nothing.
|
|
54
|
+
*/
|
|
55
|
+
const asClaude = <T>(fn: () => Promise<T>): Promise<T> =>
|
|
56
|
+
withAuditContext({ actor: CLAUDE, service: 'test-service' }, async () => await fn());
|
|
57
|
+
|
|
58
|
+
const asPerson = <T>(fn: () => Promise<T>): Promise<T> =>
|
|
59
|
+
withAuditContext(
|
|
60
|
+
{ actor: { kind: 'person', userId: 'u1', label: 'Tom', tenant: 'g1' }, service: 'test-service' },
|
|
61
|
+
async () => await fn()
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
/** Recording is fire-and-forget, so a test has to let the write land. */
|
|
65
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 60));
|
|
66
|
+
|
|
67
|
+
const events = () => AuditEvent.find().sort({ _id: 1 }).lean();
|
|
68
|
+
|
|
69
|
+
beforeAll(async () => {
|
|
70
|
+
mongo = await MongoMemoryServer.create();
|
|
71
|
+
await mongoose.connect(mongo.getUri());
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterAll(async () => {
|
|
75
|
+
await mongoose.disconnect();
|
|
76
|
+
await mongo.stop();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
beforeEach(async () => {
|
|
80
|
+
await Promise.all([Note.deleteMany({}), AuditEvent.deleteMany({})]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('attribution on the record', () => {
|
|
84
|
+
it('stamps who created it and who last touched it', async () => {
|
|
85
|
+
const note = await asClaude(() => new Note({ title: 'Dinner' }).save());
|
|
86
|
+
|
|
87
|
+
expect(note.createdBy).toBe('Claude');
|
|
88
|
+
expect(note.updatedBy).toBe('Claude');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('leaves createdBy alone on a later edit, and moves updatedBy', async () => {
|
|
92
|
+
const note = await asClaude(() => new Note({ title: 'Dinner' }).save());
|
|
93
|
+
|
|
94
|
+
await asPerson(() => Note.findOneAndUpdate({ _id: note._id }, { title: 'Late dinner' }, { new: true }));
|
|
95
|
+
|
|
96
|
+
const after = await Note.findById(note._id);
|
|
97
|
+
expect(after?.createdBy).toBe('Claude');
|
|
98
|
+
expect(after?.updatedBy).toBe('Tom');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('writes nothing at all outside a request', async () => {
|
|
102
|
+
// A backfill script is not a person, and a row claiming it was would be
|
|
103
|
+
// worse than no row.
|
|
104
|
+
const note = await new Note({ title: 'From a migration' }).save();
|
|
105
|
+
await settle();
|
|
106
|
+
|
|
107
|
+
expect(note.createdBy).toBeUndefined();
|
|
108
|
+
expect(await events()).toHaveLength(0);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('the trail', () => {
|
|
113
|
+
it('records a save of an existing document as an update, not a second create', async () => {
|
|
114
|
+
// Anything that loads a document, changes a field and saves it — revoking
|
|
115
|
+
// an API key, counting a reveal — would otherwise appear as the thing being
|
|
116
|
+
// created again, with a fresh snapshot and no sign of what changed.
|
|
117
|
+
const note = await asClaude(() => new Note({ title: 'Dinner', hours: 3 }).save());
|
|
118
|
+
|
|
119
|
+
await asClaude(async () => {
|
|
120
|
+
const loaded = await Note.findById(note._id);
|
|
121
|
+
loaded!.hours = 2;
|
|
122
|
+
return loaded!.save();
|
|
123
|
+
});
|
|
124
|
+
await settle();
|
|
125
|
+
|
|
126
|
+
const actions = (await events()).map((e) => e.action);
|
|
127
|
+
expect(actions).toEqual(['create', 'update']);
|
|
128
|
+
|
|
129
|
+
const update = (await events()).find((e) => e.action === 'update');
|
|
130
|
+
expect(update?.changes).toEqual({ hours: { from: 3, to: 2 } });
|
|
131
|
+
expect(update?.snapshot).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('records nothing when a save changed nothing', async () => {
|
|
135
|
+
const note = await asClaude(() => new Note({ title: 'Dinner' }).save());
|
|
136
|
+
|
|
137
|
+
await asClaude(async () => {
|
|
138
|
+
const loaded = await Note.findById(note._id);
|
|
139
|
+
return loaded!.save();
|
|
140
|
+
});
|
|
141
|
+
await settle();
|
|
142
|
+
|
|
143
|
+
expect((await events()).filter((e) => e.action === 'update')).toHaveLength(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('records a create with the document that was written', async () => {
|
|
147
|
+
await asClaude(() => new Note({ title: 'Dinner', hours: 2 }).save());
|
|
148
|
+
await settle();
|
|
149
|
+
|
|
150
|
+
const [event] = await events();
|
|
151
|
+
expect(event).toMatchObject({
|
|
152
|
+
action: 'create',
|
|
153
|
+
resource: 'note',
|
|
154
|
+
actorKind: 'assistant',
|
|
155
|
+
actorLabel: 'Claude',
|
|
156
|
+
actorCredentialId: 'client-1',
|
|
157
|
+
tenant: 'g1',
|
|
158
|
+
service: 'test-service'
|
|
159
|
+
});
|
|
160
|
+
expect(event.snapshot).toMatchObject({ title: 'Dinner', hours: 2 });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('records an update as before and after, field by field', async () => {
|
|
164
|
+
const note = await asClaude(() => new Note({ title: 'Dinner', hours: 3 }).save());
|
|
165
|
+
await asClaude(() => Note.findOneAndUpdate({ _id: note._id }, { hours: 2 }, { new: true }));
|
|
166
|
+
await settle();
|
|
167
|
+
|
|
168
|
+
const update = (await events()).find((e) => e.action === 'update');
|
|
169
|
+
// The point of the whole exercise: "Claude edited an entry" is barely worth
|
|
170
|
+
// storing; "Claude changed hours from 3 to 2" is what someone came to see.
|
|
171
|
+
expect(update?.changes).toEqual({ hours: { from: 3, to: 2 } });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('never persists its own before-image into the document', async () => {
|
|
175
|
+
// The obvious way to stash state on a query is `query.set()`, and that
|
|
176
|
+
// writes into the update payload — so the before-image would be saved onto
|
|
177
|
+
// the very document it was taken of, growing a copy of each record inside
|
|
178
|
+
// itself on every edit.
|
|
179
|
+
const note = await asClaude(() => new Note({ title: 'Dinner', hours: 3 }).save());
|
|
180
|
+
await asClaude(() => Note.findOneAndUpdate({ _id: note._id }, { hours: 2 }, { new: true }));
|
|
181
|
+
|
|
182
|
+
const raw = await mongoose.connection.db!.collection('audittestnotes').findOne({ _id: note._id });
|
|
183
|
+
expect(Object.keys(raw!).filter((key) => key.startsWith('__audit'))).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('records nothing for an update that changed nothing', async () => {
|
|
187
|
+
const note = await asClaude(() => new Note({ title: 'Dinner', hours: 3 }).save());
|
|
188
|
+
await asClaude(() => Note.findOneAndUpdate({ _id: note._id }, { hours: 3 }, { new: true }));
|
|
189
|
+
await settle();
|
|
190
|
+
|
|
191
|
+
expect((await events()).filter((e) => e.action === 'update')).toHaveLength(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('reports only the fields the update touched', async () => {
|
|
195
|
+
const note = await asClaude(() => new Note({ title: 'Dinner', hours: 3 }).save());
|
|
196
|
+
await asClaude(() => Note.findOneAndUpdate({ _id: note._id }, { hours: 2 }, { new: true }));
|
|
197
|
+
await settle();
|
|
198
|
+
|
|
199
|
+
const update = (await events()).find((e) => e.action === 'update');
|
|
200
|
+
expect(Object.keys(update!.changes!)).toEqual(['hours']);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('keeps what a delete removed, since nothing else does', async () => {
|
|
204
|
+
const note = await asClaude(() => new Note({ title: 'Dinner', hours: 2 }).save());
|
|
205
|
+
await asClaude(() => Note.findOneAndDelete({ _id: note._id }));
|
|
206
|
+
await settle();
|
|
207
|
+
|
|
208
|
+
const deleted = (await events()).find((e) => e.action === 'delete');
|
|
209
|
+
expect(deleted?.snapshot).toMatchObject({ title: 'Dinner', hours: 2 });
|
|
210
|
+
expect(deleted?.resourceId).toBe(String(note._id));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('records a bulk delete one row per document', async () => {
|
|
214
|
+
// A day's save deletes the entries it replaces, and "the editor removed
|
|
215
|
+
// three things" is exactly what someone would come here to find.
|
|
216
|
+
await asClaude(async () => {
|
|
217
|
+
await new Note({ title: 'One' }).save();
|
|
218
|
+
await new Note({ title: 'Two' }).save();
|
|
219
|
+
await Note.deleteMany({});
|
|
220
|
+
});
|
|
221
|
+
await settle();
|
|
222
|
+
|
|
223
|
+
const deletes = (await events()).filter((e) => e.action === 'delete');
|
|
224
|
+
expect(deletes).toHaveLength(2);
|
|
225
|
+
expect(deletes.map((d) => (d.snapshot as { title: string }).title).sort()).toEqual(['One', 'Two']);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('leaves redacted fields out of what it keeps', async () => {
|
|
229
|
+
await asClaude(() => new Note({ title: 'Dinner', secret: 'do not store' }).save());
|
|
230
|
+
await settle();
|
|
231
|
+
|
|
232
|
+
const [event] = await events();
|
|
233
|
+
expect(JSON.stringify(event)).not.toContain('do not store');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('omits bookkeeping fields nobody would want to read', async () => {
|
|
237
|
+
await asClaude(() => new Note({ title: 'Dinner' }).save());
|
|
238
|
+
await settle();
|
|
239
|
+
|
|
240
|
+
const [event] = await events();
|
|
241
|
+
expect(Object.keys(event.snapshot!)).not.toContain('updatedAt');
|
|
242
|
+
expect(Object.keys(event.snapshot!)).not.toContain('__v');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('recording never breaks the thing it records', () => {
|
|
247
|
+
it('still saves the document when the trail cannot be written', async () => {
|
|
248
|
+
const create = jest.spyOn(AuditEvent, 'create').mockRejectedValue(new Error('audit db down'));
|
|
249
|
+
|
|
250
|
+
const note = await asClaude(() => new Note({ title: 'Dinner' }).save());
|
|
251
|
+
await settle();
|
|
252
|
+
|
|
253
|
+
// Losing a row of history is a cost worth paying; losing the entry someone
|
|
254
|
+
// was logging is not.
|
|
255
|
+
expect(await Note.findById(note._id)).not.toBeNull();
|
|
256
|
+
create.mockRestore();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { Model, Schema } from 'mongoose';
|
|
2
|
+
import logger from '../logging/logger';
|
|
3
|
+
import { AuditEvent } from './AuditEvent';
|
|
4
|
+
import { currentAuditContext } from './context';
|
|
5
|
+
|
|
6
|
+
/** Bookkeeping fields nobody wants to read in a change list. */
|
|
7
|
+
const NOISE = new Set(['updatedAt', 'createdAt', '__v', 'createdBy', 'updatedBy']);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where a hook stashes what it read before the write.
|
|
11
|
+
*
|
|
12
|
+
* A symbol on the query object, not `query.set()` — that method writes into the
|
|
13
|
+
* *update payload*, so stashing there would persist the before-image into the
|
|
14
|
+
* document it was taken of. Per-query, so concurrent writes cannot read each
|
|
15
|
+
* other's.
|
|
16
|
+
*/
|
|
17
|
+
const BEFORE = Symbol('auditBefore');
|
|
18
|
+
|
|
19
|
+
/** Comparable form, so a Date and an ObjectId do not read as changed every time. */
|
|
20
|
+
const plain = (value: unknown): unknown => {
|
|
21
|
+
if (value === null || value === undefined) return value;
|
|
22
|
+
if (value instanceof Date) return value.toISOString();
|
|
23
|
+
if (typeof value === 'object' && 'toHexString' in (value as object)) return String(value);
|
|
24
|
+
return value;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const changesBetween = (
|
|
28
|
+
before: Record<string, unknown>,
|
|
29
|
+
after: Record<string, unknown>,
|
|
30
|
+
fields: string[]
|
|
31
|
+
): Record<string, { from: unknown; to: unknown }> | undefined => {
|
|
32
|
+
const changes: Record<string, { from: unknown; to: unknown }> = {};
|
|
33
|
+
|
|
34
|
+
for (const field of fields) {
|
|
35
|
+
if (NOISE.has(field)) continue;
|
|
36
|
+
const from = plain(before?.[field]);
|
|
37
|
+
const to = plain(after?.[field]);
|
|
38
|
+
if (JSON.stringify(from) !== JSON.stringify(to)) changes[field] = { from, to };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return Object.keys(changes).length > 0 ? changes : undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Never let recording a thing fail the thing.
|
|
46
|
+
*
|
|
47
|
+
* An audit trail is worth having and is not worth refusing a write over: a
|
|
48
|
+
* failure here means someone loses a row of history, and throwing would mean
|
|
49
|
+
* they lose the entry they were logging. So it is fire-and-forget with the
|
|
50
|
+
* failure written to the ordinary log, where an operator sees it.
|
|
51
|
+
*/
|
|
52
|
+
const record = (event: Record<string, unknown>): void => {
|
|
53
|
+
void AuditEvent.create(event).catch((error) => {
|
|
54
|
+
logger.warn('Could not record an audit event', {
|
|
55
|
+
error: (error as Error)?.message,
|
|
56
|
+
action: event.action,
|
|
57
|
+
resource: event.resource
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export interface AuditPluginOptions {
|
|
63
|
+
/** what these documents are called in a trail — 'activity', 'photo' */
|
|
64
|
+
resource: string;
|
|
65
|
+
/** fields never worth recording the content of */
|
|
66
|
+
redact?: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Attribute and record every write to a collection.
|
|
71
|
+
*
|
|
72
|
+
* Applied to a model rather than called from its controllers, for two reasons.
|
|
73
|
+
* The data layer is the only place that knows which fields actually changed —
|
|
74
|
+
* a controller sees the patch that was requested, not the difference it made,
|
|
75
|
+
* and those differ whenever a field was already the value being set. And a
|
|
76
|
+
* plugin cannot be forgotten: a new endpoint that writes through the model is
|
|
77
|
+
* audited without anyone remembering to add a line.
|
|
78
|
+
*
|
|
79
|
+
* Writes made outside a request — a migration, a backfill script — find no
|
|
80
|
+
* actor and are recorded as nothing, which is honest. A row claiming a script
|
|
81
|
+
* was a person would be worse than no row.
|
|
82
|
+
*/
|
|
83
|
+
export function auditPlugin(schema: Schema, options: AuditPluginOptions): void {
|
|
84
|
+
const { resource, redact = [] } = options;
|
|
85
|
+
const hide = new Set(redact);
|
|
86
|
+
|
|
87
|
+
const visible = (doc: Record<string, unknown>): Record<string, unknown> =>
|
|
88
|
+
Object.fromEntries(
|
|
89
|
+
Object.entries(doc)
|
|
90
|
+
.filter(([key]) => !NOISE.has(key) && !hide.has(key))
|
|
91
|
+
.map(([key, value]) => [key, plain(value)])
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Who last touched this, on the record itself. Denormalized on purpose: a day
|
|
95
|
+
// view showing "logged by Claude" should not have to query a second
|
|
96
|
+
// collection for every row it renders.
|
|
97
|
+
schema.add({
|
|
98
|
+
createdBy: { type: String },
|
|
99
|
+
updatedBy: { type: String }
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
schema.pre('save', async function () {
|
|
103
|
+
const context = currentAuditContext();
|
|
104
|
+
if (!context) return;
|
|
105
|
+
|
|
106
|
+
const label = context.actor.label;
|
|
107
|
+
if (this.isNew) this.set('createdBy', label);
|
|
108
|
+
this.set('updatedBy', label);
|
|
109
|
+
|
|
110
|
+
// `isNew` is false by the time the post hook runs, so it is carried here.
|
|
111
|
+
// Recording every save as a create was the original mistake: any code that
|
|
112
|
+
// loads a document, changes a field and saves it — revoking an API key,
|
|
113
|
+
// counting a reveal — would appear in the trail as the thing being created
|
|
114
|
+
// again, with a fresh snapshot and no sign of what actually changed.
|
|
115
|
+
this.$locals.auditWasNew = this.isNew;
|
|
116
|
+
if (this.isNew) return;
|
|
117
|
+
|
|
118
|
+
this.$locals.auditPaths = this.modifiedPaths().filter((path) => !NOISE.has(path));
|
|
119
|
+
this.$locals.auditBefore = await (this.constructor as Model<unknown>)
|
|
120
|
+
.findById(this._id)
|
|
121
|
+
.lean();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
schema.post('save', function (doc) {
|
|
125
|
+
const context = currentAuditContext();
|
|
126
|
+
if (!context) return;
|
|
127
|
+
|
|
128
|
+
const common = {
|
|
129
|
+
at: new Date(),
|
|
130
|
+
service: context.service,
|
|
131
|
+
resource,
|
|
132
|
+
resourceId: String(doc._id),
|
|
133
|
+
actorKind: context.actor.kind,
|
|
134
|
+
userId: context.actor.userId,
|
|
135
|
+
actorLabel: context.actor.label,
|
|
136
|
+
actorCredentialId: context.actor.credentialId,
|
|
137
|
+
tenant: context.actor.tenant
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (doc.$locals.auditWasNew) {
|
|
141
|
+
record({ ...common, action: 'create', snapshot: visible(doc.toObject()) });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const before = (doc.$locals.auditBefore ?? {}) as Record<string, unknown>;
|
|
146
|
+
const paths = (doc.$locals.auditPaths ?? []) as string[];
|
|
147
|
+
const changes = changesBetween(before, doc.toObject(), paths);
|
|
148
|
+
if (!changes) return;
|
|
149
|
+
|
|
150
|
+
record({ ...common, action: 'update', changes });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// The document as it stood, fetched before the write so there is something to
|
|
154
|
+
// compare against. Held on the query, which is per-operation, so concurrent
|
|
155
|
+
// updates cannot read each other's.
|
|
156
|
+
schema.pre('findOneAndUpdate', async function () {
|
|
157
|
+
const context = currentAuditContext();
|
|
158
|
+
if (!context) return;
|
|
159
|
+
|
|
160
|
+
(this as unknown as Record<symbol, unknown>)[BEFORE] = await this.model
|
|
161
|
+
.findOne(this.getQuery())
|
|
162
|
+
.lean();
|
|
163
|
+
|
|
164
|
+
// This one genuinely belongs in the update: it is a field on the document.
|
|
165
|
+
this.set('updatedBy', context.actor.label);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
schema.post('findOneAndUpdate', function (doc) {
|
|
169
|
+
const context = currentAuditContext();
|
|
170
|
+
if (!context || !doc) return;
|
|
171
|
+
|
|
172
|
+
const before = ((this as unknown as Record<symbol, unknown>)[BEFORE] ?? {}) as Record<
|
|
173
|
+
string,
|
|
174
|
+
unknown
|
|
175
|
+
>;
|
|
176
|
+
const after = doc.toObject ? doc.toObject() : (doc as Record<string, unknown>);
|
|
177
|
+
// Only the fields the update touched: comparing everything would report a
|
|
178
|
+
// change on any field a concurrent write happened to move.
|
|
179
|
+
const update = (this.getUpdate() ?? {}) as Record<string, unknown>;
|
|
180
|
+
const touched = Object.keys((update.$set as object) ?? update);
|
|
181
|
+
const changes = changesBetween(before, after, touched);
|
|
182
|
+
if (!changes) return;
|
|
183
|
+
|
|
184
|
+
record({
|
|
185
|
+
at: new Date(),
|
|
186
|
+
service: context.service,
|
|
187
|
+
action: 'update',
|
|
188
|
+
resource,
|
|
189
|
+
resourceId: String((doc as { _id: unknown })._id),
|
|
190
|
+
actorKind: context.actor.kind,
|
|
191
|
+
userId: context.actor.userId,
|
|
192
|
+
actorLabel: context.actor.label,
|
|
193
|
+
actorCredentialId: context.actor.credentialId,
|
|
194
|
+
tenant: context.actor.tenant,
|
|
195
|
+
changes
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
schema.post('findOneAndDelete', function (doc) {
|
|
200
|
+
const context = currentAuditContext();
|
|
201
|
+
if (!context || !doc) return;
|
|
202
|
+
|
|
203
|
+
record({
|
|
204
|
+
at: new Date(),
|
|
205
|
+
service: context.service,
|
|
206
|
+
action: 'delete',
|
|
207
|
+
resource,
|
|
208
|
+
resourceId: String((doc as { _id: unknown })._id),
|
|
209
|
+
actorKind: context.actor.kind,
|
|
210
|
+
userId: context.actor.userId,
|
|
211
|
+
actorLabel: context.actor.label,
|
|
212
|
+
actorCredentialId: context.actor.credentialId,
|
|
213
|
+
tenant: context.actor.tenant,
|
|
214
|
+
// The whole document, because a delete is the one action whose record is
|
|
215
|
+
// the only remaining copy of what was there.
|
|
216
|
+
snapshot: visible(doc.toObject ? doc.toObject() : (doc as Record<string, unknown>))
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Bulk deletes are read back first, one row each. A day's save deletes the
|
|
221
|
+
// entries it is replacing, and "the day editor removed three things" is
|
|
222
|
+
// exactly the change someone would come here to find.
|
|
223
|
+
schema.pre('deleteMany', async function () {
|
|
224
|
+
if (!currentAuditContext()) return;
|
|
225
|
+
(this as unknown as Record<symbol, unknown>)[BEFORE] = await this.model
|
|
226
|
+
.find(this.getQuery())
|
|
227
|
+
.lean();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
schema.post('deleteMany', function () {
|
|
231
|
+
const context = currentAuditContext();
|
|
232
|
+
if (!context) return;
|
|
233
|
+
|
|
234
|
+
const doomed = ((this as unknown as Record<symbol, unknown>)[BEFORE] ?? []) as Record<
|
|
235
|
+
string,
|
|
236
|
+
unknown
|
|
237
|
+
>[];
|
|
238
|
+
for (const doc of doomed) {
|
|
239
|
+
record({
|
|
240
|
+
at: new Date(),
|
|
241
|
+
service: context.service,
|
|
242
|
+
action: 'delete',
|
|
243
|
+
resource,
|
|
244
|
+
resourceId: String(doc._id),
|
|
245
|
+
actorKind: context.actor.kind,
|
|
246
|
+
userId: context.actor.userId,
|
|
247
|
+
actorLabel: context.actor.label,
|
|
248
|
+
actorCredentialId: context.actor.credentialId,
|
|
249
|
+
tenant: context.actor.tenant,
|
|
250
|
+
snapshot: visible(doc)
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|