@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
package/src/apiKeys/types.ts
CHANGED
|
@@ -24,17 +24,69 @@ export const isApiKeyScope = (value: unknown): value is ApiKeyScope =>
|
|
|
24
24
|
typeof value === 'string' && (API_KEY_SCOPES as readonly string[]).includes(value);
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
27
|
+
* A tenant a credential may act in: a group id, or the user's own data.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* Personal is a named sentinel rather than `null` so that a tenant is always a
|
|
30
|
+
* plain string — storable in an array, sendable in a JWT claim, comparable
|
|
31
|
+
* without a special case at every layer. It cannot collide with a real tenant:
|
|
32
|
+
* group ids are 24-character hex.
|
|
33
|
+
*/
|
|
34
|
+
export const PERSONAL_TENANT = 'personal';
|
|
35
|
+
|
|
36
|
+
export type Tenant = string;
|
|
37
|
+
|
|
38
|
+
/** The `groupId` a tenant corresponds to, as handlers have always read it. */
|
|
39
|
+
export const groupIdOf = (tenant: Tenant): string | null =>
|
|
40
|
+
tenant === PERSONAL_TENANT ? null : tenant;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Which tenants a credential may act in, and which one it acts in by default.
|
|
44
|
+
*
|
|
45
|
+
* The web app picks a tenant per request from a selector; an agent has no such
|
|
46
|
+
* UI. So the *set* is chosen once, by a person, and the credential cannot escape
|
|
47
|
+
* it — but within that set the caller may name one per request, which is what
|
|
48
|
+
* lets a single connection reach both a shared journal and a private photo
|
|
49
|
+
* library without reconnecting.
|
|
50
|
+
*
|
|
51
|
+
* `allowed` is never empty and always contains `default`. A credential granted
|
|
52
|
+
* exactly one tenant behaves precisely as a pinned one did: nothing to name,
|
|
53
|
+
* nothing to get wrong.
|
|
33
54
|
*/
|
|
34
55
|
export interface ApiKeyTenant {
|
|
35
|
-
|
|
56
|
+
allowed: Tenant[];
|
|
57
|
+
default: Tenant;
|
|
36
58
|
}
|
|
37
59
|
|
|
60
|
+
/** Just the groups, for the `req.userGroups` every handler already reads. */
|
|
61
|
+
export const groupIdsOf = (tenants: Tenant[]): string[] =>
|
|
62
|
+
tenants.filter((tenant) => tenant !== PERSONAL_TENANT);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read a stored tenant grant, tolerating one written before tenants were a set.
|
|
66
|
+
*
|
|
67
|
+
* Keys and grants issued under the old single-tenant model carry `groupId`
|
|
68
|
+
* alone. Falling back to it here means they keep working across the deploy
|
|
69
|
+
* rather than every connected assistant breaking at once.
|
|
70
|
+
*/
|
|
71
|
+
export const readTenants = (stored: {
|
|
72
|
+
tenants?: Tenant[] | null;
|
|
73
|
+
defaultTenant?: Tenant | null;
|
|
74
|
+
groupId?: string | null;
|
|
75
|
+
}): ApiKeyTenant => {
|
|
76
|
+
const allowed =
|
|
77
|
+
stored.tenants && stored.tenants.length > 0
|
|
78
|
+
? stored.tenants
|
|
79
|
+
: [stored.groupId ?? PERSONAL_TENANT];
|
|
80
|
+
|
|
81
|
+
const fallbackDefault = allowed[0] as Tenant;
|
|
82
|
+
const preferred = stored.defaultTenant ?? fallbackDefault;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
allowed,
|
|
86
|
+
default: allowed.includes(preferred) ? preferred : fallbackDefault
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
38
90
|
/** A key as the API hands it back — never including the secret. */
|
|
39
91
|
export interface ApiKeySummary {
|
|
40
92
|
id: string;
|
|
@@ -42,7 +94,8 @@ export interface ApiKeySummary {
|
|
|
42
94
|
/** the public half, shown in listings so a key is identifiable at a glance */
|
|
43
95
|
prefix: string;
|
|
44
96
|
scopes: ApiKeyScope[];
|
|
45
|
-
|
|
97
|
+
tenants: Tenant[];
|
|
98
|
+
defaultTenant: Tenant;
|
|
46
99
|
createdAt: string;
|
|
47
100
|
lastUsedAt: string | null;
|
|
48
101
|
expiresAt: string | null;
|
|
@@ -64,6 +117,8 @@ export interface ApiKeyVerification {
|
|
|
64
117
|
userEmail?: string;
|
|
65
118
|
userName?: string;
|
|
66
119
|
keyId?: string;
|
|
120
|
+
/** what the owner called this key, for an audit trail a person can read */
|
|
121
|
+
label?: string;
|
|
67
122
|
scopes?: ApiKeyScope[];
|
|
68
|
-
|
|
123
|
+
tenants?: ApiKeyTenant;
|
|
69
124
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import mongoose, { Document, Schema } from 'mongoose';
|
|
2
|
+
import type { ActorKind } from './actor';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One thing that happened, and who did it.
|
|
6
|
+
*
|
|
7
|
+
* Lives in backend-core rather than any one service because the question it
|
|
8
|
+
* answers spans them: "what did Claude do yesterday" is not a relationship
|
|
9
|
+
* question or an album question. Every service writes into the same collection,
|
|
10
|
+
* and `service` says which one.
|
|
11
|
+
*/
|
|
12
|
+
export interface IAuditEvent extends Document {
|
|
13
|
+
at: Date;
|
|
14
|
+
/** which service handled it — 'relationship-service', 'album-service' */
|
|
15
|
+
service: string;
|
|
16
|
+
action: 'create' | 'update' | 'delete' | 'read';
|
|
17
|
+
/** what kind of thing — 'activity', 'activity_type', 'photo' */
|
|
18
|
+
resource: string;
|
|
19
|
+
resourceId?: string;
|
|
20
|
+
actorKind: ActorKind;
|
|
21
|
+
/** the account acted for; every listing is scoped to this */
|
|
22
|
+
userId: string;
|
|
23
|
+
actorLabel: string;
|
|
24
|
+
/** a key's id or an OAuth client id; absent when a person acted directly */
|
|
25
|
+
actorCredentialId?: string;
|
|
26
|
+
/** the journal it happened in */
|
|
27
|
+
tenant: string;
|
|
28
|
+
/**
|
|
29
|
+
* What changed, field by field.
|
|
30
|
+
*
|
|
31
|
+
* Present on updates. This is the difference between an audit log and a
|
|
32
|
+
* request log: "Claude edited an entry" is barely worth storing, "Claude
|
|
33
|
+
* changed hours from 3 to 2" is the thing someone actually wants to see.
|
|
34
|
+
*/
|
|
35
|
+
changes?: Record<string, { from: unknown; to: unknown }>;
|
|
36
|
+
/** what a delete removed, so it can be read back or restored by hand */
|
|
37
|
+
snapshot?: Record<string, unknown>;
|
|
38
|
+
/** how many records a read returned — the size of what left the server */
|
|
39
|
+
count?: number;
|
|
40
|
+
createdAt: Date;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const AuditEventSchema = new Schema<IAuditEvent>(
|
|
44
|
+
{
|
|
45
|
+
at: { type: Date, required: true, default: Date.now },
|
|
46
|
+
service: { type: String, required: true },
|
|
47
|
+
action: { type: String, required: true, enum: ['create', 'update', 'delete', 'read'] },
|
|
48
|
+
resource: { type: String, required: true },
|
|
49
|
+
resourceId: { type: String },
|
|
50
|
+
actorKind: { type: String, required: true, enum: ['person', 'key', 'assistant'] },
|
|
51
|
+
userId: { type: String, required: true, index: true },
|
|
52
|
+
actorLabel: { type: String, required: true },
|
|
53
|
+
actorCredentialId: { type: String },
|
|
54
|
+
tenant: { type: String, required: true },
|
|
55
|
+
changes: { type: Schema.Types.Mixed },
|
|
56
|
+
snapshot: { type: Schema.Types.Mixed },
|
|
57
|
+
count: { type: Number }
|
|
58
|
+
},
|
|
59
|
+
{ timestamps: { createdAt: true, updatedAt: false }, collection: 'audit_events' }
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Every listing is one account's trail, newest first.
|
|
63
|
+
AuditEventSchema.index({ userId: 1, at: -1 });
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Rows expire on their own.
|
|
67
|
+
*
|
|
68
|
+
* They carry journal content — the old value of a description, a deleted
|
|
69
|
+
* entry — so keeping them indefinitely would quietly build a second copy of the
|
|
70
|
+
* journal that nothing in the product ever deletes from. A year is long enough
|
|
71
|
+
* to answer "what happened" and short enough that the copy does not outlive the
|
|
72
|
+
* question.
|
|
73
|
+
*/
|
|
74
|
+
const RETENTION_DAYS = 365;
|
|
75
|
+
AuditEventSchema.index({ at: 1 }, { expireAfterSeconds: RETENTION_DAYS * 24 * 60 * 60 });
|
|
76
|
+
|
|
77
|
+
export const AuditEvent = mongoose.models.AuditEvent
|
|
78
|
+
? (mongoose.models.AuditEvent as mongoose.Model<IAuditEvent>)
|
|
79
|
+
: mongoose.model<IAuditEvent>('AuditEvent', AuditEventSchema);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Request } from 'express';
|
|
2
|
+
import { actorOf, describeActor } from './actor';
|
|
3
|
+
import { PERSONAL_TENANT } from '../apiKeys/types';
|
|
4
|
+
|
|
5
|
+
const req = (over: Partial<Request> = {}): Request =>
|
|
6
|
+
({ query: {}, body: {}, headers: {}, ...over }) as unknown as Request;
|
|
7
|
+
|
|
8
|
+
const grant = { allowed: [PERSONAL_TENANT], default: PERSONAL_TENANT };
|
|
9
|
+
|
|
10
|
+
describe('actorOf', () => {
|
|
11
|
+
it('reads a session as the person themselves', () => {
|
|
12
|
+
const actor = actorOf(req({ user: { id: 'u1', email: 'tom@example.com', name: 'Tom' } }));
|
|
13
|
+
|
|
14
|
+
expect(actor).toMatchObject({ kind: 'person', userId: 'u1', label: 'Tom' });
|
|
15
|
+
// Nothing to revoke and nothing that rotates: a person is not a credential.
|
|
16
|
+
expect(actor?.credentialId).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('falls back to the email when a session carries no name', () => {
|
|
20
|
+
// An unlabelled row is the one nobody can act on.
|
|
21
|
+
const actor = actorOf(req({ user: { id: 'u1', email: 'tom@example.com', name: '' } }));
|
|
22
|
+
expect(actor?.label).toBe('tom@example.com');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('names an API key by what its owner called it', () => {
|
|
26
|
+
const actor = actorOf(
|
|
27
|
+
req({
|
|
28
|
+
user: { id: 'u1', email: 'tom@example.com', name: 'Tom' },
|
|
29
|
+
apiKey: {
|
|
30
|
+
keyId: 'k1',
|
|
31
|
+
scopes: [],
|
|
32
|
+
tenants: grant,
|
|
33
|
+
actingAs: PERSONAL_TENANT,
|
|
34
|
+
kind: 'api_key',
|
|
35
|
+
label: 'Claude Code',
|
|
36
|
+
credentialId: 'k1'
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(actor).toMatchObject({ kind: 'key', label: 'Claude Code', credentialId: 'k1' });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('names a connected assistant by its client, not its token', () => {
|
|
45
|
+
const actor = actorOf(
|
|
46
|
+
req({
|
|
47
|
+
user: { id: 'u1', email: 'tom@example.com', name: 'Tom' },
|
|
48
|
+
apiKey: {
|
|
49
|
+
keyId: 'client-1',
|
|
50
|
+
scopes: [],
|
|
51
|
+
tenants: { allowed: ['g1'], default: 'g1' },
|
|
52
|
+
actingAs: 'g1',
|
|
53
|
+
kind: 'oauth',
|
|
54
|
+
label: 'Claude',
|
|
55
|
+
credentialId: 'client-1'
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// An access token's `jti` rotates hourly; a trail built on it would show a
|
|
61
|
+
// different actor after every refresh.
|
|
62
|
+
expect(actor).toMatchObject({ kind: 'assistant', label: 'Claude', credentialId: 'client-1' });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('records the journal the request is acting in', () => {
|
|
66
|
+
const actor = actorOf(
|
|
67
|
+
req({
|
|
68
|
+
user: { id: 'u1', email: 'a@b.c', name: 'Tom' },
|
|
69
|
+
apiKey: {
|
|
70
|
+
keyId: 'c1',
|
|
71
|
+
scopes: [],
|
|
72
|
+
tenants: { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' },
|
|
73
|
+
actingAs: 'g1',
|
|
74
|
+
kind: 'oauth',
|
|
75
|
+
label: 'Claude',
|
|
76
|
+
credentialId: 'c1'
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(actor?.tenant).toBe('g1');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('has nothing to attribute when nobody is authenticated', () => {
|
|
85
|
+
// A row about the transport is not a row about anyone.
|
|
86
|
+
expect(actorOf(req())).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('describes software so it is not mistaken for a person', () => {
|
|
90
|
+
expect(describeActor({ kind: 'person', userId: 'u1', label: 'Tom', tenant: PERSONAL_TENANT })).toBe('Tom');
|
|
91
|
+
expect(
|
|
92
|
+
describeActor({ kind: 'assistant', userId: 'u1', label: 'Claude', tenant: 'g1' })
|
|
93
|
+
).toBe('Claude (assistant)');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Request } from 'express';
|
|
2
|
+
import { PERSONAL_TENANT, type Tenant } from '../apiKeys/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Who is acting, in the only three ways anything reaches this system.
|
|
6
|
+
*
|
|
7
|
+
* The distinction that matters is `person` against everything else. A person is
|
|
8
|
+
* at a keyboard and can see what they are doing; a key or an assistant is
|
|
9
|
+
* software acting on their behalf, and is the thing an audit trail exists to
|
|
10
|
+
* make legible. Splitting `key` from `assistant` is worth the extra case because
|
|
11
|
+
* they fail differently: a key is a secret someone pasted somewhere and may have
|
|
12
|
+
* lost, an assistant is a consent screen someone approved and can withdraw.
|
|
13
|
+
*/
|
|
14
|
+
export type ActorKind = 'person' | 'key' | 'assistant';
|
|
15
|
+
|
|
16
|
+
export interface Actor {
|
|
17
|
+
kind: ActorKind;
|
|
18
|
+
/** the account being acted for — the same whichever kind of actor it is */
|
|
19
|
+
userId: string;
|
|
20
|
+
/** what to show in a trail: the person's name, the key's name, the app's name */
|
|
21
|
+
label: string;
|
|
22
|
+
/**
|
|
23
|
+
* The credential, for software.
|
|
24
|
+
*
|
|
25
|
+
* A key's document id, or an OAuth client id. Deliberately not the access
|
|
26
|
+
* token's `jti`, which rotates hourly and would show a different actor after
|
|
27
|
+
* every refresh.
|
|
28
|
+
*/
|
|
29
|
+
credentialId?: string;
|
|
30
|
+
/** the journal this request is acting in */
|
|
31
|
+
tenant: Tenant;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read the actor off a request that has already been authenticated.
|
|
36
|
+
*
|
|
37
|
+
* Returns null for an unauthenticated request rather than inventing an
|
|
38
|
+
* anonymous actor: there is nothing to attribute, and a row saying so would be
|
|
39
|
+
* a row about the transport, not about anyone.
|
|
40
|
+
*/
|
|
41
|
+
export function actorOf(req: Request): Actor | null {
|
|
42
|
+
const user = req.user;
|
|
43
|
+
if (!user?.id) return null;
|
|
44
|
+
|
|
45
|
+
const credential = req.apiKey;
|
|
46
|
+
if (!credential) {
|
|
47
|
+
return {
|
|
48
|
+
kind: 'person',
|
|
49
|
+
userId: user.id,
|
|
50
|
+
// Falling back to the email because a name is optional on a session and an
|
|
51
|
+
// unlabelled row is the one nobody can act on.
|
|
52
|
+
label: user.name || user.email || user.id,
|
|
53
|
+
tenant: (req.query?.groupId as string) || PERSONAL_TENANT
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
kind: credential.kind === 'oauth' ? 'assistant' : 'key',
|
|
59
|
+
userId: user.id,
|
|
60
|
+
label: credential.label,
|
|
61
|
+
credentialId: credential.credentialId,
|
|
62
|
+
tenant: credential.actingAs
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One line naming an actor, for a log message or an error. */
|
|
67
|
+
export const describeActor = (actor: Actor): string =>
|
|
68
|
+
actor.kind === 'person' ? actor.label : `${actor.label} (${actor.kind})`;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Request, Response } from 'express';
|
|
2
|
+
|
|
3
|
+
jest.mock('./reads', () => ({ recordRead: jest.fn() }));
|
|
4
|
+
|
|
5
|
+
import { recordRead } from './reads';
|
|
6
|
+
import { beginAudit, currentAuditContext, withAuditContext } from './context';
|
|
7
|
+
import { PERSONAL_TENANT } from '../apiKeys/types';
|
|
8
|
+
|
|
9
|
+
const ORIGINAL_ENV = process.env;
|
|
10
|
+
|
|
11
|
+
const req = (over: Partial<Request> = {}): Request =>
|
|
12
|
+
({ method: 'GET', query: {}, body: {}, baseUrl: '', path: '/', ...over }) as unknown as Request;
|
|
13
|
+
|
|
14
|
+
const res = () => ({ on: jest.fn(), locals: {} }) as unknown as Response;
|
|
15
|
+
const user = { id: 'u1', email: 'tom@example.com', name: 'Tom' };
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
jest.clearAllMocks();
|
|
19
|
+
process.env = { ...ORIGINAL_ENV };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
process.env = ORIGINAL_ENV;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('beginAudit', () => {
|
|
27
|
+
it('puts the actor in scope for everything after it', () => {
|
|
28
|
+
let seen: string | undefined;
|
|
29
|
+
beginAudit(req({ user }), res(), () => {
|
|
30
|
+
seen = currentAuditContext()?.actor.label;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(seen).toBe('Tom');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('leaves no context behind once the request is done', () => {
|
|
37
|
+
beginAudit(req({ user }), res(), () => undefined);
|
|
38
|
+
// A leaked context would attribute the next request — or a background job —
|
|
39
|
+
// to whoever happened to come before it.
|
|
40
|
+
expect(currentAuditContext()).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('continues without a context when nobody is authenticated', () => {
|
|
44
|
+
const next = jest.fn();
|
|
45
|
+
beginAudit(req(), res(), next);
|
|
46
|
+
|
|
47
|
+
expect(next).toHaveBeenCalled();
|
|
48
|
+
expect(recordRead).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('arms the read trail for the same request', () => {
|
|
52
|
+
beginAudit(req({ user }), res(), () => undefined);
|
|
53
|
+
expect(recordRead).toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('naming the service', () => {
|
|
58
|
+
it('uses SERVICE_NAME, as metrics and health already do', () => {
|
|
59
|
+
process.env.SERVICE_NAME = 'album-service';
|
|
60
|
+
let seen: string | undefined;
|
|
61
|
+
|
|
62
|
+
beginAudit(req({ user }), res(), () => {
|
|
63
|
+
seen = currentAuditContext()?.service;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(seen).toBe('album-service');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('falls back to the package name, so local development is still labelled', () => {
|
|
70
|
+
// Nothing sets SERVICE_NAME in dev, and every row landing under one
|
|
71
|
+
// meaningless label makes the UI's area filter look broken.
|
|
72
|
+
delete process.env.SERVICE_NAME;
|
|
73
|
+
process.env.npm_package_name = 'finance-service';
|
|
74
|
+
let seen: string | undefined;
|
|
75
|
+
|
|
76
|
+
beginAudit(req({ user }), res(), () => {
|
|
77
|
+
seen = currentAuditContext()?.service;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(seen).toBe('finance-service');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('withAuditContext', () => {
|
|
85
|
+
it('runs work as an explicit actor, for jobs and tests', () => {
|
|
86
|
+
const actor = { kind: 'person' as const, userId: 'u1', label: 'Tom', tenant: PERSONAL_TENANT };
|
|
87
|
+
const seen = withAuditContext({ actor, service: 'test' }, () => currentAuditContext());
|
|
88
|
+
|
|
89
|
+
expect(seen?.actor.label).toBe('Tom');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
3
|
+
import { actorOf, type Actor } from './actor';
|
|
4
|
+
import { recordRead } from './reads';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The actor, reachable from wherever the write actually happens.
|
|
8
|
+
*
|
|
9
|
+
* Attribution has to come from the request and be recorded at the data layer,
|
|
10
|
+
* and those are far apart: a Mongoose hook knows exactly which fields changed
|
|
11
|
+
* and nothing about who asked. Threading an actor through every service and
|
|
12
|
+
* controller signature to bridge that would touch every function between the
|
|
13
|
+
* two and be silently wrong the first time someone forgot.
|
|
14
|
+
*
|
|
15
|
+
* `AsyncLocalStorage` carries it instead, so the hook reads the actor without
|
|
16
|
+
* anything in between knowing it exists. A write outside a request — a script,
|
|
17
|
+
* a migration — simply finds no actor, which is the truth about it.
|
|
18
|
+
*/
|
|
19
|
+
export interface AuditContext {
|
|
20
|
+
actor: Actor;
|
|
21
|
+
service: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const storage = new AsyncLocalStorage<AuditContext>();
|
|
25
|
+
|
|
26
|
+
export const currentAuditContext = (): AuditContext | undefined => storage.getStore();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run the rest of a request with its actor in scope.
|
|
30
|
+
*
|
|
31
|
+
* Mounted after authentication, since there is no actor before it.
|
|
32
|
+
*/
|
|
33
|
+
export const auditContext =
|
|
34
|
+
(service: string): RequestHandler =>
|
|
35
|
+
(req: Request, _res: Response, next: NextFunction): void => {
|
|
36
|
+
const actor = actorOf(req);
|
|
37
|
+
if (!actor) {
|
|
38
|
+
next();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
storage.run({ actor, service }, next);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Run something with an explicit actor — for jobs and tests. */
|
|
46
|
+
export const withAuditContext = <T>(context: AuditContext, fn: () => T): T =>
|
|
47
|
+
storage.run(context, fn);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The service this process is, for a trail that spans several of them.
|
|
51
|
+
*
|
|
52
|
+
* `SERVICE_NAME` is already how metrics, health and the internal-service clients
|
|
53
|
+
* identify a process, so auditing uses the same one rather than introducing a
|
|
54
|
+
* second name for the same thing.
|
|
55
|
+
*
|
|
56
|
+
* The fallback to the package name matters more here than elsewhere: the trail
|
|
57
|
+
* is filtered by service in the UI, and in local development nothing sets
|
|
58
|
+
* `SERVICE_NAME`, so every row would land under one meaningless label and the
|
|
59
|
+
* filter would look broken. Every service's package is named after it.
|
|
60
|
+
*/
|
|
61
|
+
const serviceName = (): string =>
|
|
62
|
+
process.env.SERVICE_NAME || process.env.npm_package_name || 'unknown-service';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Put a request's actor in scope, and arm the read trail, for everything after.
|
|
66
|
+
*
|
|
67
|
+
* Called from `authenticateAgent` rather than mounted as its own middleware. A
|
|
68
|
+
* separate mount only works on a router that authenticates with
|
|
69
|
+
* `router.use(...)`; routers that authenticate per route — album's photos,
|
|
70
|
+
* finance's tickers — have no actor yet when a router-level middleware runs, so
|
|
71
|
+
* the context would be silently empty for exactly the endpoints most worth
|
|
72
|
+
* auditing. Doing it where authentication happens makes the two inseparable.
|
|
73
|
+
*/
|
|
74
|
+
export function beginAudit(req: Request, res: Response, next: NextFunction): void {
|
|
75
|
+
const actor = actorOf(req);
|
|
76
|
+
if (!actor) {
|
|
77
|
+
next();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
recordRead(req, res, actor, serviceName());
|
|
82
|
+
storage.run({ actor, service: serviceName() }, next);
|
|
83
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { actorOf, describeActor } from './actor';
|
|
2
|
+
export type { Actor, ActorKind } from './actor';
|
|
3
|
+
export { AuditEvent } from './AuditEvent';
|
|
4
|
+
export type { IAuditEvent } from './AuditEvent';
|
|
5
|
+
export { auditContext, beginAudit, currentAuditContext, withAuditContext } from './context';
|
|
6
|
+
export type { AuditContext } from './context';
|
|
7
|
+
export { auditPlugin } from './plugin';
|
|
8
|
+
export type { AuditPluginOptions } from './plugin';
|
|
9
|
+
export { auditReads, countRead, resourceOf } from './reads';
|
|
10
|
+
export { listAuditEvents } from './service';
|
|
11
|
+
export type { AuditQuery, AuditEntry } from './service';
|