@tumbaland/backend-core 1.38.0 → 1.40.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 +82 -0
- package/dist/audit/AuditEvent.d.ts.map +1 -0
- package/dist/audit/AuditEvent.js +77 -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 +51 -0
- package/dist/audit/service.d.ts.map +1 -0
- package/dist/audit/service.js +66 -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 +123 -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 +95 -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,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
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
2
|
+
import { AuditEvent } from './AuditEvent';
|
|
3
|
+
import { actorOf, type Actor } from './actor';
|
|
4
|
+
import logger from '../logging/logger';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Record what software read, and only what software read.
|
|
8
|
+
*
|
|
9
|
+
* A person browsing their own journal generates nothing here. That is not a
|
|
10
|
+
* shortcut: one dashboard load hits half a dozen endpoints, so logging it would
|
|
11
|
+
* bury the interesting rows under thousands of uninteresting ones and put a
|
|
12
|
+
* database write behind every page view.
|
|
13
|
+
*
|
|
14
|
+
* What an assistant read is the part worth keeping, because it is the part that
|
|
15
|
+
* left the server. A created entry leaves a record behind either way; a read
|
|
16
|
+
* leaves nothing at all, and it is exactly what the privacy page warns about —
|
|
17
|
+
* ask an assistant what you did last month and those entries go to whoever runs
|
|
18
|
+
* it. This is the only place that becomes visible.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Arm the read trail for one request, if it is one worth recording.
|
|
22
|
+
*
|
|
23
|
+
* Nothing is written here — the listener fires when the response finishes, so a
|
|
24
|
+
* refused request is not recorded as a read and the count reflects what was
|
|
25
|
+
* actually sent.
|
|
26
|
+
*/
|
|
27
|
+
export function recordRead(req: Request, res: Response, actor: Actor, service: string): void {
|
|
28
|
+
if (req.method !== 'GET' || actor.kind === 'person') return;
|
|
29
|
+
|
|
30
|
+
res.on('finish', () => {
|
|
31
|
+
if (res.statusCode >= 400) return;
|
|
32
|
+
|
|
33
|
+
void AuditEvent.create({
|
|
34
|
+
at: new Date(),
|
|
35
|
+
service,
|
|
36
|
+
action: 'read',
|
|
37
|
+
resource: resourceOf(req.baseUrl + req.path),
|
|
38
|
+
actorKind: actor.kind,
|
|
39
|
+
userId: actor.userId,
|
|
40
|
+
actorLabel: actor.label,
|
|
41
|
+
actorCredentialId: actor.credentialId,
|
|
42
|
+
tenant: actor.tenant,
|
|
43
|
+
count: res.locals.auditCount as number | undefined
|
|
44
|
+
}).catch((error) => {
|
|
45
|
+
logger.warn('Could not record a read', { error: (error as Error)?.message });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The same thing as standalone middleware, for anything that authenticates its
|
|
52
|
+
* own way rather than through `authenticateAgent`.
|
|
53
|
+
*/
|
|
54
|
+
export const auditReads =
|
|
55
|
+
(service: string): RequestHandler =>
|
|
56
|
+
(req: Request, res: Response, next: NextFunction): void => {
|
|
57
|
+
const actor = actorOf(req);
|
|
58
|
+
if (actor) recordRead(req, res, actor, service);
|
|
59
|
+
next();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What a path was asking for, in one word.
|
|
64
|
+
*
|
|
65
|
+
* The first path segment, which is the collection on every route here —
|
|
66
|
+
* `/api/activities/68d6…` is about activities. Deliberately crude: a trail
|
|
67
|
+
* naming the resource is readable, and one naming the full path with ids in it
|
|
68
|
+
* is a request log wearing an audit log's clothes.
|
|
69
|
+
*/
|
|
70
|
+
export function resourceOf(path: string): string {
|
|
71
|
+
const segments = path.split('/').filter((segment) => segment && segment !== 'api');
|
|
72
|
+
return segments[0]?.replace(/-/g, '_') ?? 'unknown';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How many records a response carried.
|
|
77
|
+
*
|
|
78
|
+
* Set by a handler that knows; absent otherwise. The size of what left the
|
|
79
|
+
* server is most of what makes a read worth recording — "read the journal" and
|
|
80
|
+
* "read four hundred entries of the journal" are different events.
|
|
81
|
+
*/
|
|
82
|
+
export const countRead = (res: Response, count: number): void => {
|
|
83
|
+
// Guarded for the same reason recording is fire-and-forget: instrumentation
|
|
84
|
+
// must not be able to fail the thing it instruments. Express always provides
|
|
85
|
+
// `locals`, but a handler called directly does not have to.
|
|
86
|
+
if (!res?.locals) return;
|
|
87
|
+
res.locals.auditCount = count;
|
|
88
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
import { MongoMemoryServer } from 'mongodb-memory-server';
|
|
3
|
+
|
|
4
|
+
import { AuditEvent } from './AuditEvent';
|
|
5
|
+
import { listAuditEvents } from './service';
|
|
6
|
+
|
|
7
|
+
let mongo: MongoMemoryServer;
|
|
8
|
+
|
|
9
|
+
const event = (over: Record<string, unknown> = {}) => ({
|
|
10
|
+
at: new Date('2026-09-05T10:00:00Z'),
|
|
11
|
+
service: 'relationship-service',
|
|
12
|
+
action: 'create' as const,
|
|
13
|
+
resource: 'activity',
|
|
14
|
+
actorKind: 'assistant' as const,
|
|
15
|
+
userId: 'u1',
|
|
16
|
+
actorLabel: 'Claude',
|
|
17
|
+
actorCredentialId: 'client-1',
|
|
18
|
+
tenant: 'g1',
|
|
19
|
+
...over
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
mongo = await MongoMemoryServer.create();
|
|
24
|
+
await mongoose.connect(mongo.getUri());
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterAll(async () => {
|
|
28
|
+
await mongoose.disconnect();
|
|
29
|
+
await mongo.stop();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
beforeEach(() => AuditEvent.deleteMany({}));
|
|
33
|
+
|
|
34
|
+
describe('listAuditEvents', () => {
|
|
35
|
+
it('returns one account’s trail and never anyone else’s', async () => {
|
|
36
|
+
await AuditEvent.insertMany([event(), event({ userId: 'u2', actorLabel: 'Someone else' })]);
|
|
37
|
+
|
|
38
|
+
const { events, total } = await listAuditEvents({ userId: 'u1' });
|
|
39
|
+
|
|
40
|
+
// Scoped by the query rather than filtered afterwards: the trail carries old
|
|
41
|
+
// values of journal entries, so a wider read must not be one forgotten
|
|
42
|
+
// condition away.
|
|
43
|
+
expect(total).toBe(1);
|
|
44
|
+
expect(events[0].actorLabel).toBe('Claude');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns newest first, which is the only order anyone reads a trail in', async () => {
|
|
48
|
+
await AuditEvent.insertMany([
|
|
49
|
+
event({ at: new Date('2026-09-01T10:00:00Z'), resource: 'older' }),
|
|
50
|
+
event({ at: new Date('2026-09-05T10:00:00Z'), resource: 'newer' })
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const { events } = await listAuditEvents({ userId: 'u1' });
|
|
54
|
+
expect(events.map((e) => e.resource)).toEqual(['newer', 'older']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('narrows to one assistant, which is the question people actually ask', async () => {
|
|
58
|
+
await AuditEvent.insertMany([
|
|
59
|
+
event({ actorCredentialId: 'client-1', actorLabel: 'Claude' }),
|
|
60
|
+
event({ actorCredentialId: 'client-2', actorLabel: 'ChatGPT' })
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const { events } = await listAuditEvents({ userId: 'u1', credentialId: 'client-1' });
|
|
64
|
+
|
|
65
|
+
expect(events).toHaveLength(1);
|
|
66
|
+
expect(events[0].actorLabel).toBe('Claude');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('separates what software did from what a person did', async () => {
|
|
70
|
+
await AuditEvent.insertMany([
|
|
71
|
+
event({ actorKind: 'assistant' }),
|
|
72
|
+
event({ actorKind: 'person', actorLabel: 'Tom' })
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const { events } = await listAuditEvents({ userId: 'u1', actorKind: 'person' });
|
|
76
|
+
expect(events.map((e) => e.actorLabel)).toEqual(['Tom']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('narrows by action and by date range', async () => {
|
|
80
|
+
await AuditEvent.insertMany([
|
|
81
|
+
event({ action: 'delete', at: new Date('2026-09-01T10:00:00Z') }),
|
|
82
|
+
event({ action: 'delete', at: new Date('2026-09-05T10:00:00Z') }),
|
|
83
|
+
event({ action: 'read', at: new Date('2026-09-05T11:00:00Z') })
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const { events } = await listAuditEvents({
|
|
87
|
+
userId: 'u1',
|
|
88
|
+
action: 'delete',
|
|
89
|
+
from: new Date('2026-09-03T00:00:00Z')
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(events).toHaveLength(1);
|
|
93
|
+
expect(events[0].at).toBe('2026-09-05T10:00:00.000Z');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('hands back the change detail, which is the reason to look', async () => {
|
|
97
|
+
await AuditEvent.insertMany([event({ action: 'update', changes: { hours: { from: 3, to: 2 } } })]);
|
|
98
|
+
|
|
99
|
+
const { events } = await listAuditEvents({ userId: 'u1' });
|
|
100
|
+
expect(events[0].changes).toEqual({ hours: { from: 3, to: 2 } });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('pages, and caps a caller asking for everything at once', async () => {
|
|
104
|
+
await AuditEvent.insertMany(Array.from({ length: 5 }, (_, i) => event({ resource: `r${i}` })));
|
|
105
|
+
|
|
106
|
+
const { events, total, totalPages } = await listAuditEvents({ userId: 'u1', limit: 2, page: 2 });
|
|
107
|
+
|
|
108
|
+
expect(events).toHaveLength(2);
|
|
109
|
+
expect(total).toBe(5);
|
|
110
|
+
expect(totalPages).toBe(3);
|
|
111
|
+
|
|
112
|
+
const capped = await listAuditEvents({ userId: 'u1', limit: 5000 });
|
|
113
|
+
expect(capped.events.length).toBeLessThanOrEqual(200);
|
|
114
|
+
});
|
|
115
|
+
});
|