@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,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
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { AuditEvent, type IAuditEvent } from './AuditEvent';
|
|
2
|
+
import type { ActorKind } from './actor';
|
|
3
|
+
|
|
4
|
+
export interface AuditQuery {
|
|
5
|
+
userId: string;
|
|
6
|
+
/** narrow to one actor — a key's id or an OAuth client id */
|
|
7
|
+
credentialId?: string;
|
|
8
|
+
actorKind?: ActorKind;
|
|
9
|
+
action?: IAuditEvent['action'];
|
|
10
|
+
resource?: string;
|
|
11
|
+
/** narrow to one area — 'relationship-service', 'album-service' */
|
|
12
|
+
service?: string;
|
|
13
|
+
from?: Date;
|
|
14
|
+
to?: Date;
|
|
15
|
+
limit?: number;
|
|
16
|
+
page?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AuditEntry {
|
|
20
|
+
id: string;
|
|
21
|
+
at: string;
|
|
22
|
+
service: string;
|
|
23
|
+
action: IAuditEvent['action'];
|
|
24
|
+
resource: string;
|
|
25
|
+
resourceId?: string;
|
|
26
|
+
actorKind: ActorKind;
|
|
27
|
+
actorLabel: string;
|
|
28
|
+
actorCredentialId?: string;
|
|
29
|
+
tenant: string;
|
|
30
|
+
changes?: Record<string, { from: unknown; to: unknown }>;
|
|
31
|
+
snapshot?: Record<string, unknown>;
|
|
32
|
+
count?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const toEntry = (event: IAuditEvent): AuditEntry => ({
|
|
36
|
+
id: String(event._id),
|
|
37
|
+
at: event.at.toISOString(),
|
|
38
|
+
service: event.service,
|
|
39
|
+
action: event.action,
|
|
40
|
+
resource: event.resource,
|
|
41
|
+
resourceId: event.resourceId,
|
|
42
|
+
actorKind: event.actorKind,
|
|
43
|
+
actorLabel: event.actorLabel,
|
|
44
|
+
actorCredentialId: event.actorCredentialId,
|
|
45
|
+
tenant: event.tenant,
|
|
46
|
+
changes: event.changes,
|
|
47
|
+
snapshot: event.snapshot,
|
|
48
|
+
count: event.count
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One account's trail, newest first.
|
|
53
|
+
*
|
|
54
|
+
* Always scoped to `userId` by the query rather than filtered afterwards, so
|
|
55
|
+
* there is no path where a wider read is one forgotten condition away. The trail
|
|
56
|
+
* carries old values of journal entries, which makes it as sensitive as the
|
|
57
|
+
* journal itself.
|
|
58
|
+
*/
|
|
59
|
+
export const listAuditEvents = async (
|
|
60
|
+
query: AuditQuery
|
|
61
|
+
): Promise<{ events: AuditEntry[]; total: number; page: number; totalPages: number }> => {
|
|
62
|
+
const limit = Math.min(query.limit ?? 50, 200);
|
|
63
|
+
const page = Math.max(query.page ?? 1, 1);
|
|
64
|
+
|
|
65
|
+
const filter: Record<string, unknown> = { userId: query.userId };
|
|
66
|
+
if (query.credentialId) filter.actorCredentialId = query.credentialId;
|
|
67
|
+
if (query.actorKind) filter.actorKind = query.actorKind;
|
|
68
|
+
if (query.action) filter.action = query.action;
|
|
69
|
+
if (query.resource) filter.resource = query.resource;
|
|
70
|
+
if (query.service) filter.service = query.service;
|
|
71
|
+
if (query.from || query.to) {
|
|
72
|
+
const at: Record<string, Date> = {};
|
|
73
|
+
if (query.from) at.$gte = query.from;
|
|
74
|
+
if (query.to) at.$lte = query.to;
|
|
75
|
+
filter.at = at;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const [events, total] = await Promise.all([
|
|
79
|
+
AuditEvent.find(filter)
|
|
80
|
+
.sort({ at: -1 })
|
|
81
|
+
.skip((page - 1) * limit)
|
|
82
|
+
.limit(limit),
|
|
83
|
+
AuditEvent.countDocuments(filter)
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
events: events.map(toEntry),
|
|
88
|
+
total,
|
|
89
|
+
page,
|
|
90
|
+
totalPages: Math.ceil(total / limit)
|
|
91
|
+
};
|
|
92
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -195,7 +195,9 @@ describe('OAuth access tokens are not sessions', () => {
|
|
|
195
195
|
scopes: ['relationship:read'],
|
|
196
196
|
tenants: [PERSONAL_TENANT],
|
|
197
197
|
defaultTenant: PERSONAL_TENANT,
|
|
198
|
-
tenantNames: {}
|
|
198
|
+
tenantNames: {},
|
|
199
|
+
clientId: 'client-1',
|
|
200
|
+
clientName: 'Claude'
|
|
199
201
|
}).accessToken;
|
|
200
202
|
|
|
201
203
|
it('refuses one on a session-only route', () => {
|
package/src/oauth/models.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import mongoose, { Document, Schema } from 'mongoose';
|
|
2
|
+
import { auditPlugin } from '../audit/plugin';
|
|
2
3
|
import { PERSONAL_TENANT, type Tenant } from '../apiKeys/types';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -148,6 +149,16 @@ const RefreshTokenSchema = new Schema<IRefreshToken>(
|
|
|
148
149
|
|
|
149
150
|
RefreshTokenSchema.index({ userId: 1, createdAt: -1 });
|
|
150
151
|
|
|
152
|
+
/**
|
|
153
|
+
* A grant appearing and being revoked is the shape of "who connected Claude,
|
|
154
|
+
* and when did it stop" — the question the connections page answers for the
|
|
155
|
+
* present and this answers for the past.
|
|
156
|
+
*
|
|
157
|
+
* `tokenHash` is redacted: it is the only thing standing between a copy of this
|
|
158
|
+
* collection and a working refresh token.
|
|
159
|
+
*/
|
|
160
|
+
RefreshTokenSchema.plugin(auditPlugin, { resource: 'connection', redact: ['tokenHash'] });
|
|
161
|
+
|
|
151
162
|
export const RefreshToken = mongoose.models.RefreshToken
|
|
152
163
|
? (mongoose.models.RefreshToken as mongoose.Model<IRefreshToken>)
|
|
153
164
|
: mongoose.model<IRefreshToken>('RefreshToken', RefreshTokenSchema);
|
package/src/oauth/tokens.test.ts
CHANGED
|
@@ -18,6 +18,8 @@ const mint = (over: Partial<Parameters<typeof mintAccessToken>[0]> = {}) =>
|
|
|
18
18
|
tenants: [PERSONAL_TENANT],
|
|
19
19
|
defaultTenant: PERSONAL_TENANT,
|
|
20
20
|
tenantNames: { [PERSONAL_TENANT]: 'My own data' },
|
|
21
|
+
clientId: 'client-1',
|
|
22
|
+
clientName: 'Claude',
|
|
21
23
|
...over
|
|
22
24
|
});
|
|
23
25
|
|
package/src/oauth/tokens.ts
CHANGED
|
@@ -49,6 +49,17 @@ export interface AccessTokenClaims {
|
|
|
49
49
|
tenantNames: Record<Tenant, string>;
|
|
50
50
|
/** the single tenant an older token was pinned to; read by `readTenants` */
|
|
51
51
|
groupId?: string | null;
|
|
52
|
+
/**
|
|
53
|
+
* Which app this token was issued to.
|
|
54
|
+
*
|
|
55
|
+
* `jti` identifies the token and rotates every hour, so it cannot stand in for
|
|
56
|
+
* the connection — an audit trail built on it would show a different actor
|
|
57
|
+
* each time the assistant refreshed. The client id is stable for the life of
|
|
58
|
+
* the grant, and the name is snapshotted alongside it for the same reason the
|
|
59
|
+
* tenant names are: nothing downstream can look it up.
|
|
60
|
+
*/
|
|
61
|
+
clientId: string;
|
|
62
|
+
clientName: string;
|
|
52
63
|
email: string;
|
|
53
64
|
name: string;
|
|
54
65
|
typ: typeof ACCESS_TOKEN_TYPE;
|
|
@@ -68,6 +79,8 @@ export interface MintAccessTokenInput {
|
|
|
68
79
|
tenants: Tenant[];
|
|
69
80
|
defaultTenant: Tenant;
|
|
70
81
|
tenantNames: Record<Tenant, string>;
|
|
82
|
+
clientId: string;
|
|
83
|
+
clientName: string;
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
export interface MintedAccessToken {
|
|
@@ -88,6 +101,8 @@ export const mintAccessToken = (input: MintAccessTokenInput): MintedAccessToken
|
|
|
88
101
|
tenants: input.tenants,
|
|
89
102
|
defaultTenant: input.defaultTenant,
|
|
90
103
|
tenantNames: input.tenantNames,
|
|
104
|
+
clientId: input.clientId,
|
|
105
|
+
clientName: input.clientName,
|
|
91
106
|
email: input.email,
|
|
92
107
|
name: input.name,
|
|
93
108
|
typ: ACCESS_TOKEN_TYPE,
|
|
@@ -109,6 +124,8 @@ export interface AccessTokenVerification {
|
|
|
109
124
|
scopes?: ApiKeyScope[];
|
|
110
125
|
tenants?: ApiKeyTenant;
|
|
111
126
|
tenantNames?: Record<Tenant, string>;
|
|
127
|
+
clientId?: string;
|
|
128
|
+
clientName?: string;
|
|
112
129
|
}
|
|
113
130
|
|
|
114
131
|
/**
|
|
@@ -144,7 +161,9 @@ export const verifyAccessToken = (
|
|
|
144
161
|
name: claims.name ?? '',
|
|
145
162
|
scopes: (claims.scope ?? '').split(' ').filter(isApiKeyScope),
|
|
146
163
|
tenants: readTenants(claims),
|
|
147
|
-
tenantNames: claims.tenantNames ?? {}
|
|
164
|
+
tenantNames: claims.tenantNames ?? {},
|
|
165
|
+
clientId: claims.clientId,
|
|
166
|
+
clientName: claims.clientName
|
|
148
167
|
};
|
|
149
168
|
};
|
|
150
169
|
|