@tumbaland/backend-core 1.37.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 +23 -3
- package/dist/apiKeys/ApiKey.d.ts.map +1 -1
- package/dist/apiKeys/ApiKey.js +19 -2
- package/dist/apiKeys/ApiKey.js.map +1 -1
- package/dist/apiKeys/index.d.ts +2 -2
- package/dist/apiKeys/index.d.ts.map +1 -1
- package/dist/apiKeys/index.js +5 -1
- package/dist/apiKeys/index.js.map +1 -1
- package/dist/apiKeys/middleware.d.ts +17 -3
- package/dist/apiKeys/middleware.d.ts.map +1 -1
- package/dist/apiKeys/middleware.js +71 -34
- package/dist/apiKeys/middleware.js.map +1 -1
- package/dist/apiKeys/service.d.ts +5 -3
- package/dist/apiKeys/service.d.ts.map +1 -1
- package/dist/apiKeys/service.js +11 -3
- package/dist/apiKeys/service.js.map +1 -1
- package/dist/apiKeys/types.d.ts +43 -8
- package/dist/apiKeys/types.d.ts.map +1 -1
- package/dist/apiKeys/types.js +35 -1
- package/dist/apiKeys/types.js.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 +13 -2
- package/dist/oauth/models.d.ts.map +1 -1
- package/dist/oauth/models.js +19 -0
- package/dist/oauth/models.js.map +1 -1
- package/dist/oauth/service.d.ts +15 -8
- package/dist/oauth/service.d.ts.map +1 -1
- package/dist/oauth/service.js +10 -3
- package/dist/oauth/service.js.map +1 -1
- package/dist/oauth/tokens.d.ts +37 -5
- package/dist/oauth/tokens.d.ts.map +1 -1
- package/dist/oauth/tokens.js +9 -2
- 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 +31 -4
- package/src/apiKeys/index.ts +16 -2
- package/src/apiKeys/middleware.test.ts +126 -20
- package/src/apiKeys/middleware.ts +99 -39
- package/src/apiKeys/service.test.ts +58 -9
- package/src/apiKeys/service.ts +25 -6
- package/src/apiKeys/types.ts +63 -8
- 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 +6 -1
- package/src/oauth/models.ts +32 -2
- package/src/oauth/service.test.ts +58 -6
- package/src/oauth/service.ts +25 -7
- package/src/oauth/tokens.test.ts +22 -5
- package/src/oauth/tokens.ts +52 -7
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Request, Response } from 'express';
|
|
2
|
+
|
|
3
|
+
jest.mock('./AuditEvent', () => ({ AuditEvent: { create: jest.fn().mockResolvedValue({}) } }));
|
|
4
|
+
jest.mock('../logging/logger', () => ({
|
|
5
|
+
__esModule: true,
|
|
6
|
+
default: { warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() }
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import { AuditEvent } from './AuditEvent';
|
|
10
|
+
import { auditReads, countRead, resourceOf } from './reads';
|
|
11
|
+
import { PERSONAL_TENANT } from '../apiKeys/types';
|
|
12
|
+
|
|
13
|
+
const created = AuditEvent.create as jest.Mock;
|
|
14
|
+
|
|
15
|
+
/** A response that runs its finish listeners when told to. */
|
|
16
|
+
function mockRes(statusCode = 200) {
|
|
17
|
+
const listeners: (() => void)[] = [];
|
|
18
|
+
const res = {
|
|
19
|
+
statusCode,
|
|
20
|
+
locals: {} as Record<string, unknown>,
|
|
21
|
+
on: (event: string, fn: () => void) => {
|
|
22
|
+
if (event === 'finish') listeners.push(fn);
|
|
23
|
+
return res;
|
|
24
|
+
},
|
|
25
|
+
finish: () => listeners.forEach((fn) => fn())
|
|
26
|
+
};
|
|
27
|
+
return res as unknown as Response & { finish: () => void };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const req = (over: Partial<Request> = {}): Request =>
|
|
31
|
+
({ method: 'GET', baseUrl: '/api/activities', path: '/', query: {}, ...over }) as unknown as Request;
|
|
32
|
+
|
|
33
|
+
const assistant = {
|
|
34
|
+
keyId: 'client-1',
|
|
35
|
+
scopes: [],
|
|
36
|
+
tenants: { allowed: ['g1'], default: 'g1' },
|
|
37
|
+
actingAs: 'g1',
|
|
38
|
+
kind: 'oauth' as const,
|
|
39
|
+
label: 'Claude',
|
|
40
|
+
credentialId: 'client-1'
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const user = { id: 'u1', email: 'tom@example.com', name: 'Tom' };
|
|
44
|
+
|
|
45
|
+
beforeEach(() => jest.clearAllMocks());
|
|
46
|
+
|
|
47
|
+
describe('auditReads', () => {
|
|
48
|
+
it('records what an assistant read', async () => {
|
|
49
|
+
const res = mockRes();
|
|
50
|
+
const request = req({ user, apiKey: assistant });
|
|
51
|
+
|
|
52
|
+
auditReads('relationship-service')(request, res, jest.fn());
|
|
53
|
+
countRead(res, 47);
|
|
54
|
+
res.finish();
|
|
55
|
+
|
|
56
|
+
expect(created).toHaveBeenCalledWith(
|
|
57
|
+
expect.objectContaining({
|
|
58
|
+
action: 'read',
|
|
59
|
+
resource: 'activities',
|
|
60
|
+
actorKind: 'assistant',
|
|
61
|
+
actorLabel: 'Claude',
|
|
62
|
+
tenant: 'g1',
|
|
63
|
+
// The size of what left the server is most of what makes a read worth
|
|
64
|
+
// keeping: "read the journal" and "read 47 entries" are different events.
|
|
65
|
+
count: 47
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('records nothing for a person browsing their own journal', async () => {
|
|
71
|
+
// One dashboard load hits half a dozen endpoints. Logging those would bury
|
|
72
|
+
// the interesting rows and put a database write behind every page view.
|
|
73
|
+
const res = mockRes();
|
|
74
|
+
|
|
75
|
+
auditReads('relationship-service')(req({ user }), res, jest.fn());
|
|
76
|
+
res.finish();
|
|
77
|
+
|
|
78
|
+
expect(created).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('records nothing for a write, which the model plugin already covers', () => {
|
|
82
|
+
const res = mockRes();
|
|
83
|
+
auditReads('relationship-service')(req({ user, apiKey: assistant, method: 'POST' }), res, jest.fn());
|
|
84
|
+
res.finish();
|
|
85
|
+
|
|
86
|
+
expect(created).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('records nothing for a refused request', () => {
|
|
90
|
+
// A 403 is not a read: nothing left the server.
|
|
91
|
+
const res = mockRes(403);
|
|
92
|
+
auditReads('relationship-service')(req({ user, apiKey: assistant }), res, jest.fn());
|
|
93
|
+
res.finish();
|
|
94
|
+
|
|
95
|
+
expect(created).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('always continues the request, whether or not it recorded anything', () => {
|
|
99
|
+
const next = jest.fn();
|
|
100
|
+
auditReads('relationship-service')(req(), mockRes(), next);
|
|
101
|
+
expect(next).toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('does not fail the request when the trail cannot be written', async () => {
|
|
105
|
+
created.mockRejectedValueOnce(new Error('audit db down'));
|
|
106
|
+
const res = mockRes();
|
|
107
|
+
|
|
108
|
+
auditReads('relationship-service')(req({ user, apiKey: assistant }), res, jest.fn());
|
|
109
|
+
expect(() => res.finish()).not.toThrow();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('survives a handler that never counted anything', () => {
|
|
113
|
+
const res = mockRes();
|
|
114
|
+
auditReads('relationship-service')(req({ user, apiKey: assistant }), res, jest.fn());
|
|
115
|
+
res.finish();
|
|
116
|
+
|
|
117
|
+
expect(created).toHaveBeenCalledWith(expect.objectContaining({ count: undefined }));
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('resourceOf', () => {
|
|
122
|
+
it.each([
|
|
123
|
+
['/api/activities/', 'activities'],
|
|
124
|
+
['/api/activities/68d6d7edfb26c947ecaa057a', 'activities'],
|
|
125
|
+
['/api/activity-types/', 'activity_types'],
|
|
126
|
+
['/api/statistics/trends', 'statistics']
|
|
127
|
+
])('reads %s as %s', (path, expected) => {
|
|
128
|
+
// Deliberately crude: a trail naming the resource is readable, and one
|
|
129
|
+
// naming the full path with ids in it is a request log in disguise.
|
|
130
|
+
expect(resourceOf(path)).toBe(expected);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('names something rather than nothing for an unrecognisable path', () => {
|
|
134
|
+
expect(resourceOf('/')).toBe('unknown');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('countRead', () => {
|
|
139
|
+
it('cannot break the request it is instrumenting', () => {
|
|
140
|
+
// Express always provides `locals`; a handler called directly does not.
|
|
141
|
+
expect(() => countRead({} as Response, 3)).not.toThrow();
|
|
142
|
+
expect(() => countRead(undefined as unknown as Response, 3)).not.toThrow();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('leaves the count where the middleware will find it', () => {
|
|
146
|
+
const res = { locals: {} } as Response;
|
|
147
|
+
countRead(res, 12);
|
|
148
|
+
expect(res.locals.auditCount).toBe(12);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('PERSONAL_TENANT', () => {
|
|
153
|
+
it('is what a personal-tenant read is recorded against', () => {
|
|
154
|
+
const res = mockRes();
|
|
155
|
+
auditReads('relationship-service')(
|
|
156
|
+
req({ user, apiKey: { ...assistant, actingAs: PERSONAL_TENANT } }),
|
|
157
|
+
res,
|
|
158
|
+
jest.fn()
|
|
159
|
+
);
|
|
160
|
+
res.finish();
|
|
161
|
+
|
|
162
|
+
expect(created).toHaveBeenCalledWith(expect.objectContaining({ tenant: PERSONAL_TENANT }));
|
|
163
|
+
});
|
|
164
|
+
});
|