@ddd-ts-kit/audit 0.0.0-stage → 0.1.1
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/README.md +559 -2
- package/dist/chain/audit-checkpoint.d.ts +7 -0
- package/dist/chain/audit-checkpoint.d.ts.map +1 -0
- package/dist/chain/audit-checkpoint.js +2 -0
- package/dist/chain/audit-checkpoint.js.map +1 -0
- package/dist/chain/canonicalize.d.ts +8 -0
- package/dist/chain/canonicalize.d.ts.map +1 -0
- package/dist/chain/canonicalize.js +26 -0
- package/dist/chain/canonicalize.js.map +1 -0
- package/dist/chain/chain-head.d.ts +8 -0
- package/dist/chain/chain-head.d.ts.map +1 -0
- package/dist/chain/chain-head.js +2 -0
- package/dist/chain/chain-head.js.map +1 -0
- package/dist/chain/chain-of-tenant.d.ts +9 -0
- package/dist/chain/chain-of-tenant.d.ts.map +1 -0
- package/dist/chain/chain-of-tenant.js +10 -0
- package/dist/chain/chain-of-tenant.js.map +1 -0
- package/dist/chain/empty-chain.d.ts +3 -0
- package/dist/chain/empty-chain.d.ts.map +1 -0
- package/dist/chain/empty-chain.js +5 -0
- package/dist/chain/empty-chain.js.map +1 -0
- package/dist/chain/genesis-hash.d.ts +3 -0
- package/dist/chain/genesis-hash.d.ts.map +1 -0
- package/dist/chain/genesis-hash.js +3 -0
- package/dist/chain/genesis-hash.js.map +1 -0
- package/dist/chain/hash-of.d.ts +5 -0
- package/dist/chain/hash-of.d.ts.map +1 -0
- package/dist/chain/hash-of.js +8 -0
- package/dist/chain/hash-of.js.map +1 -0
- package/dist/chain/head-after.d.ts +4 -0
- package/dist/chain/head-after.d.ts.map +1 -0
- package/dist/chain/head-after.js +4 -0
- package/dist/chain/head-after.js.map +1 -0
- package/dist/chain/seal-checkpoint.d.ts +4 -0
- package/dist/chain/seal-checkpoint.d.ts.map +1 -0
- package/dist/chain/seal-checkpoint.js +7 -0
- package/dist/chain/seal-checkpoint.js.map +1 -0
- package/dist/chain/seal.d.ts +5 -0
- package/dist/chain/seal.d.ts.map +1 -0
- package/dist/chain/seal.js +13 -0
- package/dist/chain/seal.js.map +1 -0
- package/dist/chain/sealing-options.d.ts +12 -0
- package/dist/chain/sealing-options.d.ts.map +1 -0
- package/dist/chain/sealing-options.js +2 -0
- package/dist/chain/sealing-options.js.map +1 -0
- package/dist/drizzle/audit-chains-table.d.ts +61 -0
- package/dist/drizzle/audit-chains-table.d.ts.map +1 -0
- package/dist/drizzle/audit-chains-table.js +12 -0
- package/dist/drizzle/audit-chains-table.js.map +1 -0
- package/dist/drizzle/audit-entries-table.d.ts +424 -0
- package/dist/drizzle/audit-entries-table.d.ts.map +1 -0
- package/dist/drizzle/audit-entries-table.js +44 -0
- package/dist/drizzle/audit-entries-table.js.map +1 -0
- package/dist/drizzle/audit-guard-statements.d.ts +7 -0
- package/dist/drizzle/audit-guard-statements.d.ts.map +1 -0
- package/dist/drizzle/audit-guard-statements.js +10 -0
- package/dist/drizzle/audit-guard-statements.js.map +1 -0
- package/dist/drizzle/drizzle-audit-context.d.ts +7 -0
- package/dist/drizzle/drizzle-audit-context.d.ts.map +1 -0
- package/dist/drizzle/drizzle-audit-context.js +2 -0
- package/dist/drizzle/drizzle-audit-context.js.map +1 -0
- package/dist/drizzle/drizzle-audit-trail.d.ts +17 -0
- package/dist/drizzle/drizzle-audit-trail.d.ts.map +1 -0
- package/dist/drizzle/drizzle-audit-trail.js +28 -0
- package/dist/drizzle/drizzle-audit-trail.js.map +1 -0
- package/dist/drizzle/table-in.d.ts +4 -0
- package/dist/drizzle/table-in.d.ts.map +1 -0
- package/dist/drizzle/table-in.js +6 -0
- package/dist/drizzle/table-in.js.map +1 -0
- package/dist/drizzle.d.ts +6 -0
- package/dist/drizzle.d.ts.map +1 -0
- package/dist/drizzle.js +5 -0
- package/dist/drizzle.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/knex/create-audit-tables.d.ts +9 -0
- package/dist/knex/create-audit-tables.d.ts.map +1 -0
- package/dist/knex/create-audit-tables.js +12 -0
- package/dist/knex/create-audit-tables.js.map +1 -0
- package/dist/knex/drop-audit-tables.d.ts +5 -0
- package/dist/knex/drop-audit-tables.d.ts.map +1 -0
- package/dist/knex/drop-audit-tables.js +8 -0
- package/dist/knex/drop-audit-tables.js.map +1 -0
- package/dist/knex/knex-audit-context.d.ts +6 -0
- package/dist/knex/knex-audit-context.d.ts.map +1 -0
- package/dist/knex/knex-audit-context.js +2 -0
- package/dist/knex/knex-audit-context.js.map +1 -0
- package/dist/knex/knex-audit-trail.d.ts +14 -0
- package/dist/knex/knex-audit-trail.d.ts.map +1 -0
- package/dist/knex/knex-audit-trail.js +22 -0
- package/dist/knex/knex-audit-trail.js.map +1 -0
- package/dist/knex.d.ts +6 -0
- package/dist/knex.d.ts.map +1 -0
- package/dist/knex.js +4 -0
- package/dist/knex.js.map +1 -0
- package/dist/redaction/audit-masker.d.ts +8 -0
- package/dist/redaction/audit-masker.d.ts.map +1 -0
- package/dist/redaction/audit-masker.js +5 -0
- package/dist/redaction/audit-masker.js.map +1 -0
- package/dist/redaction/audit-projection.d.ts +13 -0
- package/dist/redaction/audit-projection.d.ts.map +1 -0
- package/dist/redaction/audit-projection.js +2 -0
- package/dist/redaction/audit-projection.js.map +1 -0
- package/dist/redaction/audited-field.d.ts +3 -0
- package/dist/redaction/audited-field.d.ts.map +1 -0
- package/dist/redaction/audited-field.js +2 -0
- package/dist/redaction/audited-field.js.map +1 -0
- package/dist/redaction/audited-fields.d.ts +16 -0
- package/dist/redaction/audited-fields.d.ts.map +1 -0
- package/dist/redaction/audited-fields.js +24 -0
- package/dist/redaction/audited-fields.js.map +1 -0
- package/dist/redaction/audited-message.d.ts +9 -0
- package/dist/redaction/audited-message.d.ts.map +1 -0
- package/dist/redaction/audited-message.js +2 -0
- package/dist/redaction/audited-message.js.map +1 -0
- package/dist/redaction/audited-projection.d.ts +10 -0
- package/dist/redaction/audited-projection.d.ts.map +1 -0
- package/dist/redaction/audited-projection.js +9 -0
- package/dist/redaction/audited-projection.js.map +1 -0
- package/dist/redaction/changes-of.d.ts +11 -0
- package/dist/redaction/changes-of.d.ts.map +1 -0
- package/dist/redaction/changes-of.js +37 -0
- package/dist/redaction/changes-of.js.map +1 -0
- package/dist/redaction/hmac-masker.d.ts +12 -0
- package/dist/redaction/hmac-masker.d.ts.map +1 -0
- package/dist/redaction/hmac-masker.js +14 -0
- package/dist/redaction/hmac-masker.js.map +1 -0
- package/dist/redaction/masked-field.d.ts +6 -0
- package/dist/redaction/masked-field.d.ts.map +1 -0
- package/dist/redaction/masked-field.js +2 -0
- package/dist/redaction/masked-field.js.map +1 -0
- package/dist/redaction/masked.d.ts +3 -0
- package/dist/redaction/masked.d.ts.map +1 -0
- package/dist/redaction/masked.js +4 -0
- package/dist/redaction/masked.js.map +1 -0
- package/dist/redaction/masking-subject.d.ts +9 -0
- package/dist/redaction/masking-subject.d.ts.map +1 -0
- package/dist/redaction/masking-subject.js +2 -0
- package/dist/redaction/masking-subject.js.map +1 -0
- package/dist/redaction/missing-masker-error.d.ts +5 -0
- package/dist/redaction/missing-masker-error.d.ts.map +1 -0
- package/dist/redaction/missing-masker-error.js +8 -0
- package/dist/redaction/missing-masker-error.js.map +1 -0
- package/dist/search/comparing.d.ts +8 -0
- package/dist/search/comparing.d.ts.map +1 -0
- package/dist/search/comparing.js +23 -0
- package/dist/search/comparing.js.map +1 -0
- package/dist/search/cursor-position.d.ts +7 -0
- package/dist/search/cursor-position.d.ts.map +1 -0
- package/dist/search/cursor-position.js +2 -0
- package/dist/search/cursor-position.js.map +1 -0
- package/dist/search/decode-cursor.d.ts +3 -0
- package/dist/search/decode-cursor.d.ts.map +1 -0
- package/dist/search/decode-cursor.js +21 -0
- package/dist/search/decode-cursor.js.map +1 -0
- package/dist/search/encode-cursor.d.ts +3 -0
- package/dist/search/encode-cursor.d.ts.map +1 -0
- package/dist/search/encode-cursor.js +5 -0
- package/dist/search/encode-cursor.js.map +1 -0
- package/dist/search/matches.d.ts +5 -0
- package/dist/search/matches.d.ts.map +1 -0
- package/dist/search/matches.js +34 -0
- package/dist/search/matches.js.map +1 -0
- package/dist/search/order-of.d.ts +4 -0
- package/dist/search/order-of.d.ts.map +1 -0
- package/dist/search/order-of.js +5 -0
- package/dist/search/order-of.js.map +1 -0
- package/dist/search/search-order.d.ts +6 -0
- package/dist/search/search-order.d.ts.map +1 -0
- package/dist/search/search-order.js +2 -0
- package/dist/search/search-order.js.map +1 -0
- package/dist/sources/allowed-reads.d.ts +9 -0
- package/dist/sources/allowed-reads.d.ts.map +1 -0
- package/dist/sources/allowed-reads.js +2 -0
- package/dist/sources/allowed-reads.js.map +1 -0
- package/dist/sources/audit-decisions.d.ts +19 -0
- package/dist/sources/audit-decisions.d.ts.map +1 -0
- package/dist/sources/audit-decisions.js +48 -0
- package/dist/sources/audit-decisions.js.map +1 -0
- package/dist/sources/audit-fact.d.ts +4 -0
- package/dist/sources/audit-fact.d.ts.map +1 -0
- package/dist/sources/audit-fact.js +2 -0
- package/dist/sources/audit-fact.js.map +1 -0
- package/dist/sources/audit-integration-events.d.ts +23 -0
- package/dist/sources/audit-integration-events.d.ts.map +1 -0
- package/dist/sources/audit-integration-events.js +59 -0
- package/dist/sources/audit-integration-events.js.map +1 -0
- package/dist/sources/audit-middleware.d.ts +33 -0
- package/dist/sources/audit-middleware.d.ts.map +1 -0
- package/dist/sources/audit-middleware.js +113 -0
- package/dist/sources/audit-middleware.js.map +1 -0
- package/dist/sources/audit-record-of.d.ts +10 -0
- package/dist/sources/audit-record-of.d.ts.map +1 -0
- package/dist/sources/audit-record-of.js +10 -0
- package/dist/sources/audit-record-of.js.map +1 -0
- package/dist/sources/audit-source.d.ts +8 -0
- package/dist/sources/audit-source.d.ts.map +1 -0
- package/dist/sources/audit-source.js +2 -0
- package/dist/sources/audit-source.js.map +1 -0
- package/dist/sources/audit-subscription.d.ts +7 -0
- package/dist/sources/audit-subscription.d.ts.map +1 -0
- package/dist/sources/audit-subscription.js +2 -0
- package/dist/sources/audit-subscription.js.map +1 -0
- package/dist/sources/audited-events.d.ts +7 -0
- package/dist/sources/audited-events.d.ts.map +1 -0
- package/dist/sources/audited-events.js +2 -0
- package/dist/sources/audited-events.js.map +1 -0
- package/dist/sources/authorization-decision.d.ts +21 -0
- package/dist/sources/authorization-decision.d.ts.map +1 -0
- package/dist/sources/authorization-decision.js +2 -0
- package/dist/sources/authorization-decision.js.map +1 -0
- package/dist/sources/record-in.d.ts +5 -0
- package/dist/sources/record-in.d.ts.map +1 -0
- package/dist/sources/record-in.js +19 -0
- package/dist/sources/record-in.js.map +1 -0
- package/dist/sql/audit-chains-table-name.d.ts +2 -0
- package/dist/sql/audit-chains-table-name.d.ts.map +1 -0
- package/dist/sql/audit-chains-table-name.js +2 -0
- package/dist/sql/audit-chains-table-name.js.map +1 -0
- package/dist/sql/audit-entries-table-name.d.ts +2 -0
- package/dist/sql/audit-entries-table-name.d.ts.map +1 -0
- package/dist/sql/audit-entries-table-name.js +2 -0
- package/dist/sql/audit-entries-table-name.js.map +1 -0
- package/dist/sql/audit-guard-sql.d.ts +8 -0
- package/dist/sql/audit-guard-sql.d.ts.map +1 -0
- package/dist/sql/audit-guard-sql.js +17 -0
- package/dist/sql/audit-guard-sql.js.map +1 -0
- package/dist/sql/audit-tables-options.d.ts +5 -0
- package/dist/sql/audit-tables-options.d.ts.map +1 -0
- package/dist/sql/audit-tables-options.js +2 -0
- package/dist/sql/audit-tables-options.js.map +1 -0
- package/dist/sql/audit-tables-sql.d.ts +7 -0
- package/dist/sql/audit-tables-sql.d.ts.map +1 -0
- package/dist/sql/audit-tables-sql.js +48 -0
- package/dist/sql/audit-tables-sql.js.map +1 -0
- package/dist/sql/audit-tables.d.ts +8 -0
- package/dist/sql/audit-tables.d.ts.map +1 -0
- package/dist/sql/audit-tables.js +7 -0
- package/dist/sql/audit-tables.js.map +1 -0
- package/dist/sql/drop-audit-tables-sql.d.ts +4 -0
- package/dist/sql/drop-audit-tables-sql.d.ts.map +1 -0
- package/dist/sql/drop-audit-tables-sql.js +12 -0
- package/dist/sql/drop-audit-tables-sql.js.map +1 -0
- package/dist/sql/entry-of.d.ts +5 -0
- package/dist/sql/entry-of.d.ts.map +1 -0
- package/dist/sql/entry-of.js +109 -0
- package/dist/sql/entry-of.js.map +1 -0
- package/dist/sql/insert-of.d.ts +4 -0
- package/dist/sql/insert-of.d.ts.map +1 -0
- package/dist/sql/insert-of.js +51 -0
- package/dist/sql/insert-of.js.map +1 -0
- package/dist/sql/named.d.ts +3 -0
- package/dist/sql/named.d.ts.map +1 -0
- package/dist/sql/named.js +6 -0
- package/dist/sql/named.js.map +1 -0
- package/dist/sql/postgres-audit-trail.d.ts +25 -0
- package/dist/sql/postgres-audit-trail.d.ts.map +1 -0
- package/dist/sql/postgres-audit-trail.js +111 -0
- package/dist/sql/postgres-audit-trail.js.map +1 -0
- package/dist/sql/qualified.d.ts +3 -0
- package/dist/sql/qualified.d.ts.map +1 -0
- package/dist/sql/qualified.js +5 -0
- package/dist/sql/qualified.js.map +1 -0
- package/dist/sql/rows-of.d.ts +4 -0
- package/dist/sql/rows-of.d.ts.map +1 -0
- package/dist/sql/rows-of.js +14 -0
- package/dist/sql/rows-of.js.map +1 -0
- package/dist/sql/sql-row.d.ts +2 -0
- package/dist/sql/sql-row.d.ts.map +1 -0
- package/dist/sql/sql-row.js +2 -0
- package/dist/sql/sql-row.js.map +1 -0
- package/dist/sql/sql-session.d.ts +9 -0
- package/dist/sql/sql-session.d.ts.map +1 -0
- package/dist/sql/sql-session.js +2 -0
- package/dist/sql/sql-session.js.map +1 -0
- package/dist/sql/sql.d.ts +12 -0
- package/dist/sql/sql.d.ts.map +1 -0
- package/dist/sql/sql.js +35 -0
- package/dist/sql/sql.js.map +1 -0
- package/dist/testing/audit-trail-contract.d.ts +16 -0
- package/dist/testing/audit-trail-contract.d.ts.map +1 -0
- package/dist/testing/audit-trail-contract.js +208 -0
- package/dist/testing/audit-trail-contract.js.map +1 -0
- package/dist/testing/audit-trail-under-contract.d.ts +4 -0
- package/dist/testing/audit-trail-under-contract.d.ts.map +1 -0
- package/dist/testing/audit-trail-under-contract.js +2 -0
- package/dist/testing/audit-trail-under-contract.js.map +1 -0
- package/dist/testing/records/a-record.d.ts +3 -0
- package/dist/testing/records/a-record.d.ts.map +1 -0
- package/dist/testing/records/a-record.js +14 -0
- package/dist/testing/records/a-record.js.map +1 -0
- package/dist/testing/records/full-record.d.ts +3 -0
- package/dist/testing/records/full-record.d.ts.map +1 -0
- package/dist/testing/records/full-record.js +16 -0
- package/dist/testing/records/full-record.js.map +1 -0
- package/dist/testing/records/in-tenant.d.ts +3 -0
- package/dist/testing/records/in-tenant.d.ts.map +1 -0
- package/dist/testing/records/in-tenant.js +5 -0
- package/dist/testing/records/in-tenant.js.map +1 -0
- package/dist/testing/records/journal.d.ts +4 -0
- package/dist/testing/records/journal.d.ts.map +1 -0
- package/dist/testing/records/journal.js +32 -0
- package/dist/testing/records/journal.js.map +1 -0
- package/dist/testing/records/later.d.ts +4 -0
- package/dist/testing/records/later.d.ts.map +1 -0
- package/dist/testing/records/later.js +6 -0
- package/dist/testing/records/later.js.map +1 -0
- package/dist/testing.d.ts +3 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +2 -0
- package/dist/testing.js.map +1 -0
- package/dist/trail/audit-trail-options.d.ts +8 -0
- package/dist/trail/audit-trail-options.d.ts.map +1 -0
- package/dist/trail/audit-trail-options.js +2 -0
- package/dist/trail/audit-trail-options.js.map +1 -0
- package/dist/trail/in-memory-audit-trail.d.ts +16 -0
- package/dist/trail/in-memory-audit-trail.d.ts.map +1 -0
- package/dist/trail/in-memory-audit-trail.js +48 -0
- package/dist/trail/in-memory-audit-trail.js.map +1 -0
- package/dist/verify/chain-break-kind.d.ts +8 -0
- package/dist/verify/chain-break-kind.d.ts.map +1 -0
- package/dist/verify/chain-break-kind.js +2 -0
- package/dist/verify/chain-break-kind.js.map +1 -0
- package/dist/verify/chain-break.d.ts +12 -0
- package/dist/verify/chain-break.d.ts.map +1 -0
- package/dist/verify/chain-break.js +2 -0
- package/dist/verify/chain-break.js.map +1 -0
- package/dist/verify/chain-verification.d.ts +10 -0
- package/dist/verify/chain-verification.d.ts.map +1 -0
- package/dist/verify/chain-verification.js +2 -0
- package/dist/verify/chain-verification.js.map +1 -0
- package/dist/verify/verify-chain.d.ts +20 -0
- package/dist/verify/verify-chain.d.ts.map +1 -0
- package/dist/verify/verify-chain.js +110 -0
- package/dist/verify/verify-chain.js.map +1 -0
- package/package.json +70 -4
package/README.md
CHANGED
|
@@ -1,3 +1,560 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ddd-ts-kit/audit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A tamper-evident audit trail that the use cases never write to. Who was allowed or refused
|
|
4
|
+
what, which command ran and how it ended, which integration event occurred: each fact is an
|
|
5
|
+
append-only entry, hash-chained to the one before it, recorded by a bus middleware, the
|
|
6
|
+
authorization hook or an event subscription. No handler and no aggregate contains an audit
|
|
7
|
+
call.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npm i @ddd-ts-kit/audit
|
|
11
|
+
npm i knex pg # only for @ddd-ts-kit/audit/knex
|
|
12
|
+
npm i drizzle-orm # only for @ddd-ts-kit/audit/drizzle
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
| Entry | Holds | Imports |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `@ddd-ts-kit/audit` | `InMemoryAuditTrail`, the three sources, `auditedFields`, the `AuditMasker` port and `hmacMasker`, `verifyChain`; re-exported from the kernel: `AuditEntry`, the `AuditTrail` and `AuditReader` ports and the types they name | the kernel only |
|
|
18
|
+
| `@ddd-ts-kit/audit/knex` | `KnexAuditTrail`, `createAuditTables`, `dropAuditTables` | `knex` (optional peer) |
|
|
19
|
+
| `@ddd-ts-kit/audit/drizzle` | `DrizzleAuditTrail`, `auditEntriesTable`, `auditChainsTable`, `auditGuardStatements` | `drizzle-orm` (optional peer) |
|
|
20
|
+
| `@ddd-ts-kit/audit/testing` | `auditTrailContract` | `@ddd-ts-kit/testing` (optional peer), the runner you pass |
|
|
21
|
+
|
|
22
|
+
## The idea
|
|
23
|
+
|
|
24
|
+
An audit trail answers "who did what, when, and was it allowed" to somebody who does not trust
|
|
25
|
+
the application: an auditor, a customer, a regulator. Three consequences:
|
|
26
|
+
|
|
27
|
+
- **It is fed from outside the use cases.** A handler that must remember to call the audit
|
|
28
|
+
will forget, and an aggregate that calls it has stopped being a model. Authorization
|
|
29
|
+
decisions, commands and integration events already pass through three places; the entries are
|
|
30
|
+
written there.
|
|
31
|
+
- **It is append-only and each entry seals the previous one.** Changing, removing or
|
|
32
|
+
reordering an entry breaks the chain at a place `verifyChain` names.
|
|
33
|
+
- **It holds identifiers and codes, never content.** Same rule as tracing and the aspects
|
|
34
|
+
package: class names, permission ids, error codes; never arguments, results or messages.
|
|
35
|
+
A value only enters when the project declares it, field by field.
|
|
36
|
+
|
|
37
|
+
It is not a log (logs are sampled, rotated and lost), not event sourcing (it does not rebuild
|
|
38
|
+
state) and not the `AuditStamp` of the kernel (`createdBy`, `updatedBy`: the last writer of a
|
|
39
|
+
row, not the history).
|
|
40
|
+
|
|
41
|
+
## The entry
|
|
42
|
+
|
|
43
|
+
The entry is a standard type of the kernel, and the two ports, `AuditTrail` and `AuditReader`,
|
|
44
|
+
are abstract classes of the kernel, so that code which records or lists depends on the kernel
|
|
45
|
+
alone. A port class is also what a binding is keyed by: there is no token beside it. This
|
|
46
|
+
package re-exports them, and holds everything that makes a trail tamper-evident: the chain, the
|
|
47
|
+
sources, redaction, verification and the stores.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
interface AuditEntry {
|
|
51
|
+
id: string;
|
|
52
|
+
chain: string; // 'tenant:<id>', or 'global' for work that names no tenant
|
|
53
|
+
sequence: number; // position in the chain: 1, 2, 3, without a gap
|
|
54
|
+
occurredAt: Date;
|
|
55
|
+
category: 'authorization' | 'command' | 'query' | 'integration-event' | 'authentication' | 'data-access' | string;
|
|
56
|
+
action: string; // a permission id, a command name, an event type
|
|
57
|
+
outcome: 'allowed' | 'denied' | 'succeeded' | 'failed';
|
|
58
|
+
reason?: string; // a stable code: NO_MATCHING_ROLE, the code of a DomainError
|
|
59
|
+
actor: { id: string; type: ActorType | 'unknown'; tenantId?: string };
|
|
60
|
+
tenantId?: string; // the tenant of the work in progress
|
|
61
|
+
impersonatedBy?: string;
|
|
62
|
+
sessionId?: string;
|
|
63
|
+
resource?: { type: string; id: string };
|
|
64
|
+
context?: string; // bounded context
|
|
65
|
+
correlationId: string;
|
|
66
|
+
causationId?: string;
|
|
67
|
+
origin: ExecutionOrigin; // 'http', 'event', 'schedule', 'cli', ...
|
|
68
|
+
changes?: Record<string, string | number | boolean | null | { masked: string }>;
|
|
69
|
+
extensions: AuditEntryExtensionValues;
|
|
70
|
+
previousHash: string;
|
|
71
|
+
hash: string;
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
A source hands an `AuditRecord` (the entry without `chain`, `sequence`, `previousHash` and
|
|
76
|
+
`hash`) to the trail, which seals it. Actor, tenant, session, impersonation, correlation,
|
|
77
|
+
causation and origin are read from the kernel `ExecutionScope`, never threaded through a use
|
|
78
|
+
case.
|
|
79
|
+
|
|
80
|
+
A project adds its own fields by declaration merging, as for the kernel `Session`. They live
|
|
81
|
+
under `extensions`, are optional when read, and must be JSON (a `Date` or a class reads as
|
|
82
|
+
`never`), since they are hashed and stored as they are. The interface is the kernel's: augment
|
|
83
|
+
`@ddd-ts-kit/kernel`, next to the other extensions of the project (augmenting
|
|
84
|
+
`@ddd-ts-kit/audit` still works):
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
declare module '@ddd-ts-kit/kernel' {
|
|
88
|
+
interface AuditEntryExtensions {
|
|
89
|
+
readonly region: 'eu' | 'us';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### One chain per tenant
|
|
95
|
+
|
|
96
|
+
Each entry belongs to one chain: `tenant:<id>` when the work in progress names a tenant,
|
|
97
|
+
`global` otherwise (`chainOfTenant`). The reasons: a tenant can be verified, exported and
|
|
98
|
+
handed over without the entries of the others; appends to a chain are serialized, so one chain
|
|
99
|
+
for everything would make every command of the system wait for the same lock; and an
|
|
100
|
+
offboarded tenant leaves chains that still verify. The price: the order between entries of two
|
|
101
|
+
tenants is given by `occurredAt` only, not proven by a hash. Pass `chainOf` to a trail to
|
|
102
|
+
decide otherwise (one chain per bounded context, a single chain for a small system).
|
|
103
|
+
|
|
104
|
+
### The hash
|
|
105
|
+
|
|
106
|
+
`hash` is the SHA-256, in lower-case hexadecimal, of the canonical form of the entry without
|
|
107
|
+
`hash`, so `previousHash`, `chain` and `sequence` are part of what is sealed. The canonical
|
|
108
|
+
form (`canonicalize`) is meant to be computed again by any program, in any language:
|
|
109
|
+
|
|
110
|
+
- JSON without whitespace, encoded in UTF-8;
|
|
111
|
+
- the keys of every object sorted by UTF-16 code unit, at every depth;
|
|
112
|
+
- a property whose value is `undefined` left out;
|
|
113
|
+
- `occurredAt` written in ISO 8601 UTC with milliseconds (`2026-03-01T10:00:00.000Z`);
|
|
114
|
+
- strings and numbers as `JSON.stringify` writes them.
|
|
115
|
+
|
|
116
|
+
The first entry of a chain has `previousHash` equal to `GENESIS_HASH` (64 zeros).
|
|
117
|
+
|
|
118
|
+
## Wiring the sources
|
|
119
|
+
|
|
120
|
+
Everything happens in the composition root. Assembled by hand first; the same pieces bound in
|
|
121
|
+
an application of `@ddd-ts-kit/di` come [after the sources](#in-an-application-of-ddd-ts-kitdi).
|
|
122
|
+
The order of the command middlewares matters:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// src/platform/audit/audit.ts
|
|
126
|
+
import { unitOfWorkMiddleware } from '@ddd-ts-kit/kernel';
|
|
127
|
+
import { authorizationMiddleware } from '@ddd-ts-kit/auth';
|
|
128
|
+
import { subscribeAll } from '@ddd-ts-kit/events';
|
|
129
|
+
import { auditDecisions, auditIntegrationEvents, auditMiddleware, auditedFields, hmacMasker, masked } from '@ddd-ts-kit/audit';
|
|
130
|
+
import { KnexAuditTrail } from '@ddd-ts-kit/audit/knex';
|
|
131
|
+
|
|
132
|
+
const trail = new KnexAuditTrail(unitOfWork, { schema: 'audit' }); // anything with `executor()`: the unit of work of @ddd-ts-kit/knex, or its context
|
|
133
|
+
const source = { scope, clock, ids };
|
|
134
|
+
|
|
135
|
+
commandBus
|
|
136
|
+
// 1. who may: refused before any transaction opens
|
|
137
|
+
.use(authorizationMiddleware({ authorizer, map, onDecision: auditDecisions(trail, { ...source, sensitivePermissions: ['records:medical-file.read'] }) }))
|
|
138
|
+
// 2. what ran and how it ended: opens the unit of work itself
|
|
139
|
+
.use(auditMiddleware(trail, {
|
|
140
|
+
...source,
|
|
141
|
+
unitOfWork,
|
|
142
|
+
masker: hmacMasker(config.auditMaskingSecret, { keyId: 'k1' }),
|
|
143
|
+
fields: [auditedFields(RescheduleAppointmentCommand, ['status', 'startsAt', masked('patientEmail')], {
|
|
144
|
+
resource: (command) => ({ type: 'appointment', id: command.appointmentId }),
|
|
145
|
+
context: 'scheduling',
|
|
146
|
+
})],
|
|
147
|
+
}))
|
|
148
|
+
// 3. joins the transaction opened by 2
|
|
149
|
+
.use(unitOfWorkMiddleware(unitOfWork));
|
|
150
|
+
|
|
151
|
+
// 4. what occurred between contexts
|
|
152
|
+
subscribeAll(eventBus, auditIntegrationEvents(trail, { registry, clock, ids, processed, unitOfWork }));
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### a. Authorization decisions
|
|
156
|
+
|
|
157
|
+
`auditDecisions(trail, options)` returns the `onDecision` function of the authorization
|
|
158
|
+
middleware of `@ddd-ts-kit/auth`. The types match structurally: this package does not depend on
|
|
159
|
+
the auth package. Each entry has category `authorization`, the permission id as `action`,
|
|
160
|
+
`allowed` or `denied` as outcome, the denial code as `reason`, the resource of the decision.
|
|
161
|
+
|
|
162
|
+
What is recorded by default:
|
|
163
|
+
|
|
164
|
+
| Decision | Recorded |
|
|
165
|
+
|---|---|
|
|
166
|
+
| denied, command or query | always |
|
|
167
|
+
| allowed, anything that is not a read | always |
|
|
168
|
+
| allowed read of a permission listed in `sensitivePermissions` | always |
|
|
169
|
+
| any other allowed read | no |
|
|
170
|
+
|
|
171
|
+
Recording every list and every page view drowns the trail and makes it expensive to keep, so
|
|
172
|
+
the allowed reads are the ones you choose. `allowedReads: 'all'` records them all,
|
|
173
|
+
`allowedReads: { sample: 0.01 }` one in a hundred. A read is, by default, a decision whose
|
|
174
|
+
`kind` is `query`: the middleware takes it from the bus that carried the message, whatever the
|
|
175
|
+
class is named. A decision taken outside a bus names no kind and is recorded. Pass `isRead` to
|
|
176
|
+
decide otherwise.
|
|
177
|
+
|
|
178
|
+
When the decision concerns another actor than the one of the execution context (a consumer
|
|
179
|
+
sending a command on behalf of a user), the entry names the actor of the decision with type
|
|
180
|
+
`unknown`: its type and tenant are not in what the hook receives.
|
|
181
|
+
|
|
182
|
+
### b. Commands, and the unit of work
|
|
183
|
+
|
|
184
|
+
`auditMiddleware` records each command with outcome `succeeded` or `failed`, and the `code` of
|
|
185
|
+
the `DomainError` as reason. A failure that is not a `DomainError` is recorded as
|
|
186
|
+
`UNEXPECTED_ERROR`: neither its class nor its message enters the trail, they quote data.
|
|
187
|
+
`queries: [ReadMedicalFileQuery]` or `queries: 'all'` records queries too.
|
|
188
|
+
|
|
189
|
+
Two requirements pull in opposite directions: the entry of a **failed** command must survive
|
|
190
|
+
the rollback, and the entry of a **succeeded** command must not exist if its transaction did
|
|
191
|
+
not commit. A middleware placed inside the unit of work loses the first, one placed outside
|
|
192
|
+
loses the second (a crash between the commit and the record, or an entry written for a commit
|
|
193
|
+
that was then refused). Hence the arrangement: the middleware is installed **before**
|
|
194
|
+
`unitOfWorkMiddleware` and is given the unit of work.
|
|
195
|
+
|
|
196
|
+
- It opens the unit of work itself; `unitOfWorkMiddleware`, and any nested `run`, join that
|
|
197
|
+
transaction.
|
|
198
|
+
- After the handler returns, it writes the `succeeded` entry in that transaction. The entry
|
|
199
|
+
and the state change commit together, or neither exists. If the entry cannot be written, the
|
|
200
|
+
command fails and rolls back: no state change without its trace.
|
|
201
|
+
- When the handler throws, or the commit is refused, the transaction is gone; the middleware
|
|
202
|
+
then writes the `failed` entry outside of it, and rethrows the failure of the use case. If
|
|
203
|
+
that write fails too, the caller still receives the failure of the use case, and
|
|
204
|
+
`onLostRecord` is told.
|
|
205
|
+
|
|
206
|
+
Without `unitOfWork` (no database, the in-memory trail) the middleware records after the
|
|
207
|
+
handler. Recording through the outbox would also be atomic for successes, but an outbox row
|
|
208
|
+
shares the fate of the transaction, so it cannot carry the failures: it solves half of the
|
|
209
|
+
problem.
|
|
210
|
+
|
|
211
|
+
Put the authorization middleware before this one. A refused command then leaves one
|
|
212
|
+
`authorization denied` entry and opens no transaction; with the opposite order it would also
|
|
213
|
+
leave a `command failed` entry with reason `FORBIDDEN`.
|
|
214
|
+
|
|
215
|
+
### c. Integration events
|
|
216
|
+
|
|
217
|
+
`auditIntegrationEvents(trail, options)` returns one subscription per event type of the
|
|
218
|
+
registry, to hand to `subscribeAll`. It reads the envelope only (type, id, correlation), never
|
|
219
|
+
the payload: the entry says that `billing.invoice.issued` occurred, with the event id as
|
|
220
|
+
`causationId` and the correlation of the envelope. Delivery is at least once, so it
|
|
221
|
+
deduplicates by event id through the `ProcessedEventStore` it is given, under the consumer
|
|
222
|
+
name `audit`; with `unitOfWork`, the mark and the entry commit together.
|
|
223
|
+
|
|
224
|
+
### d. By hand, the exception
|
|
225
|
+
|
|
226
|
+
`AuditTrail.record` stays available for what none of the three sources can see: a
|
|
227
|
+
`data-access` entry written by infrastructure, such as an export. `auditRecordOf` fills
|
|
228
|
+
everything the scope knows:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
// infrastructure/external/appointment-export.ts
|
|
232
|
+
await trail.record(auditRecordOf({ scope, clock, ids }, {
|
|
233
|
+
category: 'data-access',
|
|
234
|
+
action: 'scheduling.appointments.export',
|
|
235
|
+
outcome: 'succeeded',
|
|
236
|
+
changes: { rows: 2000, format: 'csv' },
|
|
237
|
+
}));
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
This is infrastructure code. A call to the trail in `domain/` or `application/` is the defect
|
|
241
|
+
this package exists to prevent.
|
|
242
|
+
|
|
243
|
+
### In an application of `@ddd-ts-kit/di`
|
|
244
|
+
|
|
245
|
+
The store is bound to both ports, and the masker to its own, each under its class:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
// src/platform/audit/audit.module.ts
|
|
249
|
+
import { AuditReader, AuditTrail } from '@ddd-ts-kit/kernel';
|
|
250
|
+
import { defineContextModule } from '@ddd-ts-kit/di';
|
|
251
|
+
import { AuditMasker, hmacMasker } from '@ddd-ts-kit/audit';
|
|
252
|
+
import { KnexAuditTrail } from '@ddd-ts-kit/audit/knex';
|
|
253
|
+
|
|
254
|
+
export const trail = new KnexAuditTrail(unitOfWork, { schema: 'audit' }); // the unit of work bound to `UnitOfWork` and `KnexContext`
|
|
255
|
+
|
|
256
|
+
export const auditModule = defineContextModule({
|
|
257
|
+
name: 'audit',
|
|
258
|
+
register(builder) {
|
|
259
|
+
builder.bind(AuditTrail).toValue(trail);
|
|
260
|
+
builder.bind(AuditReader).toValue(trail);
|
|
261
|
+
builder.bind(AuditMasker).toValue(hmacMasker(config.auditMaskingSecret, { keyId: 'k1' }));
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`builder.bind(AuditTrail).toClass(KnexAuditTrail, [KnexContext])` binds a store with its default
|
|
267
|
+
options instead, `KnexContext` being the port of `@ddd-ts-kit/knex`.
|
|
268
|
+
|
|
269
|
+
The sources are installed where the buses are built. The command bus of `kernelModule()` runs
|
|
270
|
+
the use case guard, then the unit of work; the audit middleware has to sit between the two, so
|
|
271
|
+
the bus is given through the options:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
// src/main.ts
|
|
275
|
+
import { AuditTrail, InMemoryCommandBus, unitOfWorkMiddleware } from '@ddd-ts-kit/kernel';
|
|
276
|
+
import { UseCaseGuard, createApplication, kernelModule } from '@ddd-ts-kit/di';
|
|
277
|
+
import { authorizationModule } from '@ddd-ts-kit/auth/di';
|
|
278
|
+
import { AuditMasker, auditDecisions, auditMiddleware } from '@ddd-ts-kit/audit';
|
|
279
|
+
|
|
280
|
+
const source = { scope, clock, ids };
|
|
281
|
+
|
|
282
|
+
const app = createApplication({
|
|
283
|
+
modules: [
|
|
284
|
+
kernelModule({
|
|
285
|
+
executionScope: scope,
|
|
286
|
+
clock,
|
|
287
|
+
ids,
|
|
288
|
+
unitOfWork,
|
|
289
|
+
commandBus: (resolve) =>
|
|
290
|
+
new InMemoryCommandBus()
|
|
291
|
+
.use((message, next, info) => resolve(UseCaseGuard).check(message, next, info))
|
|
292
|
+
.use(auditMiddleware(resolve(AuditTrail), { ...source, unitOfWork, masker: resolve(AuditMasker), fields }))
|
|
293
|
+
.use(unitOfWorkMiddleware(unitOfWork)),
|
|
294
|
+
}),
|
|
295
|
+
auditModule,
|
|
296
|
+
schedulingModule,
|
|
297
|
+
authorizationModule({ authorizer, onDecision: auditDecisions(trail, { ...source, sensitivePermissions: ['records:medical-file.read'] }) }),
|
|
298
|
+
],
|
|
299
|
+
authorization: 'required',
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
A project generated by the CLI with `--audit on` (the default) has this wiring under
|
|
304
|
+
`src/platform/audit/`: `create-audit.ts` builds the trail and its three sources,
|
|
305
|
+
`audit.module.ts` binds the two ports, subscribes the integration events under the consumer
|
|
306
|
+
name `audit` and registers the protected `SearchAuditEntriesQuery`, and
|
|
307
|
+
`src/platform/buses/create-command-bus.ts` places the audit middleware between the use case
|
|
308
|
+
guard and the unit of work.
|
|
309
|
+
|
|
310
|
+
A handler or an adapter that needs the trail names the port in its dependencies and receives
|
|
311
|
+
the store: `[AuditReader]` for the query that lists entries (see [Querying](#querying)),
|
|
312
|
+
`[AuditTrail]` for the infrastructure adapter that records by hand.
|
|
313
|
+
|
|
314
|
+
## Redaction
|
|
315
|
+
|
|
316
|
+
**By default an entry carries no payload.** `changes` exists only for what was declared:
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
auditedFields(RescheduleAppointmentCommand, ['status', 'startsAt', masked('patientEmail')]);
|
|
320
|
+
auditedProjection(ExportAppointmentsCommand, (command) => ({ plain: { format: command.options.format } }));
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
- A field that is not listed is never read. The compiler refuses a name the message does not
|
|
324
|
+
have.
|
|
325
|
+
- A value is kept only if it is a string, a number, a boolean, `null`, a bigint (as text) or
|
|
326
|
+
a `Date` (as an ISO instant). An object is never stored whole: pick its scalars with
|
|
327
|
+
`auditedProjection`.
|
|
328
|
+
- `masked('patientEmail')` stores `{ masked: '<keyId>:<hex>' }`: the HMAC-SHA-256 of the value
|
|
329
|
+
under a secret (`hmacMasker`). Two entries about the same email carry the same mask, so
|
|
330
|
+
"every entry about this person" can be answered by masking the email again; the email itself
|
|
331
|
+
is not there, and without the secret a guess cannot be checked. A plain hash would not do: an
|
|
332
|
+
email or a phone number is guessed in seconds.
|
|
333
|
+
- A field declared `masked` without a masker is refused when the middleware is built; a
|
|
334
|
+
projection that asks for a mask without one fails the command at its first use.
|
|
335
|
+
|
|
336
|
+
**What must never go in**, declared or not:
|
|
337
|
+
|
|
338
|
+
- free text (a comment, a reason typed by a user, a message body): it contains anything;
|
|
339
|
+
- credentials and tokens, session cookies, API keys, one-time codes, even hashed;
|
|
340
|
+
- whole payloads, request bodies, aggregates, results of queries;
|
|
341
|
+
- error messages and stack traces: record the `code`;
|
|
342
|
+
- special categories of personal data (health, opinions, biometrics), even masked, unless a
|
|
343
|
+
decision record says why.
|
|
344
|
+
|
|
345
|
+
### The right to erasure and an append-only chain
|
|
346
|
+
|
|
347
|
+
An entry cannot be deleted without breaking its chain, and a regulation may require that
|
|
348
|
+
personal data be erased. The two coexist as long as the chain never holds the data:
|
|
349
|
+
|
|
350
|
+
1. **Keep personal data out.** An actor id, a resource id, a permission id are identifiers. If
|
|
351
|
+
your identifiers are themselves personal data (an email as user id), fix that first, or
|
|
352
|
+
mask them at the boundary.
|
|
353
|
+
2. **Mask what you must correlate.** A masked value is not readable; it is still linked to a
|
|
354
|
+
person for whoever holds the secret.
|
|
355
|
+
3. **Crypto-shredding for erasure.** Write a masker that derives one key per data subject (or
|
|
356
|
+
per tenant) from a key store, using the `MaskingSubject` it receives (`actorId`, `tenantId`,
|
|
357
|
+
`resource`). `AuditMasker` is a port, an abstract class with the one method `mask`: the
|
|
358
|
+
adapter `implements` it and is bound under it:
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
// infrastructure/external/per-subject-masker.ts
|
|
362
|
+
export class PerSubjectMasker implements AuditMasker {
|
|
363
|
+
constructor(private readonly keys: SubjectKeys) {}
|
|
364
|
+
|
|
365
|
+
async mask(value: AuditScalar, subject: MaskingSubject): Promise<string> {
|
|
366
|
+
const key = await this.keys.keyOf(subject.resource?.id ?? subject.actorId); // created on first use
|
|
367
|
+
|
|
368
|
+
return hmacMasker(key.secret, { keyId: key.id }).mask(value, subject);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
builder.bind(AuditMasker).toClass(PerSubjectMasker, [SubjectKeys]);
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
Erasing a person is deleting their key: every mask made with it becomes a random-looking
|
|
376
|
+
token nobody can recompute, the entries stay, the hashes stay valid. The key store is then
|
|
377
|
+
the system to protect and to back up with care; this package does not provide one.
|
|
378
|
+
|
|
379
|
+
What stays after erasure is "an actor with this opaque id did this at that time". Whether that
|
|
380
|
+
is acceptable is a legal question for the project, to answer before the first entry is written.
|
|
381
|
+
|
|
382
|
+
## Verification and anchoring
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
const outcome = await verifyChain(reader, { chain: 'tenant:acme' });
|
|
386
|
+
// { valid: true, checked: 1842 }
|
|
387
|
+
// { valid: false, checked: 311, break: { kind: 'altered-content', chain: 'tenant:acme', position: 311, sequence: 312, entryId: '...' } }
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
`verifyChain(entries | reader, { chain?, from?, to?, checkpoints? })` walks a chain in the
|
|
391
|
+
order given and reports the **first** place where it stops holding:
|
|
392
|
+
|
|
393
|
+
| Kind | Meaning |
|
|
394
|
+
|---|---|
|
|
395
|
+
| `altered-content` | the entry does not hash to its `hash`, does not link to the entry before it, or disagrees with a checkpoint |
|
|
396
|
+
| `missing-entry` | the expected sequence is nowhere in what was examined (a hole, a missing head, a chain shorter than `to`) |
|
|
397
|
+
| `reordered` | the expected sequence exists elsewhere, or a position appears twice |
|
|
398
|
+
|
|
399
|
+
`position` is the index among the examined entries of the chain, `sequence` the sequence that
|
|
400
|
+
was expected there. Given entries (an export), every chain found is verified, and the entries
|
|
401
|
+
are never sorted first: a shuffled file is reported as such. Given a reader, `chain` is
|
|
402
|
+
required and the range is read page by page, then held in memory: verify long chains by ranges
|
|
403
|
+
(`from`, `to`). A range starting above 1 trusts the entry before it.
|
|
404
|
+
|
|
405
|
+
**Why anchoring.** The chain protects against whoever can change some rows. Whoever can
|
|
406
|
+
rewrite the whole table (a database administrator, an attacker with the owner's role) can
|
|
407
|
+
recompute every hash from the altered entry onwards: the result is a perfectly valid chain. The
|
|
408
|
+
only defence is a copy of some hashes where that person cannot write. `sealing` hands a
|
|
409
|
+
checkpoint over every N entries of a chain:
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
new KnexAuditTrail(unitOfWork, {
|
|
413
|
+
schema: 'audit',
|
|
414
|
+
sealing: { every: 1000, onSeal: (checkpoint) => outbox.append([auditChainSealed.create(checkpoint, meta)]) },
|
|
415
|
+
});
|
|
416
|
+
// later, against the checkpoints kept elsewhere:
|
|
417
|
+
await verifyChain(reader, { chain, checkpoints });
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
The hook runs when the entry is written, inside the unit of work when there is one. Appending
|
|
421
|
+
the checkpoint to the outbox there makes it leave only if the entry commits; a hook that calls
|
|
422
|
+
a remote service directly may anchor an entry whose transaction rolls back afterwards. Where
|
|
423
|
+
the checkpoints go is the project's choice: another database under another role, an object
|
|
424
|
+
store with retention lock, a transparency log, a signed timestamp. A truncated tail (the last
|
|
425
|
+
entries removed) is only seen by comparing with a later checkpoint or by asking for `to`.
|
|
426
|
+
|
|
427
|
+
## Stores
|
|
428
|
+
|
|
429
|
+
`InMemoryAuditTrail` implements both ports: the fake for tests (`entries` is the store, to
|
|
430
|
+
assert and to tamper with) and enough for an application without a database.
|
|
431
|
+
|
|
432
|
+
The SQL stores are for **PostgreSQL**. They write on the executor of the unit of work
|
|
433
|
+
(`KnexContext.executor()`, `DrizzleContext.executor()`): inside a transaction the entry shares
|
|
434
|
+
its fate, outside it is written at once in a transaction of its own.
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
// knex: a migration of the project
|
|
438
|
+
import { createAuditTables, dropAuditTables } from '@ddd-ts-kit/audit/knex';
|
|
439
|
+
export const up = (knex) => createAuditTables(knex, { schema: 'audit' });
|
|
440
|
+
export const down = (knex) => dropAuditTables(knex, { schema: 'audit' });
|
|
441
|
+
|
|
442
|
+
// drizzle: in the schema files drizzle-kit reads
|
|
443
|
+
import { auditChainsTable, auditEntriesTable, auditGuardStatements } from '@ddd-ts-kit/audit/drizzle';
|
|
444
|
+
export const auditEntries = auditEntriesTable('audit');
|
|
445
|
+
export const auditChains = auditChainsTable('audit');
|
|
446
|
+
// and in a custom migration (drizzle-kit generate --custom), since drizzle-kit writes no trigger:
|
|
447
|
+
for (const statement of auditGuardStatements('audit')) {
|
|
448
|
+
await db.execute(sql.raw(statement));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
new DrizzleAuditTrail(unitOfWork, { schema: 'audit' });
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
Two tables: `audit_entries` (primary key `(chain, sequence)`, unique `id`, indexes for the
|
|
455
|
+
reader) and `audit_chains`, one row per chain holding its last sequence and hash.
|
|
456
|
+
|
|
457
|
+
**Concurrency.** An append inserts the head row of its chain if it is missing, then takes it
|
|
458
|
+
with `SELECT ... FOR UPDATE`, computes the entry from it, inserts the entry and moves the head.
|
|
459
|
+
The row lock lasts until the transaction ends, so two appends to one chain never read the same
|
|
460
|
+
head and the chain cannot fork; appends to other chains do not wait. The primary key refuses a
|
|
461
|
+
duplicate position should the lock ever be bypassed. Under `REPEATABLE READ` or `SERIALIZABLE`
|
|
462
|
+
the second append fails with a serialization error instead of waiting, and the unit of work is
|
|
463
|
+
retried like any other.
|
|
464
|
+
|
|
465
|
+
**Append-only in the database too.** The migration helper creates the function
|
|
466
|
+
`audit_entries_append_only` and two triggers: `UPDATE`, `DELETE` and `TRUNCATE` on
|
|
467
|
+
`audit_entries` raise SQLSTATE `23001`, whoever asks. Triggers stop mistakes and application
|
|
468
|
+
bugs; a role that owns the table can drop them. So the application should not own the table.
|
|
469
|
+
Run the migrations with an owner role and give the application role only what it needs:
|
|
470
|
+
|
|
471
|
+
```sql
|
|
472
|
+
revoke all on all tables in schema "audit" from public;
|
|
473
|
+
grant usage on schema "audit" to app;
|
|
474
|
+
grant select, insert on "audit"."audit_entries" to app;
|
|
475
|
+
grant select, insert, update on "audit"."audit_chains" to app;
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
The application can then neither rewrite an entry, nor disable or drop the guard. What remains
|
|
479
|
+
is the owner role and the superuser, which is what anchoring is for.
|
|
480
|
+
|
|
481
|
+
A new store passes the same suite as these:
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
import { auditTrailContract } from '@ddd-ts-kit/audit/testing';
|
|
485
|
+
auditTrailContract({ describe, it, expect, beforeEach }, { name: 'MyAuditTrail', createTrail: () => freshTrail(), tamper });
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
## Querying
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
const page = await reader.search(
|
|
492
|
+
{ tenantId: 'acme', actorId: 'ada', category: 'authorization', outcome: 'denied', from, to },
|
|
493
|
+
cursorPage({ limit: 50, sort: [{ field: 'occurredAt', direction: 'desc' }] }),
|
|
494
|
+
);
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Filters: `actorId`, `resource` (`{ type, id? }`), `action`, `category`, `outcome`, period
|
|
498
|
+
(`from` included, `to` excluded), `correlationId`, `tenantId`, `chain`, `fromSequence`,
|
|
499
|
+
`toSequence`. Pagination uses the kernel types: an offset page answers `total`, a cursor page
|
|
500
|
+
`nextCursor`. Sort by `occurredAt` (then chain and sequence, the order a person reads in) or by
|
|
501
|
+
`sequence` (chain by chain, the order of verification).
|
|
502
|
+
|
|
503
|
+
`AuditReader` is a port of its own because reading the trail is itself a sensitive act. Expose
|
|
504
|
+
it as a use case like any other, which makes it protected and audited by the mechanisms above:
|
|
505
|
+
|
|
506
|
+
```ts
|
|
507
|
+
// application/queries/search-audit-entries/search-audit-entries.query.ts
|
|
508
|
+
import { AuthorizedQuery, type AuditEntry, type Page } from '@ddd-ts-kit/kernel';
|
|
509
|
+
|
|
510
|
+
export class SearchAuditEntriesQuery extends AuthorizedQuery<Page<AuditEntry>> { /* actor, tenantId, filter, page */ }
|
|
511
|
+
|
|
512
|
+
// authorization: deny by default, one permission, flagged sensitive
|
|
513
|
+
requires(SearchAuditEntriesQuery, auditPermissions['trail.read']);
|
|
514
|
+
auditDecisions(trail, { ...source, sensitivePermissions: ['audit:trail.read'] });
|
|
515
|
+
auditMiddleware(trail, { ...source, queries: [SearchAuditEntriesQuery] });
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
// application/queries/search-audit-entries/search-audit-entries.handler.ts
|
|
520
|
+
import { AuditReader, type AuditEntry, type Page } from '@ddd-ts-kit/kernel';
|
|
521
|
+
import { QueryHandler } from '@ddd-ts-kit/kernel/declarations';
|
|
522
|
+
|
|
523
|
+
@QueryHandler(SearchAuditEntriesQuery, [AuditReader])
|
|
524
|
+
export class SearchAuditEntriesHandler {
|
|
525
|
+
constructor(private readonly reader: AuditReader) {}
|
|
526
|
+
|
|
527
|
+
execute(query: SearchAuditEntriesQuery): Promise<Page<AuditEntry>> {
|
|
528
|
+
return this.reader.search({ ...query.filter, tenantId: query.tenantId }, query.page);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
Whoever reads the trail leaves an entry in it. The handler depends on `AuditReader` of the
|
|
534
|
+
kernel, which `application/` may import, and receives the store bound to it. It scopes the
|
|
535
|
+
filter to the tenant of the actor (an access scope computed by the authorizer), so one tenant
|
|
536
|
+
never lists another's entries.
|
|
537
|
+
|
|
538
|
+
## Limits
|
|
539
|
+
|
|
540
|
+
- **PostgreSQL only** for the SQL stores: row locks, row comparison for cursors, `jsonb`,
|
|
541
|
+
triggers. Another database is another adapter of the two ports, held to the contract suite.
|
|
542
|
+
- **Appends to one chain are serialized**, and inside a unit of work the head stays locked
|
|
543
|
+
until the commit. `auditMiddleware` writes last, so the window is short; a handler that
|
|
544
|
+
records by hand early and then works for long makes the other writers of its tenant wait.
|
|
545
|
+
- **A rolled back unit of work frees its positions**: the next entry reuses the sequence.
|
|
546
|
+
There is never a gap, and never an entry for work that did not commit.
|
|
547
|
+
- **A command sent from inside another unit of work** (a consumer whose `consume` opened one)
|
|
548
|
+
shares its fate: if that outer transaction rolls back, the `failed` entry goes with it.
|
|
549
|
+
- **The chain proves integrity, not completeness of capture**: a fact that never reached a
|
|
550
|
+
source (a script writing to the database, a route bypassing the bus) is not there. Keeping
|
|
551
|
+
every route on the bus is what `authz/declared` and the dependency rules are for.
|
|
552
|
+
- **`occurredAt` is the clock of the instance that recorded**; inside a chain, the order is
|
|
553
|
+
the sequence.
|
|
554
|
+
- **Verification reads the range into memory**, and trusts the entry before `from`.
|
|
555
|
+
- **No retention or archiving policy** is provided: how long entries are kept, and where old
|
|
556
|
+
chains go, is a decision of the project. Deleting old entries means dropping the guard on
|
|
557
|
+
purpose, by the owner role, after anchoring the entry that becomes the new start.
|
|
558
|
+
- drizzle wraps driver errors and prints the parameters of the failed statement in the
|
|
559
|
+
message of its error. The entry of an audit insert holds identifiers and codes only; still,
|
|
560
|
+
log the `code` of such an error, not its message.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit-checkpoint.d.ts","sourceRoot":"","sources":["../../src/chain/audit-checkpoint.ts"],"names":[],"mappings":"AAAA,gGAAgG;AAChG,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit-checkpoint.js","sourceRoot":"","sources":["../../src/chain/audit-checkpoint.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical form the hash is computed over, so that any program can compute it again:
|
|
3
|
+
* JSON without whitespace, the keys of every object sorted by UTF-16 code unit (the order of
|
|
4
|
+
* `Array.prototype.sort`), a property whose value is `undefined` left out, a date written in
|
|
5
|
+
* ISO 8601 UTC with milliseconds, numbers and strings as `JSON.stringify` writes them.
|
|
6
|
+
*/
|
|
7
|
+
export declare function canonicalize(value: unknown): string;
|
|
8
|
+
//# sourceMappingURL=canonicalize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canonicalize.d.ts","sourceRoot":"","sources":["../../src/chain/canonicalize.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAcnD"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical form the hash is computed over, so that any program can compute it again:
|
|
3
|
+
* JSON without whitespace, the keys of every object sorted by UTF-16 code unit (the order of
|
|
4
|
+
* `Array.prototype.sort`), a property whose value is `undefined` left out, a date written in
|
|
5
|
+
* ISO 8601 UTC with milliseconds, numbers and strings as `JSON.stringify` writes them.
|
|
6
|
+
*/
|
|
7
|
+
export function canonicalize(value) {
|
|
8
|
+
if (value instanceof Date) {
|
|
9
|
+
return JSON.stringify(value.toISOString());
|
|
10
|
+
}
|
|
11
|
+
if (Array.isArray(value)) {
|
|
12
|
+
return `[${value.map(canonicalize).join(',')}]`;
|
|
13
|
+
}
|
|
14
|
+
if (typeof value === 'object' && value !== null) {
|
|
15
|
+
return canonicalObject(value);
|
|
16
|
+
}
|
|
17
|
+
return JSON.stringify(value ?? null);
|
|
18
|
+
}
|
|
19
|
+
function canonicalObject(value) {
|
|
20
|
+
const members = Object.entries(value)
|
|
21
|
+
.filter(([, member]) => member !== undefined)
|
|
22
|
+
.sort(([left], [right]) => (left < right ? -1 : 1))
|
|
23
|
+
.map(([key, member]) => `${JSON.stringify(key)}:${canonicalize(member)}`);
|
|
24
|
+
return `{${members.join(',')}}`;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=canonicalize.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canonicalize.js","sourceRoot":"","sources":["../../src/chain/canonicalize.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,KAAc;IACzC,IAAI,KAAK,YAAY,IAAI,EAAE,CAAC;QAC1B,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC;IAC7C,CAAC;IAED,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,OAAO,IAAI,KAAK,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;IAClD,CAAC;IAED,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;QAChD,OAAO,eAAe,CAAC,KAAK,CAAC,CAAC;IAChC,CAAC;IAED,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC;AACvC,CAAC;AAED,SAAS,eAAe,CAAC,KAAa;IACpC,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;SAClC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC,MAAM,KAAK,SAAS,CAAC;SAC5C,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;SAClD,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,YAAY,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IAE5E,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;AAClC,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The last entry of a chain, which the next one links to. */
|
|
2
|
+
export interface ChainHead {
|
|
3
|
+
readonly chain: string;
|
|
4
|
+
/** 0 for a chain that holds no entry yet. */
|
|
5
|
+
readonly sequence: number;
|
|
6
|
+
readonly hash: string;
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=chain-head.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chain-head.d.ts","sourceRoot":"","sources":["../../src/chain/chain-head.ts"],"names":[],"mappings":"AAAA,8DAA8D;AAC9D,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAEvB,6CAA6C;IAC7C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAE1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chain-head.js","sourceRoot":"","sources":["../../src/chain/chain-head.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AuditRecord } from '@ddd-ts-kit/kernel';
|
|
2
|
+
export declare const GLOBAL_CHAIN = "global";
|
|
3
|
+
/**
|
|
4
|
+
* One chain per tenant, and one `global` chain for work that names no tenant. A tenant can
|
|
5
|
+
* then be verified, exported and handed over alone, and two tenants never wait for each other
|
|
6
|
+
* to append. The price: the order of entries of different tenants is not proven by a chain.
|
|
7
|
+
*/
|
|
8
|
+
export declare function chainOfTenant(record: AuditRecord): string;
|
|
9
|
+
//# sourceMappingURL=chain-of-tenant.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chain-of-tenant.d.ts","sourceRoot":"","sources":["../../src/chain/chain-of-tenant.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD,eAAO,MAAM,YAAY,WAAW,CAAC;AAErC;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAEzD"}
|