@orthacms/activity-server 0.4.3 → 0.5.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/lib/activity/activity-filter.d.ts.map +1 -1
- package/dist/lib/activity/activity-filter.js +2 -0
- package/dist/lib/activity/activity.constants.d.ts +8 -3
- package/dist/lib/activity/activity.constants.d.ts.map +1 -1
- package/dist/lib/activity/activity.constants.js +9 -3
- package/dist/lib/activity/controllers/dead-letters.controller.d.ts +36 -0
- package/dist/lib/activity/controllers/dead-letters.controller.d.ts.map +1 -0
- package/dist/lib/activity/controllers/dead-letters.controller.js +61 -0
- package/dist/lib/activity/controllers/entry-activity.controller.d.ts +32 -0
- package/dist/lib/activity/controllers/entry-activity.controller.d.ts.map +1 -0
- package/dist/lib/activity/controllers/entry-activity.controller.js +69 -0
- package/dist/lib/activity/dto/dead-letters-query.dto.d.ts +14 -0
- package/dist/lib/activity/dto/dead-letters-query.dto.d.ts.map +1 -0
- package/dist/lib/activity/dto/dead-letters-query.dto.js +46 -0
- package/dist/lib/activity/dto/entry-activity-query.dto.d.ts +14 -0
- package/dist/lib/activity/dto/entry-activity-query.dto.d.ts.map +1 -0
- package/dist/lib/activity/dto/entry-activity-query.dto.js +50 -0
- package/dist/lib/activity/dto/list-activity-query.dto.d.ts +16 -0
- package/dist/lib/activity/dto/list-activity-query.dto.d.ts.map +1 -1
- package/dist/lib/activity/dto/list-activity-query.dto.js +38 -0
- package/dist/lib/activity/infrastructure/audit-event-mapping.d.ts +46 -0
- package/dist/lib/activity/infrastructure/audit-event-mapping.d.ts.map +1 -1
- package/dist/lib/activity/infrastructure/audit-event-mapping.js +515 -34
- package/dist/lib/activity/services/activity.service.d.ts.map +1 -1
- package/dist/lib/activity/services/activity.service.js +6 -0
- package/dist/lib/activity/types/activity-view.d.ts +18 -1
- package/dist/lib/activity/types/activity-view.d.ts.map +1 -1
- package/dist/lib/activity.module.d.ts.map +1 -1
- package/dist/lib/activity.module.js +12 -1
- package/dist/lib/docs/activity-schemas.d.ts +19 -0
- package/dist/lib/docs/activity-schemas.d.ts.map +1 -0
- package/dist/lib/docs/activity-schemas.js +167 -0
- package/dist/lib/docs/describe-activity-api.d.ts +17 -0
- package/dist/lib/docs/describe-activity-api.d.ts.map +1 -0
- package/dist/lib/docs/describe-activity-api.js +85 -0
- package/dist/lib/schema/activity-events.d.ts +56 -2
- package/dist/lib/schema/activity-events.d.ts.map +1 -1
- package/dist/lib/schema/activity-events.js +26 -3
- package/dist/lib/utils/activity-plugin.d.ts.map +1 -1
- package/dist/lib/utils/activity-plugin.js +5 -1
- package/migrations/0001_actor_type_and_workspace.sql +8 -0
- package/migrations/meta/0001_snapshot.json +192 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +7 -8
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.AUDITED_EVENT_KINDS = exports.UnmappableAuditEventError = void 0;
|
|
3
|
+
exports.AUDIT_SUBJECT_TYPES = exports.AUDIT_KINDS = exports.AUDITED_EVENT_KINDS = exports.UnmappableAuditEventError = void 0;
|
|
4
4
|
exports.toAuditRow = toAuditRow;
|
|
5
5
|
const identity_server_1 = require("@orthacms/identity-server");
|
|
6
6
|
/**
|
|
7
|
-
* The **user.\*** audit kinds
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* The **user.\*** audit kinds the users context's `member.*` events map onto.
|
|
8
|
+
* Spelled as literals rather than imported, so the audit sink stays decoupled
|
|
9
|
+
* from every producer (activity depends on identity only); the parity unit test
|
|
10
|
+
* pins these strings against the exact rows the old in-band recorder wrote.
|
|
11
|
+
* This is the only catalogue of them — the users package deliberately keeps
|
|
12
|
+
* none, since nothing there reads it.
|
|
12
13
|
*/
|
|
13
14
|
const USER_AUDIT_KINDS = {
|
|
14
15
|
INVITED: 'user.invited',
|
|
@@ -69,6 +70,104 @@ const MEDIA_AUDIT_KINDS = {
|
|
|
69
70
|
FOLDER_RENAMED: 'media.folder.renamed',
|
|
70
71
|
FOLDER_DELETED: 'media.folder.deleted'
|
|
71
72
|
};
|
|
73
|
+
/**
|
|
74
|
+
* The **transfer.\*** audit kinds — content leaving and entering the system in
|
|
75
|
+
* bulk.
|
|
76
|
+
*
|
|
77
|
+
* `transfer/server` has raised these since the feature landed, and its own
|
|
78
|
+
* `transfer.events.ts` explains at length why an *export* — a read — raises an
|
|
79
|
+
* audit event at all: it is the one operation that takes a workspace's content
|
|
80
|
+
* out of the system, files included, and nothing in the content tables changes
|
|
81
|
+
* to record it. Nothing mapped them, so that reasoning produced no rows: the
|
|
82
|
+
* dispatcher found no subscriber, stamped `dispatched_at`, and every export and
|
|
83
|
+
* import left the log silent. The fourth time this exact failure has happened,
|
|
84
|
+
* after API tokens, entry publishes and the whole media library.
|
|
85
|
+
*
|
|
86
|
+
* An import is *also* visible as the `entry.created` / `entry.updated` rows its
|
|
87
|
+
* writes raise — it goes through `EntryWriterService` like any other write — but
|
|
88
|
+
* those rows say a hundred entries changed, not that one person imported a file.
|
|
89
|
+
* Those are different facts and the second one is the one an operator asks about.
|
|
90
|
+
*/
|
|
91
|
+
/**
|
|
92
|
+
* The **segment.\*** audit kinds — reader entitlements.
|
|
93
|
+
*
|
|
94
|
+
* Segments answer who may *read* published content, and until now nothing in
|
|
95
|
+
* that context raised an event at all: creating an audience, re-tagging one
|
|
96
|
+
* (which changes who every entry naming it is visible to, while its name and
|
|
97
|
+
* its entries stay put), deleting one (which strips its mention from every
|
|
98
|
+
* entry in one statement), and setting an entry's allow/deny lists were all
|
|
99
|
+
* access-control decisions the log recorded nothing about.
|
|
100
|
+
*
|
|
101
|
+
* The entry's own revisions carry its access as part of a snapshot, which
|
|
102
|
+
* covers exactly one of those four and only for entries saved afterwards. A
|
|
103
|
+
* segment's tag list is in no revision at all.
|
|
104
|
+
*/
|
|
105
|
+
const SEGMENT_AUDIT_KINDS = {
|
|
106
|
+
CREATED: 'segment.created',
|
|
107
|
+
UPDATED: 'segment.updated',
|
|
108
|
+
DELETED: 'segment.deleted',
|
|
109
|
+
ENTRY_ACCESS_CHANGED: 'segment.entry_access_changed'
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* The **alarm.rule.\*** audit kinds — the *configuration* of the content
|
|
113
|
+
* checks, never the findings.
|
|
114
|
+
*
|
|
115
|
+
* An alarm never gates a write (ADR-0015), which is precisely why its rules are
|
|
116
|
+
* worth a row: disable one, re-filter one, delete one, and everything
|
|
117
|
+
* downstream — the entry rail's checks block, the records column, the alarms
|
|
118
|
+
* page — simply goes quiet. Nothing about that quiet says a person chose it,
|
|
119
|
+
* and after a delete the rule row that would have explained it is gone.
|
|
120
|
+
*
|
|
121
|
+
* Findings are deliberately absent: one per matching entry per sweep, derived
|
|
122
|
+
* from content the log already records. Auditing them would make the trail a
|
|
123
|
+
* metrics feed.
|
|
124
|
+
*/
|
|
125
|
+
const ALARM_AUDIT_KINDS = {
|
|
126
|
+
RULE_CREATED: 'alarm.rule.created',
|
|
127
|
+
RULE_UPDATED: 'alarm.rule.updated',
|
|
128
|
+
RULE_DELETED: 'alarm.rule.deleted',
|
|
129
|
+
RULE_RESCANNED: 'alarm.rule.rescanned'
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* The **saved_view.\*** audit kinds.
|
|
133
|
+
*
|
|
134
|
+
* A view is a bookmark, not a grant — it replays through the ordinary list
|
|
135
|
+
* query with the reader's own permissions — so this is a low-stakes trio. It is
|
|
136
|
+
* here because a `workspace`-visible view is genuinely shared state: it is in
|
|
137
|
+
* every member's switcher, sharing it is its own permission, and deleting one
|
|
138
|
+
* takes it away from everybody.
|
|
139
|
+
*
|
|
140
|
+
* Setting a **personal default** raises nothing and is not listed: it is the
|
|
141
|
+
* reader's own landing choice, set by clicking a view, and a row per click
|
|
142
|
+
* would be a usage metric in an audit trail.
|
|
143
|
+
*/
|
|
144
|
+
const SAVED_VIEW_AUDIT_KINDS = {
|
|
145
|
+
CREATED: 'saved_view.created',
|
|
146
|
+
UPDATED: 'saved_view.updated',
|
|
147
|
+
DELETED: 'saved_view.deleted'
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* The **copilot.\*** audit kinds — authority a human hands the agent.
|
|
151
|
+
*
|
|
152
|
+
* A skill is standing instruction: it changes what the copilot does in a
|
|
153
|
+
* workspace on every future run, and one set to `auto` runs without being asked
|
|
154
|
+
* for. A tool permission is the same decision at one moment. Both are recorded.
|
|
155
|
+
*
|
|
156
|
+
* What is not here: starting a run, and renaming a conversation. Everything a
|
|
157
|
+
* run *does* is already audited where it happens — under ADR-0009 an applied
|
|
158
|
+
* change is an `entry.*` row, and it now carries the run that produced it — so
|
|
159
|
+
* a row per run would be a usage metric with no way to filter it out.
|
|
160
|
+
*/
|
|
161
|
+
const COPILOT_AUDIT_KINDS = {
|
|
162
|
+
SKILL_CREATED: 'copilot.skill.created',
|
|
163
|
+
SKILL_UPDATED: 'copilot.skill.updated',
|
|
164
|
+
SKILL_DELETED: 'copilot.skill.deleted',
|
|
165
|
+
TOOL_PERMISSION_DECIDED: 'copilot.tool_permission.decided'
|
|
166
|
+
};
|
|
167
|
+
const TRANSFER_AUDIT_KINDS = {
|
|
168
|
+
EXPORTED: 'transfer.content.exported',
|
|
169
|
+
IMPORTED: 'transfer.content.imported'
|
|
170
|
+
};
|
|
72
171
|
/** Reads a payload field as a string (or `null` when absent/nullish). */
|
|
73
172
|
function nullableString(value) {
|
|
74
173
|
return typeof value === 'string' ? value : null;
|
|
@@ -95,6 +194,9 @@ function workspaceSubject(event, meta) {
|
|
|
95
194
|
* content type rides in `meta` so the log can name *what* happened to without
|
|
96
195
|
* joining anything.
|
|
97
196
|
*
|
|
197
|
+
* The entry's frozen `title` rides alongside, so the row names something a
|
|
198
|
+
* reader recognises.
|
|
199
|
+
*
|
|
98
200
|
* `extra` carries the few facts one kind has and the others do not — the
|
|
99
201
|
* changed `fields` on an update, and whether a delete was a recoverable
|
|
100
202
|
* tombstone (`soft`) or the row leaving the table. Everything else about an
|
|
@@ -107,6 +209,12 @@ function entrySubject(event, extra = {}) {
|
|
|
107
209
|
subjectId: event.aggregateId,
|
|
108
210
|
meta: {
|
|
109
211
|
contentType: nullableString(event.payload.contentType),
|
|
212
|
+
// The entry's label **at the time of the event**, frozen by the
|
|
213
|
+
// producer. An audit row keeps no FK and no lookup, so without this
|
|
214
|
+
// the Subject cell is a bare uuid — survivable while the entry
|
|
215
|
+
// exists, and permanently unreadable after `entry.purged`, which is
|
|
216
|
+
// exactly the row this is the last remaining record of.
|
|
217
|
+
title: nullableString(event.payload.title),
|
|
110
218
|
...extra
|
|
111
219
|
}
|
|
112
220
|
};
|
|
@@ -117,12 +225,12 @@ function entrySubject(event, extra = {}) {
|
|
|
117
225
|
*
|
|
118
226
|
* `meta` records what a reviewer needs to reason about the credential — its
|
|
119
227
|
* label, its scope, its workspace bucket, and the non-secret `lookupPrefix`
|
|
120
|
-
* that identifies it in the admin list
|
|
121
|
-
* plaintext nor the hash**: `api_tokens`
|
|
122
|
-
*
|
|
123
|
-
* table.
|
|
228
|
+
* that identifies it in the admin list, plus whatever `extra` the kind adds. It
|
|
229
|
+
* deliberately carries **neither the plaintext nor the hash**: `api_tokens`
|
|
230
|
+
* stores only a SHA-256 precisely so a read of another table yields nothing
|
|
231
|
+
* usable, and the audit log is another table.
|
|
124
232
|
*/
|
|
125
|
-
function apiTokenSubject(event, auditKind) {
|
|
233
|
+
function apiTokenSubject(event, auditKind, extra = {}) {
|
|
126
234
|
const payload = event.payload;
|
|
127
235
|
return {
|
|
128
236
|
kind: auditKind,
|
|
@@ -132,7 +240,8 @@ function apiTokenSubject(event, auditKind) {
|
|
|
132
240
|
name: nullableString(payload.name),
|
|
133
241
|
scope: nullableString(payload.scope),
|
|
134
242
|
workspaceIds: payload.workspaceIds ?? [],
|
|
135
|
-
lookupPrefix: nullableString(payload.lookupPrefix)
|
|
243
|
+
lookupPrefix: nullableString(payload.lookupPrefix),
|
|
244
|
+
...extra
|
|
136
245
|
}
|
|
137
246
|
};
|
|
138
247
|
}
|
|
@@ -206,6 +315,120 @@ function mediaFolderSubject(event) {
|
|
|
206
315
|
meta: payloadWithoutActor(event)
|
|
207
316
|
};
|
|
208
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* A `'login_attempt'`-subject facet — the subject is the **address** that was
|
|
320
|
+
* tried, not a user.
|
|
321
|
+
*
|
|
322
|
+
* It has to be: the failures worth reading are exactly the ones where no
|
|
323
|
+
* account exists to key on, and a mapper that insisted on a user id would
|
|
324
|
+
* refuse them (see {@link UnmappableAuditEventError}) and park every guess
|
|
325
|
+
* against an unknown address as a dead letter. `subject_id` is `text` for this
|
|
326
|
+
* kind of subject, and `activity_events_subject_idx` then makes "what has been
|
|
327
|
+
* tried against this login" one indexed read.
|
|
328
|
+
*/
|
|
329
|
+
function signInAttemptSubject(event) {
|
|
330
|
+
return {
|
|
331
|
+
kind: identity_server_1.IDENTITY_ACTIVITY_KINDS.USER_SIGN_IN_FAILED,
|
|
332
|
+
subjectType: 'login_attempt',
|
|
333
|
+
subjectId: event.aggregateId,
|
|
334
|
+
meta: payloadWithoutActor(event)
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* An `'alarm_rule'`-subject facet. `meta` is the payload minus the actor, which
|
|
339
|
+
* for an update carries `enabled` on both sides — a disabled rule is
|
|
340
|
+
* indistinguishable from a rule that finds nothing, so that transition is the
|
|
341
|
+
* one a reader most needs spelled out.
|
|
342
|
+
*/
|
|
343
|
+
function alarmRuleSubject(event) {
|
|
344
|
+
return {
|
|
345
|
+
kind: event.kind,
|
|
346
|
+
subjectType: 'alarm_rule',
|
|
347
|
+
subjectId: event.aggregateId,
|
|
348
|
+
meta: payloadWithoutActor(event)
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
/** A `'copilot_skill'`-subject facet. Payload passthrough. */
|
|
352
|
+
function copilotSkillSubject(event) {
|
|
353
|
+
return {
|
|
354
|
+
kind: event.kind,
|
|
355
|
+
subjectType: 'copilot_skill',
|
|
356
|
+
subjectId: event.aggregateId,
|
|
357
|
+
meta: payloadWithoutActor(event)
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* A `'copilot_run'`-subject facet — the subject is the run, which is the handle
|
|
362
|
+
* a reviewer actually has: a transcript is read by run id, and the tool call
|
|
363
|
+
* this decision answered is one field inside it.
|
|
364
|
+
*/
|
|
365
|
+
function copilotRunSubject(event) {
|
|
366
|
+
return {
|
|
367
|
+
kind: event.kind,
|
|
368
|
+
subjectType: 'copilot_run',
|
|
369
|
+
subjectId: event.aggregateId,
|
|
370
|
+
meta: payloadWithoutActor(event)
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
/** A `'saved_view'`-subject facet. Payload passthrough, as for alarm rules. */
|
|
374
|
+
function savedViewSubject(event) {
|
|
375
|
+
return {
|
|
376
|
+
kind: event.kind,
|
|
377
|
+
subjectType: 'saved_view',
|
|
378
|
+
subjectId: event.aggregateId,
|
|
379
|
+
meta: payloadWithoutActor(event)
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* A `'segment'`-subject facet — the audience itself. `meta` is the payload
|
|
384
|
+
* minus the actor, which for `segment.updated` includes the tag list on both
|
|
385
|
+
* sides and for `segment.deleted` the whole record, since after that commits
|
|
386
|
+
* there is nowhere left to look it up.
|
|
387
|
+
*/
|
|
388
|
+
function segmentSubject(event) {
|
|
389
|
+
return {
|
|
390
|
+
kind: event.kind,
|
|
391
|
+
subjectType: 'segment',
|
|
392
|
+
subjectId: event.aggregateId,
|
|
393
|
+
meta: payloadWithoutActor(event)
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* A `'content_entry'`-subject facet for an access change.
|
|
398
|
+
*
|
|
399
|
+
* Deliberately the **entry**, not the segment: "who may read this record" is a
|
|
400
|
+
* fact about the record, so the row belongs in that entry's own history beside
|
|
401
|
+
* its edits and its publishes — which is where somebody asking "why can nobody
|
|
402
|
+
* read this" actually looks. It shares `subjectType` with the `entry.*` kinds
|
|
403
|
+
* for exactly that reason.
|
|
404
|
+
*/
|
|
405
|
+
function entryAccessSubject(event) {
|
|
406
|
+
return {
|
|
407
|
+
kind: event.kind,
|
|
408
|
+
subjectType: 'content_entry',
|
|
409
|
+
subjectId: event.aggregateId,
|
|
410
|
+
meta: payloadWithoutActor(event)
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* A `'content_type'`-subject facet — the subject of a transfer is the content
|
|
415
|
+
* type that moved, which is what the event's `aggregateId` already carries.
|
|
416
|
+
*
|
|
417
|
+
* There is no single entry to point at: an export names a selection and an
|
|
418
|
+
* import names a file, and both fan out across relations, files and locales.
|
|
419
|
+
* The type is the one stable handle the row can keep, and `meta` carries the
|
|
420
|
+
* rest — the format, how many records were selected, and the per-kind counts the
|
|
421
|
+
* walk actually produced — so a reviewer can see that "12 selected" left as 47
|
|
422
|
+
* records plus 9 files without opening anything.
|
|
423
|
+
*/
|
|
424
|
+
function transferSubject(event) {
|
|
425
|
+
return {
|
|
426
|
+
kind: event.kind,
|
|
427
|
+
subjectType: 'content_type',
|
|
428
|
+
subjectId: event.aggregateId,
|
|
429
|
+
meta: payloadWithoutActor(event)
|
|
430
|
+
};
|
|
431
|
+
}
|
|
209
432
|
/**
|
|
210
433
|
* The event payload with `attachActor`'s `actor` key removed — the actor is
|
|
211
434
|
* lifted onto the row's own `actorId`/`actorEmail` columns by
|
|
@@ -240,17 +463,22 @@ function payloadWithoutActor(event) {
|
|
|
240
463
|
* | `member.reactivated` | `user.reactivated` | user / `null` |
|
|
241
464
|
* | `user.password_changed` | `user.password_changed` | user / `{ sessionsRevoked }` |
|
|
242
465
|
* | `user.activated` | `user.activated` | user / `null` |
|
|
466
|
+
* | `user.disabled` | `user.suspended` | user / `null` |
|
|
467
|
+
* | `user.enabled` | `user.reactivated` | user / `null` |
|
|
243
468
|
* | `api_token.created` | `token.created` | api_token / `{ name, scope, workspaceIds, lookupPrefix }` |
|
|
244
469
|
* | `api_token.revoked` | `token.revoked` | api_token / same shape |
|
|
470
|
+
* | `api_token.used` | `token.used` | api_token / same shape + `previousUseAt` |
|
|
471
|
+
* | `user.session_revoked` | `user.session_revoked` | user / `{ sessionId }` |
|
|
245
472
|
* | `auth.signed_in` | `user.signed_in` | user / `null` |
|
|
246
473
|
* | `auth.signed_out` | `user.signed_out` | user / `null` |
|
|
247
|
-
* | `
|
|
248
|
-
* | `entry.
|
|
249
|
-
* | `entry.
|
|
250
|
-
* | `entry.
|
|
251
|
-
* | `entry.
|
|
252
|
-
* | `entry.
|
|
253
|
-
* | `entry.
|
|
474
|
+
* | `auth.sign_in_failed` | `user.sign_in_failed` | **login_attempt** / `{ reason, userId, ipAddress, userAgent }` |
|
|
475
|
+
* | `entry.created` | `entry.created` | content_entry / `{ contentType, title }` |
|
|
476
|
+
* | `entry.updated` | `entry.updated` | content_entry / `{ contentType, title, fields }` |
|
|
477
|
+
* | `entry.published` | `entry.published` | content_entry / `{ contentType, title }` |
|
|
478
|
+
* | `entry.unpublished` | `entry.unpublished` | content_entry / `{ contentType, title }` |
|
|
479
|
+
* | `entry.deleted` | `entry.deleted` | content_entry / `{ contentType, title, soft }` |
|
|
480
|
+
* | `entry.restored` | `entry.restored` | content_entry / `{ contentType, title }` |
|
|
481
|
+
* | `entry.purged` | `entry.purged` | content_entry / `{ contentType, title }` |
|
|
254
482
|
* | `media.asset.uploaded` | `media.asset.uploaded` | media_asset / payload minus `actor` |
|
|
255
483
|
* | `media.asset.updated` | `media.asset.updated` | media_asset / payload minus `actor` |
|
|
256
484
|
* | `media.asset.moved` | `media.asset.moved` | media_asset / payload minus `actor` |
|
|
@@ -258,9 +486,28 @@ function payloadWithoutActor(event) {
|
|
|
258
486
|
* | `media.folder.created` | `media.folder.created` | media_folder / payload minus `actor` |
|
|
259
487
|
* | `media.folder.renamed` | `media.folder.renamed` | media_folder / payload minus `actor` |
|
|
260
488
|
* | `media.folder.deleted` | `media.folder.deleted` | media_folder / payload minus `actor` |
|
|
489
|
+
* | `alarm.rule.created` | `alarm.rule.created` | alarm_rule / payload minus `actor` |
|
|
490
|
+
* | `alarm.rule.updated` | `alarm.rule.updated` | alarm_rule / payload minus `actor` (enabled from/to) |
|
|
491
|
+
* | `alarm.rule.deleted` | `alarm.rule.deleted` | alarm_rule / payload minus `actor` |
|
|
492
|
+
* | `alarm.rule.rescanned` | `alarm.rule.rescanned` | alarm_rule / payload minus `actor` (scan counts) |
|
|
493
|
+
* | `copilot.skill.created` | `copilot.skill.created` | copilot_skill / payload minus `actor` |
|
|
494
|
+
* | `copilot.skill.updated` | `copilot.skill.updated` | copilot_skill / payload minus `actor` |
|
|
495
|
+
* | `copilot.skill.deleted` | `copilot.skill.deleted` | copilot_skill / payload minus `actor` |
|
|
496
|
+
* | `copilot.tool_permission.decided` | same | **copilot_run** / `{ callId, decision, … }` |
|
|
497
|
+
* | `saved_view.created` | `saved_view.created` | saved_view / payload minus `actor` |
|
|
498
|
+
* | `saved_view.updated` | `saved_view.updated` | saved_view / payload minus `actor` |
|
|
499
|
+
* | `saved_view.deleted` | `saved_view.deleted` | saved_view / payload minus `actor` |
|
|
500
|
+
* | `segment.created` | `segment.created` | segment / payload minus `actor` |
|
|
501
|
+
* | `segment.updated` | `segment.updated` | segment / payload minus `actor` (tags from/to) |
|
|
502
|
+
* | `segment.deleted` | `segment.deleted` | segment / payload minus `actor` |
|
|
503
|
+
* | `segment.entry_access_changed` | same | **content_entry** / `{ allow, deny, entryIds, … }` |
|
|
504
|
+
* | `transfer.content.exported`| `transfer.content.exported`| content_type / payload minus `actor` |
|
|
505
|
+
* | `transfer.content.imported`| `transfer.content.imported`| content_type / payload minus `actor` |
|
|
261
506
|
*
|
|
262
|
-
* The actor (`actorId`/`actorEmail`) is not here — it rides on the
|
|
263
|
-
* (`attachActor`) and is read uniformly by {@link toAuditRow}
|
|
507
|
+
* The actor (`actorId`/`actorType`/`actorEmail`) is not here — it rides on the
|
|
508
|
+
* event payload (`attachActor`) and is read uniformly by {@link toAuditRow}, as
|
|
509
|
+
* is `workspaceId` (from the payload's own `workspaceId`, when the producer has
|
|
510
|
+
* one).
|
|
264
511
|
*/
|
|
265
512
|
const FACET_MAPPERS = {
|
|
266
513
|
'workspace.created': (e) => workspaceSubject(e, {
|
|
@@ -309,24 +556,75 @@ const FACET_MAPPERS = {
|
|
|
309
556
|
'user.password_changed': (e) => userSubject(e, USER_AUDIT_KINDS.PASSWORD_CHANGED, {
|
|
310
557
|
sessionsRevoked: e.payload.sessionsRevoked ?? null
|
|
311
558
|
}),
|
|
559
|
+
// The identity aggregate's own lifecycle pair. Today every disable/enable
|
|
560
|
+
// in the product runs through the users context's `Member` aggregate, whose
|
|
561
|
+
// `member.disabled` / `member.reactivated` are mapped above — these two are
|
|
562
|
+
// raised by `UserAccount.disable()` / `.enable()`, which nothing in the
|
|
563
|
+
// product calls yet. They are mapped anyway, and to the **same** audit kinds
|
|
564
|
+
// as their `member.*` counterparts: the methods are public API of a
|
|
565
|
+
// published package, so the first caller to appear would otherwise reproduce
|
|
566
|
+
// this package's signature failure — an event raised, no subscriber found,
|
|
567
|
+
// the row stamped dispatched, and an account silently locked out with
|
|
568
|
+
// nothing in the log. Which aggregate performed the change is an internal
|
|
569
|
+
// fact; the reader wants "this account was suspended".
|
|
570
|
+
'user.disabled': (e) => userSubject(e, USER_AUDIT_KINDS.SUSPENDED, null),
|
|
571
|
+
'user.enabled': (e) => userSubject(e, USER_AUDIT_KINDS.REACTIVATED, null),
|
|
312
572
|
// Invite acceptance. `accept-invite.use-case.ts` appends this alongside
|
|
313
573
|
// `auth.signed_in`, and only the sign-in was ever mapped — so the trail
|
|
314
574
|
// recorded that an invited person signed in, but never that the account
|
|
315
575
|
// itself went from `pending` to `active` and gained a credential. Those are
|
|
316
576
|
// different facts and a security review wants the first one.
|
|
317
577
|
'user.activated': (e) => userSubject(e, USER_AUDIT_KINDS.ACTIVATED, null),
|
|
318
|
-
// The sign-in itself, plus **
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
578
|
+
// The sign-in itself, plus **where from** and **how**. The IP and
|
|
579
|
+
// User-Agent were stored on the session row and nowhere else, so the trail
|
|
580
|
+
// could say somebody signed in and never from where — and a session is
|
|
581
|
+
// eventually pruned while the audit row is not.
|
|
582
|
+
'auth.signed_in': (e) => userSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.USER_SIGNED_IN, {
|
|
583
|
+
// Where it came from. Password sign-ins carry these; an SSO one
|
|
584
|
+
// does not (the browser reached the provider, not us), so they are
|
|
585
|
+
// null there rather than absent — a reader should be able to tell
|
|
586
|
+
// "not captured" from "not looked at".
|
|
587
|
+
ipAddress: nullableString(e.payload.ipAddress),
|
|
588
|
+
userAgent: nullableString(e.payload.userAgent),
|
|
589
|
+
// `method` distinguishes the two, which is the first question after
|
|
590
|
+
// a provider is misconfigured or retired: which of these people
|
|
591
|
+
// came in through the directory, and which still hold a password?
|
|
592
|
+
...(e.payload.method === 'sso'
|
|
593
|
+
? {
|
|
594
|
+
method: 'sso',
|
|
595
|
+
provider: nullableString(e.payload.provider)
|
|
596
|
+
}
|
|
597
|
+
: {})
|
|
598
|
+
}),
|
|
599
|
+
// The sign-out, and — when it was not the person's own doing — **how**.
|
|
600
|
+
// A back-channel logout is the directory ending the session: an
|
|
601
|
+
// offboarding, a compromised account, a licence revoked upstream. The
|
|
602
|
+
// person clicked nothing, and `sso-backchannel-logout.use-case.ts` puts
|
|
603
|
+
// the method and the provider in the payload precisely so the trail can
|
|
604
|
+
// say which of the two happened.
|
|
605
|
+
//
|
|
606
|
+
// Dropping them made a forced sign-out read exactly like somebody closing
|
|
607
|
+
// their own session — and "were they logged out, or did they leave?" is
|
|
608
|
+
// among the first questions asked of a trail after an incident.
|
|
609
|
+
// `auth.signed_in` records the same pair for the same reason; the
|
|
610
|
+
// asymmetry was the defect rather than a decision.
|
|
611
|
+
//
|
|
612
|
+
// An ordinary sign-out carries no method and keeps `meta: null`: inventing
|
|
613
|
+
// one would be worse than silence, because "not recorded" and "recorded as
|
|
614
|
+
// ordinary" would stop being distinguishable.
|
|
615
|
+
'auth.signed_out': (e) => userSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.USER_SIGNED_OUT, typeof e.payload.method === 'string'
|
|
324
616
|
? {
|
|
325
|
-
method:
|
|
617
|
+
method: e.payload.method,
|
|
326
618
|
provider: nullableString(e.payload.provider)
|
|
327
619
|
}
|
|
328
620
|
: null),
|
|
329
|
-
|
|
621
|
+
// …and the refusal, which had no kind at all. `auth.signed_in` fires on
|
|
622
|
+
// success only, so a log full of successful sign-ins was equally consistent
|
|
623
|
+
// with nobody ever guessing and with a sustained attack — the one question
|
|
624
|
+
// an operator most wants the trail to settle. The row carries the coarse
|
|
625
|
+
// reason, the address, and the IP/User-Agent the attempt came from; the HTTP
|
|
626
|
+
// response is unchanged and still says nothing.
|
|
627
|
+
'auth.sign_in_failed': signInAttemptSubject,
|
|
330
628
|
// Single sign-on. Three separate facts, deliberately not folded into the
|
|
331
629
|
// sign-in above: a link means a second way into the account now exists, a
|
|
332
630
|
// provisioned account is the only kind this product creates without an
|
|
@@ -344,12 +642,28 @@ const FACET_MAPPERS = {
|
|
|
344
642
|
provider: nullableString(e.payload.provider),
|
|
345
643
|
role: nullableString(e.payload.role)
|
|
346
644
|
}),
|
|
645
|
+
// An administrator ending somebody else's session. `auth.signed_out` is a
|
|
646
|
+
// person closing their own; this is an act performed *on* an account, and
|
|
647
|
+
// it was the one route under `/users/:id` that wrote nothing at all.
|
|
648
|
+
'user.session_revoked': (e) => userSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.USER_SESSION_REVOKED, {
|
|
649
|
+
sessionId: nullableString(e.payload.sessionId)
|
|
650
|
+
}),
|
|
347
651
|
// External-API bearer tokens. Nothing mapped these before, so minting and
|
|
348
652
|
// revoking a long-lived key to workspace content left the log completely
|
|
349
653
|
// silent (BUG-identity-server-01) — the audit trail could not answer "who
|
|
350
654
|
// issued this credential, when, and scoped to what".
|
|
351
655
|
'api_token.created': (e) => apiTokenSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.TOKEN_CREATED),
|
|
352
656
|
'api_token.revoked': (e) => apiTokenSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.TOKEN_REVOKED),
|
|
657
|
+
// …and the credential actually being used, throttled at the source to one
|
|
658
|
+
// row per token per minute. `last_used_at` on the token row answers "when
|
|
659
|
+
// was this last used" and nothing else: a token dormant for six months that
|
|
660
|
+
// woke up on Tuesday reads exactly like one in daily use, which is the
|
|
661
|
+
// distinction that matters after a key leaks.
|
|
662
|
+
'api_token.used': (e) => apiTokenSubject(e, identity_server_1.IDENTITY_ACTIVITY_KINDS.TOKEN_USED, {
|
|
663
|
+
// How long the credential had been quiet before this request. The
|
|
664
|
+
// touch overwrites `last_used_at`, so this is the only copy of it.
|
|
665
|
+
previousUseAt: e.payload.previousUseAt ?? null
|
|
666
|
+
}),
|
|
353
667
|
// Content publish lifecycle. `content-server` has raised these on the outbox
|
|
354
668
|
// since the entry aggregate was introduced — its own comment anticipated
|
|
355
669
|
// this subscriber — but nothing consumed them, so the log carried no content
|
|
@@ -382,19 +696,178 @@ const FACET_MAPPERS = {
|
|
|
382
696
|
[MEDIA_AUDIT_KINDS.ASSET_DELETED]: mediaAssetSubject,
|
|
383
697
|
[MEDIA_AUDIT_KINDS.FOLDER_CREATED]: mediaFolderSubject,
|
|
384
698
|
[MEDIA_AUDIT_KINDS.FOLDER_RENAMED]: mediaFolderSubject,
|
|
385
|
-
[MEDIA_AUDIT_KINDS.FOLDER_DELETED]: mediaFolderSubject
|
|
699
|
+
[MEDIA_AUDIT_KINDS.FOLDER_DELETED]: mediaFolderSubject,
|
|
700
|
+
// Bulk export and import. Raised with an actor since the feature shipped and
|
|
701
|
+
// dropped on the floor ever since, so the one operation that moves a
|
|
702
|
+
// workspace's content out of the system wholesale was the one operation the
|
|
703
|
+
// audit log could not answer for.
|
|
704
|
+
// Reader entitlements. Four access-control decisions that produced no
|
|
705
|
+
// event of any kind, so the log could not answer "when did this stop being
|
|
706
|
+
// public, and who decided that".
|
|
707
|
+
[SEGMENT_AUDIT_KINDS.CREATED]: segmentSubject,
|
|
708
|
+
[SEGMENT_AUDIT_KINDS.UPDATED]: segmentSubject,
|
|
709
|
+
[SEGMENT_AUDIT_KINDS.DELETED]: segmentSubject,
|
|
710
|
+
[SEGMENT_AUDIT_KINDS.ENTRY_ACCESS_CHANGED]: entryAccessSubject,
|
|
711
|
+
// Content checks — the rules, not their findings.
|
|
712
|
+
[ALARM_AUDIT_KINDS.RULE_CREATED]: alarmRuleSubject,
|
|
713
|
+
[ALARM_AUDIT_KINDS.RULE_UPDATED]: alarmRuleSubject,
|
|
714
|
+
[ALARM_AUDIT_KINDS.RULE_DELETED]: alarmRuleSubject,
|
|
715
|
+
[ALARM_AUDIT_KINDS.RULE_RESCANNED]: alarmRuleSubject,
|
|
716
|
+
// Shared list views. The personal default is not here — see the kind block.
|
|
717
|
+
[SAVED_VIEW_AUDIT_KINDS.CREATED]: savedViewSubject,
|
|
718
|
+
[SAVED_VIEW_AUDIT_KINDS.UPDATED]: savedViewSubject,
|
|
719
|
+
[SAVED_VIEW_AUDIT_KINDS.DELETED]: savedViewSubject,
|
|
720
|
+
// The copilot's standing instructions, and the moment a person grants one
|
|
721
|
+
// of its calls the authority to proceed.
|
|
722
|
+
[COPILOT_AUDIT_KINDS.SKILL_CREATED]: copilotSkillSubject,
|
|
723
|
+
[COPILOT_AUDIT_KINDS.SKILL_UPDATED]: copilotSkillSubject,
|
|
724
|
+
[COPILOT_AUDIT_KINDS.SKILL_DELETED]: copilotSkillSubject,
|
|
725
|
+
[COPILOT_AUDIT_KINDS.TOOL_PERMISSION_DECIDED]: copilotRunSubject,
|
|
726
|
+
[TRANSFER_AUDIT_KINDS.EXPORTED]: transferSubject,
|
|
727
|
+
[TRANSFER_AUDIT_KINDS.IMPORTED]: transferSubject
|
|
386
728
|
};
|
|
387
729
|
/**
|
|
388
730
|
* Every event kind the activity subscriber audits — the dispatcher delivers
|
|
389
731
|
* only these to it.
|
|
732
|
+
*
|
|
733
|
+
* These are the mappers' **inputs**. For the `kind` values they produce — the
|
|
734
|
+
* strings that reach the wire and that a client renders — see
|
|
735
|
+
* {@link AUDIT_KINDS}, which is a different and slightly shorter list
|
|
736
|
+
* (`member.*` and `user.disabled` collapse onto `user.*` audit kinds).
|
|
390
737
|
*/
|
|
391
738
|
exports.AUDITED_EVENT_KINDS = Object.keys(FACET_MAPPERS);
|
|
392
|
-
/**
|
|
739
|
+
/**
|
|
740
|
+
* Every audit `kind` this mapper can **produce** — the catalogue a client has
|
|
741
|
+
* to be able to render.
|
|
742
|
+
*
|
|
743
|
+
* It is declared rather than derived because deriving it would mean running
|
|
744
|
+
* every mapper, and several legitimately throw on a payload that cannot name
|
|
745
|
+
* their subject. `audit-event-mapping.spec.ts` drives each mapper once and
|
|
746
|
+
* asserts the two agree in both directions, so a kind added to `FACET_MAPPERS`
|
|
747
|
+
* and forgotten here fails a test rather than reaching the admin as a raw
|
|
748
|
+
* dotted token.
|
|
749
|
+
*
|
|
750
|
+
* That failure is the whole reason this exists. The admin restates these
|
|
751
|
+
* strings locally (it cannot import a server plugin), the mapper casts
|
|
752
|
+
* `dto.kind as ActivityKind` without checking, and a kind with no descriptor
|
|
753
|
+
* falls back to printing the wire token. No type error, no runtime error, no
|
|
754
|
+
* failing test — six `workspace.*` kinds, `user.activated`, all seven `media.*`
|
|
755
|
+
* kinds and all three `user.sso_*` kinds shipped that way.
|
|
756
|
+
*/
|
|
757
|
+
exports.AUDIT_KINDS = [
|
|
758
|
+
'user.invited',
|
|
759
|
+
'user.invite_resent',
|
|
760
|
+
'user.invite_revoked',
|
|
761
|
+
'user.password_reset_issued',
|
|
762
|
+
'user.activated',
|
|
763
|
+
'user.profile_updated',
|
|
764
|
+
'user.role_changed',
|
|
765
|
+
'user.suspended',
|
|
766
|
+
'user.reactivated',
|
|
767
|
+
'user.password_changed',
|
|
768
|
+
'user.signed_in',
|
|
769
|
+
'user.signed_out',
|
|
770
|
+
'user.sign_in_failed',
|
|
771
|
+
'user.session_revoked',
|
|
772
|
+
'user.sso_linked',
|
|
773
|
+
'user.sso_provisioned',
|
|
774
|
+
'user.sso_role_mapped',
|
|
775
|
+
'workspace.created',
|
|
776
|
+
'workspace.updated',
|
|
777
|
+
'workspace.archived',
|
|
778
|
+
'workspace.unarchived',
|
|
779
|
+
'workspace.deleted',
|
|
780
|
+
'workspace.member_added',
|
|
781
|
+
'workspace.member_removed',
|
|
782
|
+
'workspace.content_granted',
|
|
783
|
+
'workspace.content_revoked',
|
|
784
|
+
'entry.created',
|
|
785
|
+
'entry.updated',
|
|
786
|
+
'entry.published',
|
|
787
|
+
'entry.unpublished',
|
|
788
|
+
'entry.deleted',
|
|
789
|
+
'entry.restored',
|
|
790
|
+
'entry.purged',
|
|
791
|
+
'token.created',
|
|
792
|
+
'token.revoked',
|
|
793
|
+
'token.used',
|
|
794
|
+
'media.asset.uploaded',
|
|
795
|
+
'media.asset.updated',
|
|
796
|
+
'media.asset.moved',
|
|
797
|
+
'media.asset.deleted',
|
|
798
|
+
'media.folder.created',
|
|
799
|
+
'media.folder.renamed',
|
|
800
|
+
'media.folder.deleted',
|
|
801
|
+
'transfer.content.exported',
|
|
802
|
+
'transfer.content.imported',
|
|
803
|
+
'segment.created',
|
|
804
|
+
'segment.updated',
|
|
805
|
+
'segment.deleted',
|
|
806
|
+
'segment.entry_access_changed',
|
|
807
|
+
'alarm.rule.created',
|
|
808
|
+
'alarm.rule.updated',
|
|
809
|
+
'alarm.rule.deleted',
|
|
810
|
+
'alarm.rule.rescanned',
|
|
811
|
+
'saved_view.created',
|
|
812
|
+
'saved_view.updated',
|
|
813
|
+
'saved_view.deleted',
|
|
814
|
+
'copilot.skill.created',
|
|
815
|
+
'copilot.skill.updated',
|
|
816
|
+
'copilot.skill.deleted',
|
|
817
|
+
'copilot.tool_permission.decided'
|
|
818
|
+
];
|
|
819
|
+
/**
|
|
820
|
+
* Every `subject_type` this mapper can stamp — the column's whole range.
|
|
821
|
+
*
|
|
822
|
+
* Restated in the admin for the same reason as {@link AUDIT_KINDS}, and pinned
|
|
823
|
+
* by the same spec: the raw values are snake_cased machine tokens that a client
|
|
824
|
+
* has to translate into something a person reads.
|
|
825
|
+
*/
|
|
826
|
+
exports.AUDIT_SUBJECT_TYPES = [
|
|
827
|
+
'user',
|
|
828
|
+
'workspace',
|
|
829
|
+
'content_entry',
|
|
830
|
+
'content_type',
|
|
831
|
+
'api_token',
|
|
832
|
+
'media_asset',
|
|
833
|
+
'media_folder',
|
|
834
|
+
'login_attempt',
|
|
835
|
+
'segment',
|
|
836
|
+
'alarm_rule',
|
|
837
|
+
'saved_view',
|
|
838
|
+
'copilot_skill',
|
|
839
|
+
'copilot_run'
|
|
840
|
+
];
|
|
841
|
+
/**
|
|
842
|
+
* Reads the acting principal off the event payload (`attachActor`'s `actor`
|
|
843
|
+
* key).
|
|
844
|
+
*
|
|
845
|
+
* `type` defaults to `'user'` **only when there is an actor at all**: a row
|
|
846
|
+
* with no `actor_id` must not claim to have been performed by a user, so an
|
|
847
|
+
* absent actor yields `null` on every field. An `actor` written before
|
|
848
|
+
* `attachActor` carried a type is a person by construction — nothing else could
|
|
849
|
+
* be one — so defaulting it is a statement of fact rather than a guess.
|
|
850
|
+
*
|
|
851
|
+
* `label` is the readable name of a non-person actor (an API token's label);
|
|
852
|
+
* for a user the readable name is the email, which already has its own column.
|
|
853
|
+
*
|
|
854
|
+
* `via` says **how** — a copilot run, say. It is folded into `meta` rather than
|
|
855
|
+
* given a column: it is open per-mechanism data, it is absent on the
|
|
856
|
+
* overwhelming majority of rows, and nothing filters or indexes on it.
|
|
857
|
+
*/
|
|
393
858
|
function readActor(payload) {
|
|
394
859
|
const actor = payload.actor;
|
|
860
|
+
const id = nullableString(actor?.id);
|
|
861
|
+
if (!id) {
|
|
862
|
+
return { id: null, email: null, type: null, via: null };
|
|
863
|
+
}
|
|
395
864
|
return {
|
|
396
|
-
id
|
|
397
|
-
email: nullableString(actor?.email)
|
|
865
|
+
id,
|
|
866
|
+
email: nullableString(actor?.email) ?? nullableString(actor?.label),
|
|
867
|
+
type: nullableString(actor?.type) ?? 'user',
|
|
868
|
+
via: actor?.via && typeof actor.via === 'object'
|
|
869
|
+
? actor.via
|
|
870
|
+
: null
|
|
398
871
|
};
|
|
399
872
|
}
|
|
400
873
|
/**
|
|
@@ -416,14 +889,22 @@ function toAuditRow(event) {
|
|
|
416
889
|
}
|
|
417
890
|
const facet = mapper(event);
|
|
418
891
|
const actor = readActor(event.payload);
|
|
892
|
+
// `via` is merged into the kind's own meta rather than replacing it: a
|
|
893
|
+
// copilot-applied entry update is still an entry update, and a reader wants
|
|
894
|
+
// the changed fields *and* the run that changed them.
|
|
895
|
+
const meta = actor.via
|
|
896
|
+
? { ...(facet.meta ?? {}), via: actor.via }
|
|
897
|
+
: facet.meta;
|
|
419
898
|
return {
|
|
420
899
|
id: event.eventId,
|
|
421
900
|
kind: facet.kind,
|
|
422
901
|
subjectType: facet.subjectType,
|
|
423
902
|
subjectId: facet.subjectId,
|
|
424
903
|
actorId: actor.id,
|
|
904
|
+
actorType: actor.type,
|
|
425
905
|
actorEmail: actor.email,
|
|
426
|
-
|
|
906
|
+
workspaceId: nullableString(event.payload.workspaceId),
|
|
907
|
+
meta,
|
|
427
908
|
at: event.occurredAt
|
|
428
909
|
};
|
|
429
910
|
}
|