@astram/guard 0.18.0 → 0.20.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/audit/audit-chain-sealer.d.ts +90 -0
- package/dist/audit/audit-chain-sealer.d.ts.map +1 -0
- package/dist/audit/audit-chain-sealer.js +520 -0
- package/dist/audit/audit-seal-config.d.ts +22 -0
- package/dist/audit/audit-seal-config.d.ts.map +1 -0
- package/dist/audit/audit-seal-config.js +35 -0
- package/dist/audit/audit-sealer.service.d.ts +62 -0
- package/dist/audit/audit-sealer.service.d.ts.map +1 -0
- package/dist/audit/audit-sealer.service.js +197 -0
- package/dist/audit/node-sha256-hash.d.ts +6 -0
- package/dist/audit/node-sha256-hash.d.ts.map +1 -0
- package/dist/audit/node-sha256-hash.js +16 -0
- package/dist/auth/account.controller.d.ts +3 -1
- package/dist/auth/account.controller.d.ts.map +1 -1
- package/dist/auth/account.controller.js +79 -6
- package/dist/auth/admin-signin-controls.d.ts +80 -0
- package/dist/auth/admin-signin-controls.d.ts.map +1 -0
- package/dist/auth/admin-signin-controls.js +91 -0
- package/dist/auth/auth-audit.d.ts +59 -0
- package/dist/auth/auth-audit.d.ts.map +1 -0
- package/dist/auth/auth-audit.js +93 -0
- package/dist/auth/auth.controller.d.ts +53 -6
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +330 -44
- package/dist/auth/auth.module.d.ts +49 -0
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +149 -4
- package/dist/auth/captcha-gate.d.ts +103 -0
- package/dist/auth/captcha-gate.d.ts.map +1 -0
- package/dist/auth/captcha-gate.js +245 -0
- package/dist/auth/dto.d.ts +22 -1
- package/dist/auth/dto.d.ts.map +1 -1
- package/dist/auth/login-name-count-boot.d.ts +24 -0
- package/dist/auth/login-name-count-boot.d.ts.map +1 -0
- package/dist/auth/login-name-count-boot.js +47 -0
- package/dist/auth/logout-proof.d.ts +55 -0
- package/dist/auth/logout-proof.d.ts.map +1 -0
- package/dist/auth/logout-proof.js +127 -0
- package/dist/auth/pending-mfa-store.d.ts +5 -0
- package/dist/auth/pending-mfa-store.d.ts.map +1 -1
- package/dist/auth/pending-mfa-store.js +2 -0
- package/dist/auth/persistence.module.d.ts +2 -2
- package/dist/auth/persistence.module.d.ts.map +1 -1
- package/dist/auth/persistence.module.js +19 -3
- package/dist/auth/ports/audit-sink.d.ts +5 -2
- package/dist/auth/ports/audit-sink.d.ts.map +1 -1
- package/dist/auth/ports/mfa-device-store.d.ts +7 -0
- package/dist/auth/ports/mfa-device-store.d.ts.map +1 -1
- package/dist/auth/profile-resolver.d.ts +6 -0
- package/dist/auth/profile-resolver.d.ts.map +1 -1
- package/dist/auth/rate-limit/attempt-limiter.d.ts +6 -0
- package/dist/auth/rate-limit/attempt-limiter.d.ts.map +1 -1
- package/dist/auth/rate-limit/attempt-limiter.js +14 -0
- package/dist/auth/tokens.d.ts +59 -0
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +61 -1
- package/dist/authentication/credential-provisioner.d.ts +7 -0
- package/dist/authentication/credential-provisioner.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-provisioner.d.ts +1 -0
- package/dist/authentication/mariadb-credential-provisioner.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-provisioner.js +7 -0
- package/dist/authentication/mariadb-credential-resolver.d.ts +23 -2
- package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-resolver.js +45 -1
- package/dist/authentication/mariadb-mfa-device-store.d.ts +7 -0
- package/dist/authentication/mariadb-mfa-device-store.d.ts.map +1 -1
- package/dist/authentication/mariadb-mfa-device-store.js +32 -0
- package/dist/authentication/mariadb-mfa-requirement-source.d.ts +55 -0
- package/dist/authentication/mariadb-mfa-requirement-source.d.ts.map +1 -0
- package/dist/authentication/mariadb-mfa-requirement-source.js +164 -0
- package/dist/authentication/mariadb-mfa-resetter.d.ts +42 -0
- package/dist/authentication/mariadb-mfa-resetter.d.ts.map +1 -0
- package/dist/authentication/mariadb-mfa-resetter.js +59 -0
- package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-profile-writer.js +5 -0
- package/dist/authentication/sign-in-with.d.ts +14 -0
- package/dist/authentication/sign-in-with.d.ts.map +1 -0
- package/dist/authentication/sign-in-with.js +36 -0
- package/dist/authz/core-access.d.ts +11 -0
- package/dist/authz/core-access.d.ts.map +1 -0
- package/dist/authz/core-access.js +14 -0
- package/dist/authz/list-scope-sql.d.ts.map +1 -1
- package/dist/authz/list-scope-sql.js +15 -5
- package/dist/authz/wire-record-authorization.d.ts.map +1 -1
- package/dist/authz/wire-record-authorization.js +21 -3
- package/dist/captcha/captcha.providers.d.ts +18 -0
- package/dist/captcha/captcha.providers.d.ts.map +1 -0
- package/dist/captcha/captcha.providers.js +78 -0
- package/dist/captcha/recaptcha-verifier.d.ts +20 -0
- package/dist/captcha/recaptcha-verifier.d.ts.map +1 -0
- package/dist/captcha/recaptcha-verifier.js +48 -0
- package/dist/captcha/siteverify.d.ts +47 -0
- package/dist/captcha/siteverify.d.ts.map +1 -0
- package/dist/captcha/siteverify.js +98 -0
- package/dist/captcha/turnstile-verifier.d.ts +18 -0
- package/dist/captcha/turnstile-verifier.d.ts.map +1 -0
- package/dist/captcha/turnstile-verifier.js +41 -0
- package/dist/config/captcha-config.d.ts +47 -0
- package/dist/config/captcha-config.d.ts.map +1 -0
- package/dist/config/captcha-config.js +163 -0
- package/dist/config/reserved-principal-ids.d.ts +15 -0
- package/dist/config/reserved-principal-ids.d.ts.map +1 -1
- package/dist/config/reserved-principal-ids.js +20 -3
- package/dist/enforcement/global-enforcement.guard.d.ts +9 -1
- package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
- package/dist/enforcement/global-enforcement.guard.js +52 -10
- package/dist/enforcement/reenroll-allowlist.d.ts +5 -0
- package/dist/enforcement/reenroll-allowlist.d.ts.map +1 -1
- package/dist/enforcement/reenroll-allowlist.js +5 -0
- package/dist/identity/auth-facts.controller.d.ts +31 -4
- package/dist/identity/auth-facts.controller.d.ts.map +1 -1
- package/dist/identity/auth-facts.controller.js +46 -11
- package/dist/identity/identity-actions.d.ts +2 -2
- package/dist/identity/identity-actions.d.ts.map +1 -1
- package/dist/identity/module-registry.bridge.d.ts.map +1 -1
- package/dist/identity/module-registry.bridge.js +3 -6
- package/dist/identity/permission-facts.adapter.d.ts +22 -0
- package/dist/identity/permission-facts.adapter.d.ts.map +1 -1
- package/dist/identity/permission-facts.adapter.js +26 -1
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +43 -11
- package/dist/internal.d.ts +27 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +36 -1
- package/dist/module/shield.module.d.ts +27 -0
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +10 -0
- package/dist/request-auth/request-principal.d.ts +8 -1
- package/dist/request-auth/request-principal.d.ts.map +1 -1
- package/dist/request-auth/request-principal.js +11 -4
- package/dist/request-auth/session-token-binder.d.ts +82 -0
- package/dist/request-auth/session-token-binder.d.ts.map +1 -1
- package/dist/request-auth/session-token-binder.js +203 -1
- package/dist/session/mfa-requirement-service.d.ts +168 -0
- package/dist/session/mfa-requirement-service.d.ts.map +1 -0
- package/dist/session/mfa-requirement-service.js +461 -0
- package/dist/session/session-restriction-store.d.ts +36 -0
- package/dist/session/session-restriction-store.d.ts.map +1 -1
- package/dist/session/session-restriction-store.js +57 -6
- package/dist/session/session-restriction.d.ts +22 -0
- package/dist/session/session-restriction.d.ts.map +1 -0
- package/dist/session/session-restriction.js +23 -0
- package/package.json +10 -5
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type LoggerService } from '@nestjs/common';
|
|
2
|
+
import type { Pool } from 'mariadb';
|
|
3
|
+
import { type Clock, type FrameworkSchemas, type HashFunction } from '@astram/core';
|
|
4
|
+
/** Lookback for the incremental scan (SPEC §10). */
|
|
5
|
+
export declare const AUDIT_SEAL_LOOKBACK_MS: number;
|
|
6
|
+
/** Full sweep and table re-probe period (SPEC §10). */
|
|
7
|
+
export declare const AUDIT_SEAL_SWEEP_MS: number;
|
|
8
|
+
/** Candidate rows per keyset page (SPEC §10). */
|
|
9
|
+
export declare const AUDIT_SEAL_PAGE = 1000;
|
|
10
|
+
/** Keyset pages per tick (SPEC §10). */
|
|
11
|
+
export declare const AUDIT_SEAL_PAGES = 10;
|
|
12
|
+
/** Tenants sealed per tick (SPEC §10). */
|
|
13
|
+
export declare const AUDIT_SEAL_TENANTS = 200;
|
|
14
|
+
/** Parked (stuck) tenants held at most (SPEC §10). */
|
|
15
|
+
export declare const AUDIT_SEAL_STUCK = 200;
|
|
16
|
+
/** The logger surface the sealer uses (Nest `Logger` shape). */
|
|
17
|
+
export type AuditSealerLogger = Pick<LoggerService, 'log' | 'warn' | 'error'>;
|
|
18
|
+
export interface AuditChainSealerOptions {
|
|
19
|
+
/** `SHIELD_DB_POOL`. Only `getConnection()` is used: every statement runs on the lock's session. */
|
|
20
|
+
readonly pool: Pool;
|
|
21
|
+
/** Resolved framework database names (`FRAMEWORK_SCHEMAS`). */
|
|
22
|
+
readonly schemas: FrameworkSchemas;
|
|
23
|
+
/** Chain hash (`NodeSha256Hash` in production). */
|
|
24
|
+
readonly hash: HashFunction;
|
|
25
|
+
/** Rows per tenant per tick (`AUDIT_SEAL_BATCH`). */
|
|
26
|
+
readonly batch: number;
|
|
27
|
+
/** Sealer clock: `ac_sealed_at_ms` and the sweep period. Default `Date.now`. */
|
|
28
|
+
readonly clock?: Clock;
|
|
29
|
+
/** Default `new Logger('Shield')`. */
|
|
30
|
+
readonly logger?: AuditSealerLogger;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A log-safe class for an error: a typed core reason, a driver error code / errno, or
|
|
34
|
+
* `unexpected`. Never the message (it can echo row content, e.g. a duplicate-key value).
|
|
35
|
+
*/
|
|
36
|
+
export declare function auditSealErrorClass(err: unknown): string;
|
|
37
|
+
/** The leader lock name: `<ns>:audit_sealer` (single layout: `<db>:audit_sealer`), ≤ 64 chars. */
|
|
38
|
+
export declare function auditSealerLockName(schemas: Pick<FrameworkSchemas, 'namespace'>): string;
|
|
39
|
+
export declare class AuditChainSealer {
|
|
40
|
+
private readonly pool;
|
|
41
|
+
private readonly hash;
|
|
42
|
+
private readonly batch;
|
|
43
|
+
private readonly clock;
|
|
44
|
+
private readonly logger;
|
|
45
|
+
private readonly lockName;
|
|
46
|
+
private readonly logTable;
|
|
47
|
+
private readonly chainTable;
|
|
48
|
+
private readonly checkpointTable;
|
|
49
|
+
/** Sealer clock at the start of the last sweep; `undefined` ⇒ the next leader tick sweeps. */
|
|
50
|
+
private lastSweepAtMs;
|
|
51
|
+
/** Tenants parked until the next sweep (exact strings). */
|
|
52
|
+
private readonly stuck;
|
|
53
|
+
/** `(reason, tenant)` keys already logged this sweep. */
|
|
54
|
+
private readonly logged;
|
|
55
|
+
/** The stuck-set overflow was logged this sweep. */
|
|
56
|
+
private overflowLogged;
|
|
57
|
+
/**
|
|
58
|
+
* Backlog cursor (SPEC §5.G step 2): the oldest position the last tick left unsealed because
|
|
59
|
+
* of the batch / page / tenant caps (stuck tenants excluded). Leader memory only.
|
|
60
|
+
*/
|
|
61
|
+
private cursor;
|
|
62
|
+
constructor(options: AuditChainSealerOptions);
|
|
63
|
+
/**
|
|
64
|
+
* One tick. Always releases the lock and returns (or destroys) the connection before it
|
|
65
|
+
* settles. A tenant batch error never rejects; an unexpected error (e.g. the candidate read
|
|
66
|
+
* failing) rejects after the cleanup.
|
|
67
|
+
*/
|
|
68
|
+
tick(): Promise<void>;
|
|
69
|
+
private forgetLeadership;
|
|
70
|
+
private sealOnce;
|
|
71
|
+
/** The oldest position the caps left behind, skipping stuck tenants; `undefined` ⇒ none. */
|
|
72
|
+
private oldestLeftBehind;
|
|
73
|
+
/** `MIN(MAX(ac_occurred_at_ms), db now) − lookback`, or `undefined` for an empty chain. */
|
|
74
|
+
private readWindowStart;
|
|
75
|
+
/**
|
|
76
|
+
* Keyset-paged candidate read, grouped by exact tenant string, ≤ `batch` rows per tenant and
|
|
77
|
+
* ≤ 200 tenants (first appearance in keyset order; the rest wait for the next tick). The
|
|
78
|
+
* first page starts after `windowStart`, or at the backlog cursor `start` when given. Also
|
|
79
|
+
* returns what the caps left behind, for the backlog cursor.
|
|
80
|
+
*/
|
|
81
|
+
private readCandidates;
|
|
82
|
+
/** Each tenant's head: the `MAX(ac_sequence)` chain row, else its latest checkpoint. */
|
|
83
|
+
private readHeads;
|
|
84
|
+
/** Link and insert one tenant's batch in one transaction; errors stay with this tenant. */
|
|
85
|
+
private sealTenant;
|
|
86
|
+
/** Log a failed batch (once per tenant and reason per sweep) and park it if it repeats. */
|
|
87
|
+
private recordFailure;
|
|
88
|
+
private logOnce;
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=audit-chain-sealer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit-chain-sealer.d.ts","sourceRoot":"","sources":["../../src/audit/audit-chain-sealer.ts"],"names":[],"mappings":"AAmCA,OAAO,EAAU,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC5D,OAAO,KAAK,EAAE,IAAI,EAAkB,MAAM,SAAS,CAAC;AACpD,OAAO,EASL,KAAK,KAAK,EACV,KAAK,gBAAgB,EACrB,KAAK,YAAY,EAClB,MAAM,cAAc,CAAC;AAEtB,oDAAoD;AACpD,eAAO,MAAM,sBAAsB,QAAc,CAAC;AAClD,uDAAuD;AACvD,eAAO,MAAM,mBAAmB,QAAc,CAAC;AAC/C,iDAAiD;AACjD,eAAO,MAAM,eAAe,OAAO,CAAC;AACpC,wCAAwC;AACxC,eAAO,MAAM,gBAAgB,KAAK,CAAC;AACnC,0CAA0C;AAC1C,eAAO,MAAM,kBAAkB,MAAM,CAAC;AACtC,sDAAsD;AACtD,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAOpC,gEAAgE;AAChE,MAAM,MAAM,iBAAiB,GAAG,IAAI,CAAC,aAAa,EAAE,KAAK,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC;AAE9E,MAAM,WAAW,uBAAuB;IACtC,oGAAoG;IACpG,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IACpB,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC;IACnC,mDAAmD;IACnD,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAC5B,qDAAqD;IACrD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,gFAAgF;IAChF,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC;IACvB,sCAAsC;IACtC,QAAQ,CAAC,MAAM,CAAC,EAAE,iBAAiB,CAAC;CACrC;AAyDD;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CASxD;AAUD,kGAAkG;AAClG,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,IAAI,CAAC,gBAAgB,EAAE,WAAW,CAAC,GAAG,MAAM,CAOxF;AAOD,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAO;IAC5B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAe;IACpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAQ;IAC9B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoB;IAC3C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IAEzC,8FAA8F;IAC9F,OAAO,CAAC,aAAa,CAAqB;IAC1C,2DAA2D;IAC3D,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqB;IAC3C,yDAAyD;IACzD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAqB;IAC5C,oDAAoD;IACpD,OAAO,CAAC,cAAc,CAAS;IAC/B;;;OAGG;IACH,OAAO,CAAC,MAAM,CAA6B;gBAE/B,OAAO,EAAE,uBAAuB;IAa5C;;;;OAIG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAgD3B,OAAO,CAAC,gBAAgB;YAQV,QAAQ;IAwCtB,4FAA4F;IAC5F,OAAO,CAAC,gBAAgB;IAWxB,2FAA2F;YAC7E,eAAe;IAa7B;;;;;OAKG;YACW,cAAc;IA0H5B,wFAAwF;YAC1E,SAAS;IAyDvB,2FAA2F;YAC7E,UAAU;IAmExB,2FAA2F;IAC3F,OAAO,CAAC,aAAa;IAuBrB,OAAO,CAAC,OAAO;CAMhB"}
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AuditChainSealer = exports.AUDIT_SEAL_STUCK = exports.AUDIT_SEAL_TENANTS = exports.AUDIT_SEAL_PAGES = exports.AUDIT_SEAL_PAGE = exports.AUDIT_SEAL_SWEEP_MS = exports.AUDIT_SEAL_LOOKBACK_MS = void 0;
|
|
4
|
+
exports.auditSealErrorClass = auditSealErrorClass;
|
|
5
|
+
exports.auditSealerLockName = auditSealerLockName;
|
|
6
|
+
/**
|
|
7
|
+
* `AuditChainSealer` — one tick of the deferred audit sealer (audit-chain-store SPEC §5.G,
|
|
8
|
+
* §10, §11, §13).
|
|
9
|
+
*
|
|
10
|
+
* Writers keep appending plain rows to `<audit>.audit_log`; one leader per namespace (named
|
|
11
|
+
* lock) chains every committed row per tenant into the append-only `<audit>.audit_chain`.
|
|
12
|
+
* A tick:
|
|
13
|
+
* 1. checks out ONE pool connection and takes `GET_LOCK('<ns>:audit_sealer', 0)`; not
|
|
14
|
+
* acquired ⇒ return the connection, no work;
|
|
15
|
+
* 2. window start `W = MIN(MAX(ac_occurred_at_ms), db now) − 15 min` from the chain alone
|
|
16
|
+
* (`idx_ac_occurred`); empty chain ⇒ no bound. The first tick of a leader and then one
|
|
17
|
+
* tick per hour is a sweep: no bound, the stuck set and the logged-once memory clear.
|
|
18
|
+
* Backlog cursor: when the batch / page / tenant caps leave rows behind, the oldest such
|
|
19
|
+
* `(al_occurred_at_ms, al_uuid)` (stuck tenants excluded) is kept in memory and the next
|
|
20
|
+
* tick starts its keyset there instead of at `W` when it is older; cleared when a tick
|
|
21
|
+
* leaves nothing behind, on lock loss and at each sweep (which may set it again);
|
|
22
|
+
* 3./4. reads candidates in keyset pages on `idx_audit_time` (1000 rows, ≤ 10 pages, stops at
|
|
23
|
+
* 200 tenants), anti-joined into `uq_ac_al_uuid`, excluding parked tenants and tenants
|
|
24
|
+
* that already hold `batch` rows this tick; groups them by exact tenant string in Node;
|
|
25
|
+
* reads each tenant's head (chain PK, else latest checkpoint, else genesis);
|
|
26
|
+
* 5. links each tenant's rows with core `auditEventFromRow` + `linkEntry` and inserts them
|
|
27
|
+
* in ONE transaction per tenant on the same connection. A failed batch rolls back alone;
|
|
28
|
+
* a repeating error (duplicate key 1062, `AuditRowError`, `AuditValidationError`) parks
|
|
29
|
+
* the tenant until the next sweep (≤ 200 parked), anything else retries next tick;
|
|
30
|
+
* 6. `finally`: `RELEASE_LOCK`, then return the connection; if the release fails (or the
|
|
31
|
+
* lock state is unknown) destroy the connection instead, so a pooled connection never
|
|
32
|
+
* carries the lock.
|
|
33
|
+
*
|
|
34
|
+
* Every `audit_log` ⇄ chain comparison and every tenant filter on `audit_log` carries
|
|
35
|
+
* `COLLATE utf8mb4_nopad_bin` on the `audit_log` side (exact bytes, NO PAD; `audit_log` is
|
|
36
|
+
* `utf8mb4_unicode_ci`, a bare comparison raises error 1267). Database names come from
|
|
37
|
+
* `FrameworkSchemas`, never a literal. Logs carry fixed text, tenant and sequence only:
|
|
38
|
+
* never `err.message` and never row content (SPEC §11).
|
|
39
|
+
*/
|
|
40
|
+
const node_crypto_1 = require("node:crypto");
|
|
41
|
+
const common_1 = require("@nestjs/common");
|
|
42
|
+
const core_1 = require("@astram/core");
|
|
43
|
+
/** Lookback for the incremental scan (SPEC §10). */
|
|
44
|
+
exports.AUDIT_SEAL_LOOKBACK_MS = 15 * 60_000;
|
|
45
|
+
/** Full sweep and table re-probe period (SPEC §10). */
|
|
46
|
+
exports.AUDIT_SEAL_SWEEP_MS = 60 * 60_000;
|
|
47
|
+
/** Candidate rows per keyset page (SPEC §10). */
|
|
48
|
+
exports.AUDIT_SEAL_PAGE = 1000;
|
|
49
|
+
/** Keyset pages per tick (SPEC §10). */
|
|
50
|
+
exports.AUDIT_SEAL_PAGES = 10;
|
|
51
|
+
/** Tenants sealed per tick (SPEC §10). */
|
|
52
|
+
exports.AUDIT_SEAL_TENANTS = 200;
|
|
53
|
+
/** Parked (stuck) tenants held at most (SPEC §10). */
|
|
54
|
+
exports.AUDIT_SEAL_STUCK = 200;
|
|
55
|
+
/** MariaDB's limit on a `GET_LOCK` name. */
|
|
56
|
+
const MAX_LOCK_NAME = 64;
|
|
57
|
+
/** MariaDB duplicate-key error number. */
|
|
58
|
+
const ER_DUP_ENTRY = 1062;
|
|
59
|
+
/** A BIGINT column value as a `bigint` for ordering; `undefined` when it is not an integer. */
|
|
60
|
+
function ordinalOf(value) {
|
|
61
|
+
try {
|
|
62
|
+
if (typeof value === 'bigint')
|
|
63
|
+
return value;
|
|
64
|
+
if (typeof value === 'number' && Number.isInteger(value))
|
|
65
|
+
return BigInt(value);
|
|
66
|
+
if (typeof value === 'string' && /^-?\d+$/.test(value.trim()))
|
|
67
|
+
return BigInt(value.trim());
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// fall through
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
/** Thrown inside a tick when the connection can no longer be trusted (rollback failed). */
|
|
75
|
+
class ConnectionUnusable extends Error {
|
|
76
|
+
constructor() {
|
|
77
|
+
super('audit_sealer_connection_unusable');
|
|
78
|
+
this.name = 'ConnectionUnusable';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const DRIVER_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
82
|
+
/**
|
|
83
|
+
* A log-safe class for an error: a typed core reason, a driver error code / errno, or
|
|
84
|
+
* `unexpected`. Never the message (it can echo row content, e.g. a duplicate-key value).
|
|
85
|
+
*/
|
|
86
|
+
function auditSealErrorClass(err) {
|
|
87
|
+
if (err instanceof core_1.AuditRowError)
|
|
88
|
+
return `row_rejected:${err.reason}`;
|
|
89
|
+
if (err instanceof core_1.AuditValidationError)
|
|
90
|
+
return `event_rejected:${err.reason}`;
|
|
91
|
+
if (typeof err === 'object' && err !== null) {
|
|
92
|
+
const { code, errno } = err;
|
|
93
|
+
if (typeof code === 'string' && DRIVER_CODE.test(code))
|
|
94
|
+
return `db_error:${code}`;
|
|
95
|
+
if (typeof errno === 'number' && Number.isSafeInteger(errno))
|
|
96
|
+
return `db_error:${errno}`;
|
|
97
|
+
}
|
|
98
|
+
return 'unexpected';
|
|
99
|
+
}
|
|
100
|
+
/** Errors that repeat on every retry of the same rows, so the tenant is parked (SPEC §5.G.3). */
|
|
101
|
+
function isRepeating(err) {
|
|
102
|
+
if (err instanceof core_1.AuditRowError || err instanceof core_1.AuditValidationError)
|
|
103
|
+
return true;
|
|
104
|
+
return (typeof err === 'object' && err !== null && err.errno === ER_DUP_ENTRY);
|
|
105
|
+
}
|
|
106
|
+
/** The leader lock name: `<ns>:audit_sealer` (single layout: `<db>:audit_sealer`), ≤ 64 chars. */
|
|
107
|
+
function auditSealerLockName(schemas) {
|
|
108
|
+
const name = `${schemas.namespace}:audit_sealer`;
|
|
109
|
+
if (name.length <= MAX_LOCK_NAME)
|
|
110
|
+
return name;
|
|
111
|
+
// A 55-char namespace or 64-char single-layout database exceeds MariaDB's limit: keep the
|
|
112
|
+
// name unique per namespace with a digest (same approach as the migrate ledger lock).
|
|
113
|
+
const digest = (0, node_crypto_1.createHash)('sha256').update(schemas.namespace, 'utf8').digest('hex');
|
|
114
|
+
return `audit_sealer:${digest.slice(0, MAX_LOCK_NAME - 'audit_sealer:'.length)}`;
|
|
115
|
+
}
|
|
116
|
+
const quoteId = (name) => `\`${name.replace(/`/g, '``')}\``;
|
|
117
|
+
/** `?, ?, …` for `n` parameters. */
|
|
118
|
+
const placeholders = (n) => Array.from({ length: n }, () => '?').join(', ');
|
|
119
|
+
class AuditChainSealer {
|
|
120
|
+
pool;
|
|
121
|
+
hash;
|
|
122
|
+
batch;
|
|
123
|
+
clock;
|
|
124
|
+
logger;
|
|
125
|
+
lockName;
|
|
126
|
+
logTable;
|
|
127
|
+
chainTable;
|
|
128
|
+
checkpointTable;
|
|
129
|
+
/** Sealer clock at the start of the last sweep; `undefined` ⇒ the next leader tick sweeps. */
|
|
130
|
+
lastSweepAtMs;
|
|
131
|
+
/** Tenants parked until the next sweep (exact strings). */
|
|
132
|
+
stuck = new Set();
|
|
133
|
+
/** `(reason, tenant)` keys already logged this sweep. */
|
|
134
|
+
logged = new Set();
|
|
135
|
+
/** The stuck-set overflow was logged this sweep. */
|
|
136
|
+
overflowLogged = false;
|
|
137
|
+
/**
|
|
138
|
+
* Backlog cursor (SPEC §5.G step 2): the oldest position the last tick left unsealed because
|
|
139
|
+
* of the batch / page / tenant caps (stuck tenants excluded). Leader memory only.
|
|
140
|
+
*/
|
|
141
|
+
cursor;
|
|
142
|
+
constructor(options) {
|
|
143
|
+
this.pool = options.pool;
|
|
144
|
+
this.hash = options.hash;
|
|
145
|
+
this.batch = options.batch;
|
|
146
|
+
this.clock = options.clock ?? { nowMs: () => Date.now() };
|
|
147
|
+
this.logger = options.logger ?? new common_1.Logger('Shield');
|
|
148
|
+
this.lockName = auditSealerLockName(options.schemas);
|
|
149
|
+
const db = quoteId(options.schemas.audit);
|
|
150
|
+
this.logTable = `${db}.${quoteId('audit_log')}`;
|
|
151
|
+
this.chainTable = `${db}.${quoteId('audit_chain')}`;
|
|
152
|
+
this.checkpointTable = `${db}.${quoteId('audit_chain_checkpoint')}`;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* One tick. Always releases the lock and returns (or destroys) the connection before it
|
|
156
|
+
* settles. A tenant batch error never rejects; an unexpected error (e.g. the candidate read
|
|
157
|
+
* failing) rejects after the cleanup.
|
|
158
|
+
*/
|
|
159
|
+
async tick() {
|
|
160
|
+
const conn = await this.pool.getConnection();
|
|
161
|
+
/** GET_LOCK's answer; stays `undefined` (outcome unknown) when the statement fails. */
|
|
162
|
+
let locked;
|
|
163
|
+
let destroy = false;
|
|
164
|
+
try {
|
|
165
|
+
const rows = (await conn.query('SELECT GET_LOCK(?, 0) AS acquired', [
|
|
166
|
+
this.lockName,
|
|
167
|
+
]));
|
|
168
|
+
const acquired = rows[0]?.acquired;
|
|
169
|
+
locked = acquired !== null && acquired !== undefined && Number(acquired) === 1;
|
|
170
|
+
if (!locked) {
|
|
171
|
+
// Another instance leads. If this one leads again later it is a new leader: it sweeps
|
|
172
|
+
// first and starts with an empty stuck set.
|
|
173
|
+
this.forgetLeadership();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
await this.sealOnce(conn);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
if (!(err instanceof ConnectionUnusable))
|
|
180
|
+
throw err;
|
|
181
|
+
destroy = true;
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
if (locked === undefined) {
|
|
185
|
+
// GET_LOCK failed: whether the session holds the lock is unknown, so never pool it.
|
|
186
|
+
destroy = true;
|
|
187
|
+
}
|
|
188
|
+
else if (locked) {
|
|
189
|
+
try {
|
|
190
|
+
await conn.query('SELECT RELEASE_LOCK(?) AS released', [this.lockName]);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
destroy = true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (destroy) {
|
|
197
|
+
try {
|
|
198
|
+
conn.destroy();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// The socket is closed either way; the server drops the session's lock with it.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
try {
|
|
206
|
+
await conn.release();
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// Returning a connection to a closing pool is non-fatal.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
forgetLeadership() {
|
|
215
|
+
this.lastSweepAtMs = undefined;
|
|
216
|
+
this.stuck.clear();
|
|
217
|
+
this.logged.clear();
|
|
218
|
+
this.overflowLogged = false;
|
|
219
|
+
this.cursor = undefined;
|
|
220
|
+
}
|
|
221
|
+
async sealOnce(conn) {
|
|
222
|
+
const now = this.clock.nowMs();
|
|
223
|
+
const sweep = this.lastSweepAtMs === undefined || now - this.lastSweepAtMs >= exports.AUDIT_SEAL_SWEEP_MS;
|
|
224
|
+
if (sweep) {
|
|
225
|
+
this.lastSweepAtMs = now;
|
|
226
|
+
this.stuck.clear();
|
|
227
|
+
this.logged.clear();
|
|
228
|
+
this.overflowLogged = false;
|
|
229
|
+
// The sweep scans from the start; it sets the cursor again if it leaves rows behind.
|
|
230
|
+
this.cursor = undefined;
|
|
231
|
+
}
|
|
232
|
+
const windowStart = sweep ? undefined : await this.readWindowStart(conn);
|
|
233
|
+
// Start from the backlog cursor instead of W when it is older (SPEC §5.G step 2).
|
|
234
|
+
const start = windowStart !== undefined &&
|
|
235
|
+
this.cursor !== undefined &&
|
|
236
|
+
this.cursor.ordinal <= BigInt(windowStart)
|
|
237
|
+
? this.cursor
|
|
238
|
+
: undefined;
|
|
239
|
+
const read = await this.readCandidates(conn, start === undefined ? windowStart : undefined, start);
|
|
240
|
+
// Rows left behind by the caps, before sealing: if the connection dies mid-tick, the next
|
|
241
|
+
// tick still resumes from here.
|
|
242
|
+
this.cursor = this.oldestLeftBehind(read);
|
|
243
|
+
const { groups } = read;
|
|
244
|
+
if (groups.size === 0)
|
|
245
|
+
return;
|
|
246
|
+
const heads = await this.readHeads(conn, [...groups.keys()]);
|
|
247
|
+
for (const [tenant, rows] of groups) {
|
|
248
|
+
await this.sealTenant(conn, tenant, rows, heads.get(tenant));
|
|
249
|
+
}
|
|
250
|
+
// Tenants parked during this tick wait for the sweep: their rows do not hold the cursor.
|
|
251
|
+
this.cursor = this.oldestLeftBehind(read);
|
|
252
|
+
}
|
|
253
|
+
/** The oldest position the caps left behind, skipping stuck tenants; `undefined` ⇒ none. */
|
|
254
|
+
oldestLeftBehind(read) {
|
|
255
|
+
let oldest = read.tail;
|
|
256
|
+
for (const [tenant, position] of read.leftBehind) {
|
|
257
|
+
if (this.stuck.has(tenant))
|
|
258
|
+
continue;
|
|
259
|
+
if (oldest === undefined || read.order.get(position) < read.order.get(oldest)) {
|
|
260
|
+
oldest = position;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return oldest;
|
|
264
|
+
}
|
|
265
|
+
/** `MIN(MAX(ac_occurred_at_ms), db now) − lookback`, or `undefined` for an empty chain. */
|
|
266
|
+
async readWindowStart(conn) {
|
|
267
|
+
const rows = (await conn.query(`SELECT MAX(ac_occurred_at_ms) AS max_ms,
|
|
268
|
+
CAST(UNIX_TIMESTAMP(NOW(3)) * 1000 AS SIGNED) AS now_ms
|
|
269
|
+
FROM ${this.chainTable}`));
|
|
270
|
+
const row = rows[0];
|
|
271
|
+
if (row?.max_ms === null || row?.max_ms === undefined)
|
|
272
|
+
return undefined;
|
|
273
|
+
const max = (0, core_1.toSafeInteger)(row.max_ms);
|
|
274
|
+
const dbNow = row.now_ms === null || row.now_ms === undefined ? max : (0, core_1.toSafeInteger)(row.now_ms);
|
|
275
|
+
return Math.min(max, dbNow) - exports.AUDIT_SEAL_LOOKBACK_MS;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Keyset-paged candidate read, grouped by exact tenant string, ≤ `batch` rows per tenant and
|
|
279
|
+
* ≤ 200 tenants (first appearance in keyset order; the rest wait for the next tick). The
|
|
280
|
+
* first page starts after `windowStart`, or at the backlog cursor `start` when given. Also
|
|
281
|
+
* returns what the caps left behind, for the backlog cursor.
|
|
282
|
+
*/
|
|
283
|
+
async readCandidates(conn, windowStart, start) {
|
|
284
|
+
const groups = new Map();
|
|
285
|
+
const full = new Set();
|
|
286
|
+
const leftBehind = new Map();
|
|
287
|
+
const order = new Map();
|
|
288
|
+
/** Per full tenant, the read-order index of its last taken row. */
|
|
289
|
+
const lastTaken = new Map();
|
|
290
|
+
let after = start;
|
|
291
|
+
let index = 0;
|
|
292
|
+
let lastRow;
|
|
293
|
+
let exhausted = false;
|
|
294
|
+
const positionOf = (row, inclusive) => {
|
|
295
|
+
const ordinal = ordinalOf(row.al_occurred_at_ms);
|
|
296
|
+
if (ordinal === undefined)
|
|
297
|
+
return undefined;
|
|
298
|
+
return { occurred: row.al_occurred_at_ms, ordinal, uuid: row.al_uuid, inclusive };
|
|
299
|
+
};
|
|
300
|
+
for (let page = 0; page < exports.AUDIT_SEAL_PAGES; page++) {
|
|
301
|
+
const where = [];
|
|
302
|
+
const params = [];
|
|
303
|
+
if (windowStart !== undefined) {
|
|
304
|
+
where.push('al_occurred_at_ms > ?');
|
|
305
|
+
params.push(windowStart);
|
|
306
|
+
}
|
|
307
|
+
if (after !== undefined) {
|
|
308
|
+
// The cursor row itself is still unsealed: an inclusive start keeps it (`>=`).
|
|
309
|
+
where.push(`(al_occurred_at_ms > ? OR (al_occurred_at_ms = ? AND al_uuid ${after.inclusive ? '>=' : '>'} ?))`);
|
|
310
|
+
params.push(after.occurred, after.occurred, after.uuid);
|
|
311
|
+
}
|
|
312
|
+
const excluded = [...this.stuck, ...full];
|
|
313
|
+
if (excluded.length > 0) {
|
|
314
|
+
where.push(`al_tenant_id COLLATE utf8mb4_nopad_bin NOT IN (${placeholders(excluded.length)})`);
|
|
315
|
+
params.push(...excluded);
|
|
316
|
+
}
|
|
317
|
+
where.push(`NOT EXISTS (SELECT 1 FROM ${this.chainTable}
|
|
318
|
+
WHERE ac_al_uuid = al_uuid COLLATE utf8mb4_nopad_bin)`);
|
|
319
|
+
params.push(exports.AUDIT_SEAL_PAGE);
|
|
320
|
+
const rows = (await conn.query(`SELECT al_uuid, al_type, al_occurred_at_ms, al_actor_id, al_tenant_id, al_action_key,
|
|
321
|
+
al_resource_json, al_decision_json, al_attributes_json
|
|
322
|
+
FROM ${this.logTable}
|
|
323
|
+
WHERE ${where.join('\n AND ')}
|
|
324
|
+
ORDER BY al_occurred_at_ms, al_uuid
|
|
325
|
+
LIMIT ?`, params));
|
|
326
|
+
for (const row of rows) {
|
|
327
|
+
const i = index++;
|
|
328
|
+
const tenant = row.al_tenant_id;
|
|
329
|
+
if (this.stuck.has(tenant))
|
|
330
|
+
continue;
|
|
331
|
+
let group = groups.get(tenant);
|
|
332
|
+
let skip = false;
|
|
333
|
+
if (group === undefined) {
|
|
334
|
+
if (groups.size >= exports.AUDIT_SEAL_TENANTS) {
|
|
335
|
+
skip = true; // tenant cap
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
group = [];
|
|
339
|
+
groups.set(tenant, group);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
else if (group.length >= this.batch) {
|
|
343
|
+
skip = true; // batch cap
|
|
344
|
+
}
|
|
345
|
+
if (skip || group === undefined) {
|
|
346
|
+
if (!leftBehind.has(tenant)) {
|
|
347
|
+
const position = positionOf(row, true);
|
|
348
|
+
if (position !== undefined) {
|
|
349
|
+
leftBehind.set(tenant, position);
|
|
350
|
+
order.set(position, i);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
group.push(row);
|
|
356
|
+
if (group.length >= this.batch) {
|
|
357
|
+
full.add(tenant);
|
|
358
|
+
lastTaken.set(tenant, { index: i, row });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
lastRow = rows[rows.length - 1] ?? lastRow;
|
|
362
|
+
if (rows.length < exports.AUDIT_SEAL_PAGE) {
|
|
363
|
+
exhausted = true;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
if (groups.size >= exports.AUDIT_SEAL_TENANTS)
|
|
367
|
+
break;
|
|
368
|
+
const last = rows[rows.length - 1];
|
|
369
|
+
after = { occurred: last.al_occurred_at_ms, uuid: last.al_uuid, inclusive: false };
|
|
370
|
+
}
|
|
371
|
+
// A full tenant's later rows were excluded from the following pages (NOT IN), so it may
|
|
372
|
+
// have rows left even when none was seen: resume strictly after its last taken row.
|
|
373
|
+
for (const [tenant, taken] of lastTaken) {
|
|
374
|
+
if (leftBehind.has(tenant))
|
|
375
|
+
continue;
|
|
376
|
+
const position = positionOf(taken.row, false);
|
|
377
|
+
if (position === undefined)
|
|
378
|
+
continue;
|
|
379
|
+
leftBehind.set(tenant, position);
|
|
380
|
+
// Strictly after row `index`: before any row read later, after the row itself.
|
|
381
|
+
order.set(position, taken.index + 0.5);
|
|
382
|
+
}
|
|
383
|
+
// Paging stopped on `pages` or `tenants` with a full last page: the rest was never read.
|
|
384
|
+
let tail;
|
|
385
|
+
if (!exhausted && lastRow !== undefined) {
|
|
386
|
+
tail = positionOf(lastRow, false);
|
|
387
|
+
if (tail !== undefined)
|
|
388
|
+
order.set(tail, index - 1 + 0.5);
|
|
389
|
+
}
|
|
390
|
+
return { groups, leftBehind, tail, order };
|
|
391
|
+
}
|
|
392
|
+
/** Each tenant's head: the `MAX(ac_sequence)` chain row, else its latest checkpoint. */
|
|
393
|
+
async readHeads(conn, tenants) {
|
|
394
|
+
const heads = new Map();
|
|
395
|
+
const chainRows = (await conn.query(`SELECT ac.ac_tenant_id, ac.ac_sequence, ac.ac_entry_hash,
|
|
396
|
+
CASE WHEN al.al_uuid IS NULL THEN 1 ELSE 0 END AS al_missing
|
|
397
|
+
FROM (SELECT ac_tenant_id AS head_tenant, MAX(ac_sequence) AS head_sequence
|
|
398
|
+
FROM ${this.chainTable}
|
|
399
|
+
WHERE ac_tenant_id IN (${placeholders(tenants.length)})
|
|
400
|
+
GROUP BY ac_tenant_id) h
|
|
401
|
+
JOIN ${this.chainTable} ac
|
|
402
|
+
ON ac.ac_tenant_id = h.head_tenant AND ac.ac_sequence = h.head_sequence
|
|
403
|
+
LEFT JOIN ${this.logTable} al
|
|
404
|
+
ON al.al_uuid = ac.ac_al_uuid COLLATE utf8mb4_unicode_ci
|
|
405
|
+
AND al.al_uuid COLLATE utf8mb4_nopad_bin = ac.ac_al_uuid`, tenants));
|
|
406
|
+
for (const r of chainRows) {
|
|
407
|
+
heads.set(r.ac_tenant_id, {
|
|
408
|
+
anchor: {
|
|
409
|
+
tenantId: r.ac_tenant_id,
|
|
410
|
+
sequenceNumber: (0, core_1.toSafeInteger)(r.ac_sequence),
|
|
411
|
+
entryHash: r.ac_entry_hash,
|
|
412
|
+
},
|
|
413
|
+
missingRow: Number(r.al_missing) === 1,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
const rest = tenants.filter((t) => !heads.has(t));
|
|
417
|
+
if (rest.length === 0)
|
|
418
|
+
return heads;
|
|
419
|
+
const checkpoints = (await conn.query(`SELECT c.acc_tenant_id, c.acc_sequence, c.acc_entry_hash
|
|
420
|
+
FROM (SELECT acc_tenant_id AS cp_tenant, MAX(acc_sequence) AS cp_sequence
|
|
421
|
+
FROM ${this.checkpointTable}
|
|
422
|
+
WHERE acc_tenant_id IN (${placeholders(rest.length)})
|
|
423
|
+
GROUP BY acc_tenant_id) m
|
|
424
|
+
JOIN ${this.checkpointTable} c
|
|
425
|
+
ON c.acc_tenant_id = m.cp_tenant AND c.acc_sequence = m.cp_sequence`, rest));
|
|
426
|
+
for (const r of checkpoints) {
|
|
427
|
+
heads.set(r.acc_tenant_id, {
|
|
428
|
+
anchor: {
|
|
429
|
+
tenantId: r.acc_tenant_id,
|
|
430
|
+
sequenceNumber: (0, core_1.toSafeInteger)(r.acc_sequence),
|
|
431
|
+
entryHash: r.acc_entry_hash,
|
|
432
|
+
},
|
|
433
|
+
missingRow: false,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
return heads;
|
|
437
|
+
}
|
|
438
|
+
/** Link and insert one tenant's batch in one transaction; errors stay with this tenant. */
|
|
439
|
+
async sealTenant(conn, tenant, rows, head) {
|
|
440
|
+
if (head?.missingRow) {
|
|
441
|
+
// Keep sealing from the head's hash; audit:verify keeps reporting missing_row there.
|
|
442
|
+
this.logOnce('head_missing_row', tenant, `audit sealer: tenant ${JSON.stringify(tenant)} head at sequence ` +
|
|
443
|
+
`${head.anchor.sequenceNumber} has no audit_log row; sealing continues from its hash`);
|
|
444
|
+
}
|
|
445
|
+
let prev = head?.anchor;
|
|
446
|
+
/** The sequence being linked or inserted; a failure is reported at it. */
|
|
447
|
+
let sequence = prev !== undefined ? prev.sequenceNumber + 1 : 0;
|
|
448
|
+
let inTransaction = false;
|
|
449
|
+
try {
|
|
450
|
+
const links = rows.map((row) => {
|
|
451
|
+
sequence = prev !== undefined ? prev.sequenceNumber + 1 : 0;
|
|
452
|
+
const event = (0, core_1.auditEventFromRow)(row);
|
|
453
|
+
const link = (0, core_1.linkEntry)(event, prev, this.hash);
|
|
454
|
+
prev = { tenantId: tenant, sequenceNumber: link.sequenceNumber, entryHash: link.entryHash };
|
|
455
|
+
return { row, link, occurredAtMs: event.occurredAtMs };
|
|
456
|
+
});
|
|
457
|
+
const sealedAtMs = this.clock.nowMs();
|
|
458
|
+
await conn.beginTransaction();
|
|
459
|
+
inTransaction = true;
|
|
460
|
+
for (const { row, link, occurredAtMs } of links) {
|
|
461
|
+
sequence = link.sequenceNumber;
|
|
462
|
+
await conn.query(`INSERT INTO ${this.chainTable}
|
|
463
|
+
(ac_tenant_id, ac_sequence, ac_al_uuid, ac_content_hash, ac_prev_hash,
|
|
464
|
+
ac_entry_hash, ac_occurred_at_ms, ac_sealed_at_ms)
|
|
465
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
466
|
+
tenant,
|
|
467
|
+
link.sequenceNumber,
|
|
468
|
+
row.al_uuid,
|
|
469
|
+
link.contentHash,
|
|
470
|
+
link.prevHash,
|
|
471
|
+
link.entryHash,
|
|
472
|
+
occurredAtMs,
|
|
473
|
+
sealedAtMs,
|
|
474
|
+
]);
|
|
475
|
+
}
|
|
476
|
+
await conn.commit();
|
|
477
|
+
inTransaction = false;
|
|
478
|
+
}
|
|
479
|
+
catch (err) {
|
|
480
|
+
let rollbackFailed = false;
|
|
481
|
+
if (inTransaction) {
|
|
482
|
+
try {
|
|
483
|
+
await conn.rollback();
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
rollbackFailed = true;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
this.recordFailure(tenant, sequence, err);
|
|
490
|
+
if (rollbackFailed)
|
|
491
|
+
throw new ConnectionUnusable();
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/** Log a failed batch (once per tenant and reason per sweep) and park it if it repeats. */
|
|
495
|
+
recordFailure(tenant, sequence, err) {
|
|
496
|
+
const reason = auditSealErrorClass(err);
|
|
497
|
+
const repeating = isRepeating(err);
|
|
498
|
+
if (repeating && this.stuck.size >= exports.AUDIT_SEAL_STUCK && !this.stuck.has(tenant)) {
|
|
499
|
+
// Systemic failure: one count-only line per sweep, no per-tenant log, retried each tick.
|
|
500
|
+
if (!this.overflowLogged) {
|
|
501
|
+
this.overflowLogged = true;
|
|
502
|
+
this.logger.error(`audit sealer: ${exports.AUDIT_SEAL_STUCK} tenants are parked (the maximum); further ` +
|
|
503
|
+
'tenants failing with a repeating error are retried every tick until the next sweep');
|
|
504
|
+
}
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (repeating)
|
|
508
|
+
this.stuck.add(tenant);
|
|
509
|
+
this.logOnce(reason, tenant, `audit sealer: tenant ${JSON.stringify(tenant)} batch rolled back at sequence ${sequence} ` +
|
|
510
|
+
`(${reason}); ${repeating ? 'parked until the next sweep' : 'retried next tick'}`);
|
|
511
|
+
}
|
|
512
|
+
logOnce(reason, tenant, message) {
|
|
513
|
+
const key = `${reason}\u0000${tenant}`;
|
|
514
|
+
if (this.logged.has(key))
|
|
515
|
+
return;
|
|
516
|
+
this.logged.add(key);
|
|
517
|
+
this.logger.error(message);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
exports.AuditChainSealer = AuditChainSealer;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `AUDIT_SEAL_*` env loader for the deferred audit sealer (audit-chain-store SPEC §5.I, §10,
|
|
3
|
+
* §13). Read once at boot by the persistence wiring; an invalid value fails the boot with the
|
|
4
|
+
* variable name and its bounds, never the value (it might be a misplaced secret). Unset, empty
|
|
5
|
+
* or whitespace-only ⇒ the default (as `loadMessageConfig`).
|
|
6
|
+
*/
|
|
7
|
+
/** The two tunables of the sealer (the sealer itself cannot be switched off, SPEC §11). */
|
|
8
|
+
export interface AuditSealConfig {
|
|
9
|
+
/** Milliseconds between the end of one tick and the start of the next. */
|
|
10
|
+
readonly intervalMs: number;
|
|
11
|
+
/** Rows sealed per tenant per tick. */
|
|
12
|
+
readonly batch: number;
|
|
13
|
+
}
|
|
14
|
+
export declare const DEFAULT_AUDIT_SEAL_INTERVAL_MS = 2000;
|
|
15
|
+
export declare const MIN_AUDIT_SEAL_INTERVAL_MS = 500;
|
|
16
|
+
export declare const MAX_AUDIT_SEAL_INTERVAL_MS = 60000;
|
|
17
|
+
export declare const DEFAULT_AUDIT_SEAL_BATCH = 500;
|
|
18
|
+
export declare const MIN_AUDIT_SEAL_BATCH = 1;
|
|
19
|
+
export declare const MAX_AUDIT_SEAL_BATCH = 5000;
|
|
20
|
+
/** Load and validate `AUDIT_SEAL_INTERVAL_MS` and `AUDIT_SEAL_BATCH`. Throws on an invalid value. */
|
|
21
|
+
export declare function loadAuditSealConfig(env: NodeJS.ProcessEnv): AuditSealConfig;
|
|
22
|
+
//# sourceMappingURL=audit-seal-config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit-seal-config.d.ts","sourceRoot":"","sources":["../../src/audit/audit-seal-config.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,2FAA2F;AAC3F,MAAM,WAAW,eAAe;IAC9B,0EAA0E;IAC1E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,uCAAuC;IACvC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,eAAO,MAAM,8BAA8B,OAAO,CAAC;AACnD,eAAO,MAAM,0BAA0B,MAAM,CAAC;AAC9C,eAAO,MAAM,0BAA0B,QAAS,CAAC;AACjD,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAC5C,eAAO,MAAM,oBAAoB,IAAI,CAAC;AACtC,eAAO,MAAM,oBAAoB,OAAO,CAAC;AAqBzC,qGAAqG;AACrG,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,eAAe,CAiB3E"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `AUDIT_SEAL_*` env loader for the deferred audit sealer (audit-chain-store SPEC §5.I, §10,
|
|
4
|
+
* §13). Read once at boot by the persistence wiring; an invalid value fails the boot with the
|
|
5
|
+
* variable name and its bounds, never the value (it might be a misplaced secret). Unset, empty
|
|
6
|
+
* or whitespace-only ⇒ the default (as `loadMessageConfig`).
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.MAX_AUDIT_SEAL_BATCH = exports.MIN_AUDIT_SEAL_BATCH = exports.DEFAULT_AUDIT_SEAL_BATCH = exports.MAX_AUDIT_SEAL_INTERVAL_MS = exports.MIN_AUDIT_SEAL_INTERVAL_MS = exports.DEFAULT_AUDIT_SEAL_INTERVAL_MS = void 0;
|
|
10
|
+
exports.loadAuditSealConfig = loadAuditSealConfig;
|
|
11
|
+
exports.DEFAULT_AUDIT_SEAL_INTERVAL_MS = 2000;
|
|
12
|
+
exports.MIN_AUDIT_SEAL_INTERVAL_MS = 500;
|
|
13
|
+
exports.MAX_AUDIT_SEAL_INTERVAL_MS = 60_000;
|
|
14
|
+
exports.DEFAULT_AUDIT_SEAL_BATCH = 500;
|
|
15
|
+
exports.MIN_AUDIT_SEAL_BATCH = 1;
|
|
16
|
+
exports.MAX_AUDIT_SEAL_BATCH = 5000;
|
|
17
|
+
/** Plain decimal digits only (no sign, exponent, radix prefix or fraction). */
|
|
18
|
+
const PLAIN_DIGITS = /^[0-9]{1,10}$/;
|
|
19
|
+
function boundedInteger(env, name, fallback, min, max) {
|
|
20
|
+
const value = env[name]?.trim();
|
|
21
|
+
if (!value)
|
|
22
|
+
return fallback;
|
|
23
|
+
const n = PLAIN_DIGITS.test(value) ? Number(value) : NaN;
|
|
24
|
+
if (!Number.isSafeInteger(n) || n < min || n > max) {
|
|
25
|
+
throw new Error(`${name} must be a whole number between ${min} and ${max}`);
|
|
26
|
+
}
|
|
27
|
+
return n;
|
|
28
|
+
}
|
|
29
|
+
/** Load and validate `AUDIT_SEAL_INTERVAL_MS` and `AUDIT_SEAL_BATCH`. Throws on an invalid value. */
|
|
30
|
+
function loadAuditSealConfig(env) {
|
|
31
|
+
return {
|
|
32
|
+
intervalMs: boundedInteger(env, 'AUDIT_SEAL_INTERVAL_MS', exports.DEFAULT_AUDIT_SEAL_INTERVAL_MS, exports.MIN_AUDIT_SEAL_INTERVAL_MS, exports.MAX_AUDIT_SEAL_INTERVAL_MS),
|
|
33
|
+
batch: boundedInteger(env, 'AUDIT_SEAL_BATCH', exports.DEFAULT_AUDIT_SEAL_BATCH, exports.MIN_AUDIT_SEAL_BATCH, exports.MAX_AUDIT_SEAL_BATCH),
|
|
34
|
+
};
|
|
35
|
+
}
|