diff --git a/AGENTS.md b/AGENTS.md index 7efc88c..8f17595 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,19 @@ Use the Maven 3.9.11 wrapper (Java 21); Maven 3.10 cannot read the parent POM. O ## Architecture -Package: `com.kingsrook.qbits.wormaudit`. Entry point: `WormAuditQBitProducer implements QBitMetaDataProducer`. Config exposes `tableMetaDataCustomizer` and `retentionYears` (default 6). +Package: `com.kingsrook.qbits.wormaudit`. Entry point: `WormAuditQBitProducer implements QBitMetaDataProducer`. Config requires the host `backendName`; core audit tables must exist before production. + +- `ddl`: reviewed Liquibase PostgreSQL template and safe renderer; public sidecar tables and owner-only retention purge. +- `guard`: immutable QQQ capabilities and value-free SQLSTATE mapping. +- `canonical`, `chain`, `checkpoint`: strict JCS v1, transaction/advisory-locked writer, explicit detail coverage, pinned Ed25519 ranges. +- `verify`: snapshot checks of content, continuity, coverage and checkpoints, starting at the purge anchor. +- `processes`: standard streamed ETL action, scheduled chainer and daily QAlerts producer; never app navigation entries. +- `model`: read-only tables, range record references and standard record child lists. No app-home widgets or export execution in phase (a). + +PostgreSQL uses a transaction advisory lock; other backends require a single writer JVM. +Use `./mvnw.cmd -o verify` offline. PostgreSQL tests skip without Docker locally, but fail whenever `CI` is set. +Core RC.5 has no assistant deny API (reflective registration) and disallows underscores in declared alert-type metadata; +signals retain the required `worm_audit.chain.break` name and dispatch through QAlerts without that declaration. ## Invariants and naming diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a967a..20c6b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,4 +6,9 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ### Added -- Initial scaffold +- Phase (a): PostgreSQL append-only tables/triggers, safe DDL renderer, retention purge with immutable refusal/anchor records, and runtime grants. +- Canonical audit v1 documents, contiguous hash chain with late-detail coverage, pinned Ed25519 checkpoints and binary Merkle roots. +- Snapshot verification, streamed ETL Review/Result action, scheduled chaining/daily verification, and core QAlerts raise/resolve/heartbeat integration. +- Read-only metadata, WORM Audit table navigation, record range references and child lists; reflective RC.6 assistant deny registration. +- Offline memory/H2 acceptance, real pre-insert PHI canary hook, and PostgreSQL Testcontainers acceptance with CI-strict Docker availability. +- Adoption and operations documentation. Export execution and app-home widgets remain later phases. diff --git a/CLAUDE.md b/CLAUDE.md index 4ca02cc..f020596 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,19 @@ Use the Maven 3.9.11 wrapper (Java 21); Maven 3.10 cannot read the parent POM. O ## Architecture -Package: `com.kingsrook.qbits.wormaudit`. Entry point: `WormAuditQBitProducer implements QBitMetaDataProducer`. Config exposes `tableMetaDataCustomizer` and `retentionYears` (default 6). +Package: `com.kingsrook.qbits.wormaudit`. Entry point: `WormAuditQBitProducer implements QBitMetaDataProducer`. Config requires the host `backendName`; core audit tables must exist before production. + +- `ddl`: reviewed Liquibase PostgreSQL template and safe renderer; public sidecar tables and owner-only retention purge. +- `guard`: immutable QQQ capabilities and value-free SQLSTATE mapping. +- `canonical`, `chain`, `checkpoint`: strict JCS v1, transaction/advisory-locked writer, explicit detail coverage, pinned Ed25519 ranges. +- `verify`: snapshot checks of content, continuity, coverage and checkpoints, starting at the purge anchor. +- `processes`: standard streamed ETL action, scheduled chainer and daily QAlerts producer; never app navigation entries. +- `model`: read-only tables, range record references and standard record child lists. No app-home widgets or export execution in phase (a). + +PostgreSQL uses a transaction advisory lock; other backends require a single writer JVM. +Use `./mvnw.cmd -o verify` offline. PostgreSQL tests skip without Docker locally, but fail whenever `CI` is set. +Core RC.5 has no assistant deny API (reflective registration) and disallows underscores in declared alert-type metadata; +signals retain the required `worm_audit.chain.break` name and dispatch through QAlerts without that declaration. ## Invariants and naming diff --git a/README.md b/README.md index 1322288..c851f22 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,279 @@ # QBit WORM Audit -Write-once, read-many (WORM) audit for QQQ applications. The QQQ core audit tables (`audit`, `audit_detail`) and this qbit's own tables become append-only at the database level, every audit row is linked into a hash chain with periodic signed checkpoints, a verification action and a daily job detect tampering, and new audit rows are sealed into an S3 bucket with Object Lock in COMPLIANCE mode (default retention 6 years, the HIPAA audit-record retention). Audit content is hashed and sealed exactly as core writes it; with qbit-secured-data, PHI field values in audit details are already HMACs, so no raw PHI reaches the chain or the sealed copies. This repository currently contains the compiling scaffold; the design is in [docs/DESIGN.md](docs/DESIGN.md) once the spec PR lands. +Phases (a) and (b) provide PostgreSQL append-only enforcement, an asynchronous audit hash chain, +Ed25519 checkpoints, live and sealed-export verification actions, and daily jobs with core QAlerts signals. +It hashes exactly the audit values core stored; it does not read application records or +secured-data keys. Sealed JSONL exports use qbit-object-storage 0.1.0-RC.2 and COMPLIANCE +Object Lock. The app home and setup widgets remain phase (c). -## Requirements - -Java 21 and QQQ 4.1.0-RC.5 or later. PostgreSQL is the supported database for the append-only enforcement. The Maven wrapper pins Maven 3.9.11. - -## Install - -```xml - - com.kingsrook.qbits - qbit-worm-audit - 0.1.0-SNAPSHOT - -``` +Requires Java 21, Maven wrapper 3.9.11 and QQQ 4.1.0-RC.5. PostgreSQL provides enforcement; +memory/H2 support tamper-evidence tests without database enforcement. ## Tables and permissions -Planned tables (none are produced yet): `worm_audit_chain`, `worm_audit_checkpoint`, `worm_audit_export`, `worm_audit_verification`, `worm_audit_purge`. Backend names match table names. Permissions use the full `worm_audit` prefix. +All sidecar tables use the configured host backend, snake_case fields, BIGINT identifiers, +record sections, and full table-name permissions. They do not audit themselves. The app lists +Verifications, Sealed Exports, Checkpoints, Audit Chain, and Purges in that order. + +| Table | Purpose | Permission | +|---|---|---| +| `worm_audit_verification` | Immutable USER/SCHEDULE results | `worm_audit_verification.read` | +| `worm_audit_export` | Successfully sealed export catalog | `worm_audit_export.read` | +| `worm_audit_checkpoint` | Signed ranges and Merkle roots | `worm_audit_checkpoint.read` | +| `worm_audit_chain` | Contiguous links and explicit detail coverage | `worm_audit_chain.read` | +| `worm_audit_purge` | Refusals, removal counts and retention anchors | `worm_audit_purge.read` | + +`worm_audit.verify` authorizes the standard streamed ETL **Verify audit chain** action. +It also authorizes **Verify sealed export** on selected export records. `worm_audit.export` +authorizes **Export now** on the export table. Processes are table/record actions or schedules; +none is a navigation entry. +Insert/update/delete capabilities are absent on sidecar tables; update/delete capabilities +are removed from all four core audit tables. Internal appends use an explicit permission bypass. +Checkpoint, export and verification record screens have a bounded chain-link child list; +range endpoints and export/checkpoint references link to their records. + +The table metadata customizer can configure host ESB publications. Quick Search registration +is host-owned through `QuickSearchQBitConfig.searchableTables`; add all five tables. +Verification and export inserts use QQQ's record-change listener SPI, +so an installed ESB publisher receives those events when the host configures its destinations +on these two tables. The qbit defines no destination and configures no chain-link publication. ## Configuration -`WormAuditQBitConfig` exposes an optional `tableMetaDataCustomizer` and `retentionYears` (default 6). +| Field | Default / contract | +|---|---| +| `backendName` | Required; core and sidecar audit tables must use this backend | +| `retentionYears` | 6; compiled into the migration, not changed by a runtime flag | +| `signingKey` | Optional `Supplier`; `SigningKey(keyId, PrivateKey)` | +| `trustedPublicKeys` | `Map`; must contain the signing key's public key | +| `chainIntervalSeconds` | 30 | +| `settleHorizonSeconds` | 60 (zero allowed) | +| `exportBucketAlias` | `worm_audit`; qbit-object-storage BACKUP bucket alias | +| `exportPrefix` | `worm-audit`; slash-separated letters, digits, underscores or hyphens | +| `exportCron` | `0 0 2 * * ?`; 02:00 UTC on the configured scheduler | +| `batchSize` | 1,000 headers per tick / links per insert batch | +| `lateDetailLookbackHours` | 24; positive hours, older omissions checked by full verification | +| `checkpointEveryLinks` | 10,000; maximum links in each checkpoint | +| `checkpointEveryMinutes` | 60; requires at least one uncheckpointed link | +| `verifyCron` | `0 0 3 * * ?` (03:00 UTC, Quartz syntax) | +| `schedulerName` | `worm_audit`; uses the host scheduler if present, otherwise registers Quartz | +| `coreAuditTableNames` | Overrides keyed by `audit`, `auditDetail`, `auditTable`, `auditUser` | +| `appRole` | `app` | +| `tableMetaDataCustomizer` | Optional; append-only capability guard runs afterward | + +A missing signing key produces no checkpoints and adds the warning **no signing key configured** +to verification. It does not itself break the chain. Signing requires a matching public-key pin. +Rotate by adding a new pin and changing the supplier; retain old pins for old checkpoints. +Never store the private key in a database row or checked-in configuration. ## Adopting in another app +Add `com.kingsrook.qbits:qbit-worm-audit:0.1.0-SNAPSHOT`. Define the host's core audit tables +with `AuditsMetaDataProvider` first, then install the qbit: + ```java -new WormAuditQBitProducer() - .withQBitConfig(new WormAuditQBitConfig()) - .produce(qInstance); +WormAuditQBitConfig config = new WormAuditQBitConfig() + .withBackendName("main") + .withAppRole("app") + .withSigningKey(() -> new WormAuditQBitConfig.SigningKey("2026-10", privateKey)) + .withTrustedPublicKeys(Map.of("2026-10", publicKey)); +new WormAuditQBitProducer().withQBitConfig(config).produce(qInstance); +String migration = WormAuditDdl.postgres(config); ``` -Production currently registers the QBit identity and config only. Database changesets, the object-storage bucket and the alert wiring are documented with each phase. +1. Render and review the migration, then copy it into the host Liquibase changelog. + The [default rendering](docs/ddl/worm-audit-postgres.sql) uses `audit`, `audit_detail`, + `audit_table`, `audit_user`, runtime role `app`, and six years. The renderer resolves table + backend names during production; explicit overrides win. Standalone rendering uses those + defaults. Identifiers are validated and quoted; schema-qualified core table names are supported. +2. Apply the DDL as the schema owner. It creates the five sidecar tables in `public`, functions, + triggers, the NOLOGIN `worm_audit_purger` role, and runtime grants. Core column names must use + the usual snake_case mappings (`audit_id`, `audit_table_id`, etc.). The migration is idempotent. Provision the purger role beforehand or give the migration role permission to create it. +3. Run as a separate runtime role with SELECT/INSERT and sequence USAGE. Do not make it a member + of the purger role or a schema/table owner. Use the host's PostgreSQL RDBMS strategy (RC.5's + base strategy uses MySQL quoting; see the test host strategy in `RdbmsTestSupport`). +4. Start QQQ's standard scheduler with its system session and grant users only the required read + and verification permissions. Point the existing alerts implementation at core `QAlerts`. +5. Configure ESB publications on verification/export in the table customizer. Register all five + tables in the host Quick Search config with `SearchableTableConfig` / `SearchableFieldConfig`. + Index identifiers and status fields; set basepull timestamps to `chained_at`, `created_at`, + `sealed_at`, `finished_at`, and `requested_at` respectively (these tables have no `modifyDate`). -## Build +The RC.6 assistant deny API is registered reflectively when present; absence in RC.5 is a no-op. +RC.5's alert-type metadata validator disallows underscores, so the qbit sends the specified +`worm_audit.chain.break` signal without declaring incompatible alert-type metadata. `QAlerts` +still dispatches it to the sink (or its logging fallback). Every scheduled verification sends +`worm_audit.verify.daily`; FAIL raises with the first break and verification record reference; +PASS resolves dedup key `chain`. -```bash -./mvnw compile -./mvnw test -./mvnw verify +## Chain and verification format + +- JCS v1 uses UTF-16 key ordering, strict surrogate validation and UTC microsecond timestamps. + IDs are integer longs except canonical `recordId`, which is a string. No floating-point values. +- Link hashes concatenate the predecessor's **64 lowercase ASCII hex bytes**, one NUL byte, + and canonical UTF-8 bytes. Genesis is 64 ASCII zeroes. +- Every link names the detail IDs it covered, at most 250. A PostgreSQL insert guard and GIN + overlap index reject duplicate coverage under the same advisory lock. Overflow and later details use + `LATE_DETAIL` links with the same canonical header-and-detail-subset document. The canonical + JSON is never persisted. A v1 content mismatch can identify the link, audit and covered detail + IDs, but cannot identify one changed field among multiple details without original content. +- PostgreSQL chaining uses transaction-scoped advisory lock `86753090421` shared with purge. + Other backends use a JVM-wide lock and require one writer JVM. SQL backends select settled + unchained headers with an anti-join; late commits are picked up on a later tick. +- Merkle leaves are decoded 32-byte link hashes; parents hash the concatenated binary children; + an odd node is promoted unchanged. Ed25519 signs JCS of the seven specified range/trust fields. +- SQL verification uses a repeatable-read snapshot. Full runs check settled header/detail + completeness; selected or filtered runs check their links, predecessor boundaries and + intersecting checkpoints. Full verification currently materializes link metadata in memory. + +## Retention operations + +Only the ops login in `worm_audit_purger` may execute: + +```sql +SELECT public.worm_audit_purge(now() - interval '6 years'); ``` -Verification includes Checkstyle and JaCoCo gates of 70% instructions and 90% classes. On Windows, use `./mvnw.cmd`. +A recent/null cutoff or an incomplete expired prefix records REFUSED and returns normally. +Purge locks the audit tables against concurrent inserts, removes only a prefix closed over +all links and details of its audits, and records its last link id/hash. Detail and chain deletion +is allowed only under the function-owner identity, transaction-local purge flag, and compiled +retention cutoff. An app session setting that flag gets no deletion authority. + +Lookups cannot change id/name or be deleted/truncated. The runtime role may update only the +lookup label/dates through column grants; table-level UPDATE stays revoked. With normal grants, +PostgreSQL rejects prohibited runtime writes as SQLSTATE 42501 before executing a trigger. +The second layer (triggers) returns `P0001` / `worm_audit:` even if someone grants wider DML +rights. The qbit maps those errors to a value-free user-facing message. + +Verification starts from the latest successful purge anchor. Checkpoint signatures still verify +after purge, but Merkle recomputation of a purged/partly purged checkpoint requires its sealed +export and is reported as a warning. Seal and verify exports before exercising retention +removal in production. Superusers/owners can disable triggers; +protect signing keys and keep public-key pins outside the database. + +## Sealed exports + +Install qbit-object-storage and configure `exportBucketAlias` (default `worm_audit`) as a +`BucketRole.BACKUP` bucket. **Object Lock must be enabled when the bucket is created**. +The exporter requires the bucket's `OBJECT_LOCK` capability to be `SUPPORTED`; run the +object-storage read-only capability probe during setup. Unknown, partial and unsupported +capabilities all refuse sealing. Every object receives explicit **COMPLIANCE** retention +until the sealing time plus `retentionYears` (six by default), checked by HEAD after PUT. + +The runtime IAM policy needs `s3:PutObject`, `s3:GetObject`, `s3:ListBucket`, +`s3:GetObjectRetention`, `s3:PutObjectRetention`, and +`s3:GetBucketObjectLockConfiguration`. Grant no delete or governance-bypass access. +The storage bucket's upload size limit must accommodate the largest daily JSONL file. +Retention protects object versions; protect the storage volumes and maintain off-site copies too. + +At 02:00 UTC, or through **Export now**, the exporter takes the chainer's writer lock and +seals links after the previous export through the latest checkpoint. The first export starts +after the purge anchor, or at genesis. No new checkpoint produces an explicit "Nothing to +seal" Result. If retention has removed an unexported gap, or a range cannot be proven by +complete signed checkpoints, sealing refuses rather than recording incomplete evidence. +Other database backends require a single writer JVM, as in phase (a). + +Keys use `///
/-/` with a UTC date: + +- `links.jsonl`: JCS lines containing `link` identifiers/hashes and the embedded canonical + `content` document exactly as hashed. +- `checkpoints.jsonl`: JCS checkpoint envelopes including signatures and public keys. +- `manifest.json`: JCS `worm-audit-export/1` with `fromLinkId`, `toLinkId`, `linkCount`, + `checkpointCount`, `files` (filename to SHA-256), `headHash`, `prevManifestSha256`, + `qbitVersion`, and `createdAt`. + +Files are streamed through temporary disk storage with 1,000-link pages and batched content +fetches, then removed. Only ids, hashes and object metadata enter the export catalog. +The `object_versions` text column pins each upload's returned version id in a JCS JSON +object keyed by `checkpoints.jsonl`, `links.jsonl`, and `manifest.json`. Sealing refuses +missing version ids or a different version in the retention HEAD response, leaving no +catalog row and raising the export failure alert. +Partial upload failures leave no catalog row; retained orphan objects may remain. Retrying +can create new locked versions under the same keys. Failures raise `worm_audit.export.failed` +with dedup key `export`; success resolves it. Every scheduled run emits +`worm_audit.export.daily`. + +**Verify sealed export** accepts one or several selected records. It downloads and checks +the object hashes, retention, manifest chain, link hashes, pinned checkpoint signatures and +Merkle roots, then compares the sealed content and link metadata against a live database +snapshot. Differences name exact link/audit ids without printing audit values. Each export +gets its own `SEALED_EXPORT` verification row. Purged prefixes verify from the sealed copy +alone and receive an INFO line. Verification does not write restored audit rows. + +Verification compares every current HEAD version to the catalog and reports an ERROR for +any overwrite, even when the replacement bytes are identical. It then reads the recorded +sealed versions, so retained evidence remains verifiable after an overwrite; the overall +verification still fails because the overwrite is a break. Missing catalog versions fail +closed. RC.2's `StorageBucket.get` has no version parameter, so the adapter uses the public +`ObjectStorageRuntime.connection(...).client()` accessor for an explicit S3 version GET, +reusing its connection, key normalization and rate limiter and checking the response version. +No object-storage change is required; this adapter depends on RC.2's public internal runtime +accessors. The storage credentials need `s3:GetObjectVersion` in addition to the existing +upload, HEAD and listing permissions. + +## Build and acceptance + +```powershell +./mvnw.cmd -o verify +``` + +Checkstyle and JaCoCo gates (70% instructions, 90% classes) run without Docker. Memory and H2 +tests cover canonical vectors, signatures, rollback, exact coverage, late arrivals/details, +verification findings, PHI tokenization, process summaries, permissions, and the alerts SPI. +The PHI stand-in uses RC.5 `AuditActionCustomizerInterface.customizeInput` before insertion; +classification is test-local because RC.5 lacks `DataClassification`. + +PostgreSQL acceptance uses `postgres:16-alpine`, applies the DDL twice and tests grants, +triggers, purge, real QQQ RDBMS chaining, superuser tamper, and 8 writers / 3 chainers. +It skips automatically without Docker locally and fails if **any** `CI` environment value is set. +On a Docker-enabled coordinator (image already available if running without network): + +```powershell +$env:CI = 'true' +./mvnw.cmd -o '-Dtest=PostgresAcceptanceTest' test +./mvnw.cmd -o verify +``` + +MinIO export acceptance uses the same `RequiresDockerCondition`. It checks COMPLIANCE +retention, denied version deletion, overwrite version preservation, export verification, +exact live-tamper findings through the record action, and refusal of a bucket without lock: + +```powershell +$env:CI = 'true' +./mvnw.cmd -o '-Dtest=MinioExportAcceptanceTest' test +``` + +The default MinIO image is pinned to the same Chainguard digest as object-storage's tests. +Override it with `-Dqqq.test.minioImage=` when necessary. Images must already be +available for a completely network-free Docker run. + +POSIX equivalents: `CI=true ./mvnw -o -Dtest=MinioExportAcceptanceTest test`, +`CI=true ./mvnw -o -Dtest=PostgresAcceptanceTest test` and +`CI=true ./mvnw -o verify`. No commits, publishing or deployment are part of these commands. ## License [Apache License 2.0](LICENSE). Copyright Kingsrook, LLC. + +### Physical columns and bounded polling + +`WormAuditDdl.postgres(config)` renders table names only. Core physical columns +must be snake_case: `audit.id`, `audit.timestamp`, `audit_detail.id`, +`audit_detail.audit_id`, `audit_table.id/name/label/create_date/modify_date`, and +`audit_user.id/name/create_date/modify_date`. QInstance field backend-name +customizations do not change this migration; adapt the reviewed SQL before +applying it if your schema differs. UTC values in `timestamp without time zone` +and `timestamptz` are both supported. + +`lateDetailLookbackHours` defaults to **24** (positive hours). Each PostgreSQL +poll checks uncovered details whose chained header timestamp lies in +`[cutoff - lookback, cutoff)`. The daily full verifier reports omissions outside +that window. Initial header details are always chained, including overflow +chunks. Checkpoints contain at most `checkpointEveryLinks` links; multiple full +ranges can be signed per tick. Verification uses 1,000-link keyset pages. + +Full verification retains at most 1,000 exact break diagnostics and 100 warnings, +while persisting the total break count. SQL completeness counts all offenders +and reports the first 100 header ids and first 100 detail ids. This keeps a badly +corrupted chain from exhausting verifier memory. diff --git a/docs/ddl/worm-audit-postgres.sql b/docs/ddl/worm-audit-postgres.sql new file mode 100644 index 0000000..fca11e9 --- /dev/null +++ b/docs/ddl/worm-audit-postgres.sql @@ -0,0 +1,326 @@ +-- liquibase formatted sql + +-- changeset worm-audit:1 +CREATE TABLE IF NOT EXISTS public.worm_audit_chain ( + id bigint PRIMARY KEY, + kind text NOT NULL, + audit_id bigint NOT NULL, + detail_ids text NOT NULL, + prev_hash text NOT NULL, + link_hash text NOT NULL, + chained_at timestamptz NOT NULL, + CHECK (id > 0), + CHECK (kind IN ('AUDIT', 'LATE_DETAIL')), + CHECK (prev_hash ~ '^[0-9a-f]{64}$' AND link_hash ~ '^[0-9a-f]{64}$'), + CHECK (detail_ids = '' OR (detail_ids ~ '^(0|[1-9][0-9]*)(,(0|[1-9][0-9]*))*$' AND cardinality(string_to_array(detail_ids, ',')) <= 250)) +); + +CREATE TABLE IF NOT EXISTS public.worm_audit_checkpoint ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + from_link_id bigint, + to_link_id bigint, + link_count bigint, + head_hash text, + merkle_root text, + algorithm text, + key_id text, + public_key text, + signature text, + created_at timestamptz +); + +CREATE TABLE IF NOT EXISTS public.worm_audit_export ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + from_link_id bigint, + to_link_id bigint, + from_checkpoint_id bigint, + to_checkpoint_id bigint, + bucket_alias text, + object_prefix text, + object_versions text, + manifest_key text, + manifest_sha256 text, + prev_manifest_sha256 text, + object_count bigint, + byte_count bigint, + lock_mode text, + retain_until timestamptz, + sealed_at timestamptz +); + +CREATE TABLE IF NOT EXISTS public.worm_audit_verification ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + kind text, + "trigger" text, + started_at timestamptz, + finished_at timestamptz, + from_link_id bigint, + to_link_id bigint, + links_checked bigint, + outcome text, + break_count bigint, + first_break_link_id bigint, + first_break_audit_id bigint, + first_break_reason text, + export_id bigint, + run_by text +); + +CREATE TABLE IF NOT EXISTS public.worm_audit_purge ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + requested_at timestamptz, + requested_by text, + cutoff timestamptz, + retention_interval text, + outcome text, + reason text, + audits_deleted bigint, + details_deleted bigint, + links_deleted bigint, + anchor_link_id bigint, + anchor_hash text +); + +CREATE UNIQUE INDEX IF NOT EXISTS worm_audit_chain_audit ON public.worm_audit_chain(audit_id) WHERE kind = 'AUDIT'; +CREATE INDEX IF NOT EXISTS worm_audit_chain_audit_all ON public.worm_audit_chain(audit_id); + +-- changeset worm-audit:2 splitStatements:false +CREATE OR REPLACE FUNCTION public.worm_audit_reject() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp SET timezone = 'UTC' AS $$ +DECLARE + old_time timestamptz; + function_owner name; + stack text; + row_identity text := 'all'; +BEGIN + IF TG_OP = 'DELETE' AND current_setting('worm_audit.purging', true) = 'on' THEN + SELECT pg_get_userbyid(proowner) INTO function_owner FROM pg_proc + WHERE oid = 'public.worm_audit_purge(timestamptz)'::regprocedure; + GET DIAGNOSTICS stack = PG_CONTEXT; + IF current_user = function_owner AND position('function worm_audit_purge(timestamp with time zone)' in stack) > 0 THEN + IF TG_RELID = '"audit"'::regclass THEN + old_time := OLD.timestamp; + ELSIF TG_RELID = '"audit_detail"'::regclass THEN + SELECT timestamp INTO old_time FROM "audit" WHERE id = OLD.audit_id; + ELSIF TG_RELID = 'public.worm_audit_chain'::regclass THEN + SELECT timestamp INTO old_time FROM "audit" WHERE id = OLD.audit_id; + END IF; + IF old_time < now() - interval '6 years' THEN + RETURN OLD; + END IF; + END IF; + END IF; + IF TG_LEVEL = 'ROW' THEN + row_identity := OLD.id::text; + END IF; + RAISE EXCEPTION USING ERRCODE = 'P0001', + MESSAGE = format('worm_audit: audit rows are write-once; %s of "%s" is not allowed (id=%s). Expired rows are removed only by worm_audit_purge().', + TG_OP, TG_TABLE_NAME, row_identity), + HINT = 'see qbit-worm-audit docs/DESIGN.md section 4'; +END; +$$; + +-- changeset worm-audit:3 splitStatements:false +CREATE OR REPLACE FUNCTION public.worm_audit_lookup_reject() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$ +BEGIN + IF TG_OP = 'UPDATE' AND NEW.id IS NOT DISTINCT FROM OLD.id AND NEW.name IS NOT DISTINCT FROM OLD.name THEN + RETURN NEW; + END IF; + RAISE EXCEPTION USING ERRCODE = 'P0001', + MESSAGE = format('worm_audit: audit rows are write-once; %s of "%s" is not allowed (id=%s). Expired rows are removed only by worm_audit_purge().', + TG_OP, TG_TABLE_NAME, OLD.id), + HINT = 'see qbit-worm-audit docs/DESIGN.md section 4'; +END; +$$; + +-- changeset worm-audit:4 splitStatements:false +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'worm_audit_purger') THEN + CREATE ROLE worm_audit_purger NOLOGIN; + END IF; +END; +$$; + +-- changeset worm-audit:5 splitStatements:false +CREATE OR REPLACE FUNCTION public.worm_audit_purge(p_cutoff timestamptz) RETURNS void +LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public, pg_temp SET timezone = 'UTC' AS $$ +DECLARE + boundary bigint; + smaller bigint; + anchor text; + audits_count bigint; + details_count bigint; + links_count bigint; +BEGIN + IF p_cutoff IS NULL OR p_cutoff > now() - interval '6 years' THEN + INSERT INTO public.worm_audit_purge(requested_at, requested_by, cutoff, retention_interval, outcome, reason) + VALUES(now(), session_user, p_cutoff, '6 years', 'REFUSED', 'Cutoff is within retention or null'); + RETURN; + END IF; + -- Same lock as the Java chainer; table locks also exclude concurrent audit inserts. + PERFORM pg_advisory_xact_lock(86753090421); + LOCK TABLE "audit", "audit_detail", public.worm_audit_chain IN SHARE ROW EXCLUSIVE MODE; + SELECT coalesce(min(c.id) - 1, (SELECT max(id) FROM public.worm_audit_chain)) INTO boundary + FROM public.worm_audit_chain c LEFT JOIN "audit" a ON a.id = c.audit_id + WHERE a.id IS NULL OR a.timestamp IS NULL OR a.timestamp >= p_cutoff; + -- Shrink to a prefix closed over every audit's links AND all its stored details. + LOOP + SELECT min(c.id) - 1 INTO smaller FROM public.worm_audit_chain c + WHERE c.id <= boundary AND ( + EXISTS (SELECT FROM public.worm_audit_chain later WHERE later.audit_id = c.audit_id AND later.id > boundary) + OR EXISTS (SELECT FROM "audit_detail" d WHERE d.audit_id = c.audit_id AND NOT EXISTS ( + SELECT FROM public.worm_audit_chain covered WHERE covered.audit_id = c.audit_id + AND d.id::text = ANY(string_to_array(covered.detail_ids, ','))))); + EXIT WHEN smaller IS NULL; + boundary := smaller; + END LOOP; + SELECT link_hash INTO anchor FROM public.worm_audit_chain WHERE id = boundary; + IF anchor IS NULL THEN + INSERT INTO public.worm_audit_purge(requested_at, requested_by, cutoff, retention_interval, outcome, reason) + VALUES(now(), session_user, p_cutoff, '6 years', 'REFUSED', 'No complete expired chain prefix'); + RETURN; + END IF; + PERFORM set_config('worm_audit.purging', 'on', true); + DELETE FROM "audit_detail" d WHERE EXISTS + (SELECT FROM public.worm_audit_chain c WHERE c.audit_id = d.audit_id AND c.id <= boundary); + GET DIAGNOSTICS details_count = ROW_COUNT; + -- Keep ids locally because links must be deleted before their audit headers. + WITH removed AS (DELETE FROM public.worm_audit_chain WHERE id <= boundary RETURNING audit_id), + removed_audits AS (DELETE FROM "audit" WHERE id IN (SELECT audit_id FROM removed) RETURNING id) + SELECT (SELECT count(*) FROM removed), (SELECT count(*) FROM removed_audits) INTO links_count, audits_count; + PERFORM set_config('worm_audit.purging', 'off', true); + INSERT INTO public.worm_audit_purge(requested_at, requested_by, cutoff, retention_interval, outcome, + audits_deleted, details_deleted, links_deleted, anchor_link_id, anchor_hash) + VALUES(now(), session_user, p_cutoff, '6 years', 'PURGED', audits_count, details_count, links_count, boundary, anchor); +END; +$$; + +-- changeset worm-audit:6 +DROP TRIGGER IF EXISTS worm_audit_row_guard ON "audit"; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON "audit" + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON "audit"; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON "audit" + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON "audit" FROM "app"; +GRANT SELECT, INSERT ON "audit" TO "app"; + +DROP TRIGGER IF EXISTS worm_audit_row_guard ON "audit_detail"; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON "audit_detail" + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON "audit_detail"; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON "audit_detail" + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON "audit_detail" FROM "app"; +GRANT SELECT, INSERT ON "audit_detail" TO "app"; + +DROP TRIGGER IF EXISTS worm_audit_row_guard ON public.worm_audit_chain; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON public.worm_audit_chain + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON public.worm_audit_chain; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON public.worm_audit_chain + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON public.worm_audit_chain FROM "app"; +GRANT SELECT, INSERT ON public.worm_audit_chain TO "app"; + +DROP TRIGGER IF EXISTS worm_audit_row_guard ON public.worm_audit_checkpoint; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON public.worm_audit_checkpoint + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON public.worm_audit_checkpoint; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON public.worm_audit_checkpoint + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON public.worm_audit_checkpoint FROM "app"; +GRANT SELECT, INSERT ON public.worm_audit_checkpoint TO "app"; + +DROP TRIGGER IF EXISTS worm_audit_row_guard ON public.worm_audit_export; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON public.worm_audit_export + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON public.worm_audit_export; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON public.worm_audit_export + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON public.worm_audit_export FROM "app"; +GRANT SELECT, INSERT ON public.worm_audit_export TO "app"; + +DROP TRIGGER IF EXISTS worm_audit_row_guard ON public.worm_audit_verification; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON public.worm_audit_verification + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON public.worm_audit_verification; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON public.worm_audit_verification + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON public.worm_audit_verification FROM "app"; +GRANT SELECT, INSERT ON public.worm_audit_verification TO "app"; + +DROP TRIGGER IF EXISTS worm_audit_row_guard ON public.worm_audit_purge; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON public.worm_audit_purge + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON public.worm_audit_purge; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON public.worm_audit_purge + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON public.worm_audit_purge FROM "app"; +GRANT SELECT, INSERT ON public.worm_audit_purge TO "app"; + +DROP TRIGGER IF EXISTS worm_audit_row_guard ON "audit_table"; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON "audit_table" + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_lookup_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON "audit_table"; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON "audit_table" + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON "audit_table" FROM "app"; +GRANT SELECT, INSERT ON "audit_table" TO "app"; + +DROP TRIGGER IF EXISTS worm_audit_row_guard ON "audit_user"; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON "audit_user" + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_lookup_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON "audit_user"; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON "audit_user" + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON "audit_user" FROM "app"; +GRANT SELECT, INSERT ON "audit_user" TO "app"; + +-- Lookups may refresh display metadata, but never identities or names. +GRANT UPDATE (label, create_date, modify_date) ON "audit_table" TO "app"; +GRANT UPDATE (create_date, modify_date) ON "audit_user" TO "app"; + +REVOKE ALL ON FUNCTION public.worm_audit_purge(timestamptz) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.worm_audit_purge(timestamptz) FROM "app"; +GRANT EXECUTE ON FUNCTION public.worm_audit_purge(timestamptz) TO worm_audit_purger; + +-- changeset worm-audit:7 splitStatements:false +DO $$ +DECLARE + table_name text; + sequence_name text; +BEGIN + FOREACH table_name IN ARRAY ARRAY['"audit"', '"audit_detail"', '"audit_table"', '"audit_user"', + 'public.worm_audit_checkpoint', 'public.worm_audit_export', 'public.worm_audit_verification', 'public.worm_audit_purge'] LOOP + sequence_name := pg_get_serial_sequence(table_name, 'id'); + IF sequence_name IS NOT NULL THEN + EXECUTE format('GRANT USAGE ON SEQUENCE %s TO "app"', sequence_name); + END IF; + END LOOP; +END; +$$; + +-- changeset worm-audit:8 splitStatements:false +CREATE OR REPLACE FUNCTION public.worm_audit_detail_coverage() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$ +BEGIN + -- Serialize even direct SQL appends; the GIN index makes overlap checks indexed. + PERFORM pg_advisory_xact_lock(86753090421); + IF NEW.detail_ids <> '' AND ( + EXISTS (SELECT FROM unnest(string_to_array(NEW.detail_ids, ',')) AS d(id) GROUP BY d.id HAVING count(*) > 1) + OR EXISTS (SELECT FROM public.worm_audit_chain c + WHERE string_to_array(c.detail_ids, ',') && string_to_array(NEW.detail_ids, ','))) THEN + RAISE EXCEPTION USING ERRCODE = '23505', MESSAGE = 'worm_audit: audit detail is already covered by the chain'; + END IF; + RETURN NEW; +END; +$$; + +-- changeset worm-audit:9 +CREATE INDEX IF NOT EXISTS worm_audit_chain_detail_coverage ON public.worm_audit_chain USING gin (string_to_array(detail_ids, ',')); +DROP TRIGGER IF EXISTS worm_audit_detail_coverage_guard ON public.worm_audit_chain; +CREATE TRIGGER worm_audit_detail_coverage_guard BEFORE INSERT ON public.worm_audit_chain + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_detail_coverage(); diff --git a/pom.xml b/pom.xml index b65def9..d8d5bc8 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,7 @@ 21 0.8.15 2.2.224 + 2.0.5 @@ -81,7 +82,11 @@ com.kingsrook.qqq qqq-backend-module-rdbms - test + + + com.kingsrook.qbits + qbit-object-storage + 0.1.0-RC.2 com.kingsrook.qqq @@ -99,6 +104,30 @@ ${h2.version} test + + org.postgresql + postgresql + 42.7.14 + test + + + org.testcontainers + testcontainers-postgresql + ${testcontainers.version} + test + + + org.testcontainers + testcontainers-minio + ${testcontainers.version} + test + + + org.testcontainers + testcontainers-junit-jupiter + ${testcontainers.version} + test + org.junit.jupiter junit-jupiter diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/WormAuditQBitConfig.java b/src/main/java/com/kingsrook/qbits/wormaudit/WormAuditQBitConfig.java index 754fcc6..8f07e01 100644 --- a/src/main/java/com/kingsrook/qbits/wormaudit/WormAuditQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/wormaudit/WormAuditQBitConfig.java @@ -21,25 +21,374 @@ package com.kingsrook.qbits.wormaudit; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.Map; +import java.util.function.Supplier; import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; /******************************************************************************* - ** Configuration for the WORM audit qbit. + ** Host configuration. Private signing material never enters metadata records. *******************************************************************************/ public class WormAuditQBitConfig implements QBitConfig { public static final int DEFAULT_RETENTION_YEARS = 6; - private MetaDataCustomizerInterface tableMetaDataCustomizer; - private int retentionYears = DEFAULT_RETENTION_YEARS; + /******************************************************************************* + ** Signing identity; the matching public key must be pinned in trustedPublicKeys. + *******************************************************************************/ + public record SigningKey(String keyId, PrivateKey privateKey) + { + } + private String backendName = null; + private int retentionYears = 6; + private Supplier signingKey = null; + private Map trustedPublicKeys = Map.of(); + private int chainIntervalSeconds = 30; + private int settleHorizonSeconds = 60; + private int batchSize = 1000; + private int lateDetailLookbackHours = 24; + private int checkpointEveryLinks = 10000; + private int checkpointEveryMinutes = 60; + private String verifyCron = "0 0 3 * * ?"; + private String exportBucketAlias = "worm_audit"; + private String exportPrefix = "worm-audit"; + private String exportCron = "0 0 2 * * ?"; + private String schedulerName = "worm_audit"; + private Map coreAuditTableNames = Map.of(); + private String appRole = "app"; + private MetaDataCustomizerInterface tableMetaDataCustomizer = null; /******************************************************************************* - ** Getter for the optional table customizer. + ** Read backendName. + *******************************************************************************/ + public String getBackendName() + { + return (backendName); + } + + + /******************************************************************************* + ** Configure backendName. + *******************************************************************************/ + public WormAuditQBitConfig withBackendName(String backendName) + { + this.backendName = backendName; + return (this); + } + + + /******************************************************************************* + ** Read retentionYears. + *******************************************************************************/ + public int getRetentionYears() + { + return (retentionYears); + } + + + /******************************************************************************* + ** Configure retentionYears. + *******************************************************************************/ + public WormAuditQBitConfig withRetentionYears(int retentionYears) + { + this.retentionYears = retentionYears; + return (this); + } + + + /******************************************************************************* + ** Read signingKey. + *******************************************************************************/ + public Supplier getSigningKey() + { + return (signingKey); + } + + + /******************************************************************************* + ** Configure signingKey. + *******************************************************************************/ + public WormAuditQBitConfig withSigningKey(Supplier signingKey) + { + this.signingKey = signingKey; + return (this); + } + + + /******************************************************************************* + ** Read trustedPublicKeys. + *******************************************************************************/ + public Map getTrustedPublicKeys() + { + return (trustedPublicKeys); + } + + + /******************************************************************************* + ** Configure trustedPublicKeys. + *******************************************************************************/ + public WormAuditQBitConfig withTrustedPublicKeys(Map trustedPublicKeys) + { + this.trustedPublicKeys = trustedPublicKeys; + return (this); + } + + + /******************************************************************************* + ** Read chainIntervalSeconds. + *******************************************************************************/ + public int getChainIntervalSeconds() + { + return (chainIntervalSeconds); + } + + + /******************************************************************************* + ** Configure chainIntervalSeconds. + *******************************************************************************/ + public WormAuditQBitConfig withChainIntervalSeconds(int chainIntervalSeconds) + { + this.chainIntervalSeconds = chainIntervalSeconds; + return (this); + } + + + /******************************************************************************* + ** Read settleHorizonSeconds. + *******************************************************************************/ + public int getSettleHorizonSeconds() + { + return (settleHorizonSeconds); + } + + + /******************************************************************************* + ** Configure settleHorizonSeconds. + *******************************************************************************/ + public WormAuditQBitConfig withSettleHorizonSeconds(int settleHorizonSeconds) + { + this.settleHorizonSeconds = settleHorizonSeconds; + return (this); + } + + + /******************************************************************************* + ** Read the bounded late-detail polling window in hours. + *******************************************************************************/ + public int getLateDetailLookbackHours() + { + return (lateDetailLookbackHours); + } + + + /******************************************************************************* + ** Configure late-detail polling; older omissions are reported by verification. + *******************************************************************************/ + public WormAuditQBitConfig withLateDetailLookbackHours(int lateDetailLookbackHours) + { + this.lateDetailLookbackHours = lateDetailLookbackHours; + return (this); + } + + + /******************************************************************************* + ** Read batchSize. + *******************************************************************************/ + public int getBatchSize() + { + return (batchSize); + } + + + /******************************************************************************* + ** Configure batchSize. + *******************************************************************************/ + public WormAuditQBitConfig withBatchSize(int batchSize) + { + this.batchSize = batchSize; + return (this); + } + + + /******************************************************************************* + ** Read checkpointEveryLinks. + *******************************************************************************/ + public int getCheckpointEveryLinks() + { + return (checkpointEveryLinks); + } + + + /******************************************************************************* + ** Configure checkpointEveryLinks. + *******************************************************************************/ + public WormAuditQBitConfig withCheckpointEveryLinks(int checkpointEveryLinks) + { + this.checkpointEveryLinks = checkpointEveryLinks; + return (this); + } + + + /******************************************************************************* + ** Read checkpointEveryMinutes. + *******************************************************************************/ + public int getCheckpointEveryMinutes() + { + return (checkpointEveryMinutes); + } + + + /******************************************************************************* + ** Configure checkpointEveryMinutes. + *******************************************************************************/ + public WormAuditQBitConfig withCheckpointEveryMinutes(int checkpointEveryMinutes) + { + this.checkpointEveryMinutes = checkpointEveryMinutes; + return (this); + } + + + /******************************************************************************* + ** Read verifyCron. + *******************************************************************************/ + public String getVerifyCron() + { + return (verifyCron); + } + + + /******************************************************************************* + ** Read the Object Lock bucket alias. + *******************************************************************************/ + public String getExportBucketAlias() + { + return (exportBucketAlias); + } + + + /******************************************************************************* + ** Configure the Object Lock bucket alias. + *******************************************************************************/ + public WormAuditQBitConfig withExportBucketAlias(String exportBucketAlias) + { + this.exportBucketAlias = exportBucketAlias; + return (this); + } + + + /******************************************************************************* + ** Read the export key prefix. + *******************************************************************************/ + public String getExportPrefix() + { + return (exportPrefix); + } + + + /******************************************************************************* + ** Configure the export key prefix. + *******************************************************************************/ + public WormAuditQBitConfig withExportPrefix(String exportPrefix) + { + this.exportPrefix = exportPrefix; + return (this); + } + + + /******************************************************************************* + ** Read the UTC export schedule. + *******************************************************************************/ + public String getExportCron() + { + return (exportCron); + } + + + /******************************************************************************* + ** Configure the UTC export schedule. + *******************************************************************************/ + public WormAuditQBitConfig withExportCron(String exportCron) + { + this.exportCron = exportCron; + return (this); + } + + + /******************************************************************************* + ** Configure verifyCron. + *******************************************************************************/ + public WormAuditQBitConfig withVerifyCron(String verifyCron) + { + this.verifyCron = verifyCron; + return (this); + } + + + /******************************************************************************* + ** Read schedulerName. + *******************************************************************************/ + public String getSchedulerName() + { + return (schedulerName); + } + + + /******************************************************************************* + ** Configure schedulerName. + *******************************************************************************/ + public WormAuditQBitConfig withSchedulerName(String schedulerName) + { + this.schedulerName = schedulerName; + return (this); + } + + + /******************************************************************************* + ** Read coreAuditTableNames. + *******************************************************************************/ + public Map getCoreAuditTableNames() + { + return (coreAuditTableNames); + } + + + /******************************************************************************* + ** Configure coreAuditTableNames. + *******************************************************************************/ + public WormAuditQBitConfig withCoreAuditTableNames(Map coreAuditTableNames) + { + this.coreAuditTableNames = coreAuditTableNames; + return (this); + } + + + /******************************************************************************* + ** Read appRole. + *******************************************************************************/ + public String getAppRole() + { + return (appRole); + } + + + /******************************************************************************* + ** Configure appRole. + *******************************************************************************/ + public WormAuditQBitConfig withAppRole(String appRole) + { + this.appRole = appRole; + return (this); + } + + + /******************************************************************************* + ** Read tableMetaDataCustomizer. *******************************************************************************/ @Override public MetaDataCustomizerInterface getTableMetaDataCustomizer() @@ -48,9 +397,8 @@ public class WormAuditQBitConfig implements QBitConfig } - /******************************************************************************* - ** Fluent setter for the optional table customizer. + ** Configure tableMetaDataCustomizer. *******************************************************************************/ public WormAuditQBitConfig withTableMetaDataCustomizer(MetaDataCustomizerInterface tableMetaDataCustomizer) { @@ -59,23 +407,20 @@ public class WormAuditQBitConfig implements QBitConfig } - /******************************************************************************* - ** Getter for the audit retention in years (HIPAA default: 6). + ** Reject invalid runtime limits before producing metadata. *******************************************************************************/ - public int getRetentionYears() + public void validate() { - return (retentionYears); - } - - - - /******************************************************************************* - ** Fluent setter for the audit retention in years. - *******************************************************************************/ - public WormAuditQBitConfig withRetentionYears(int retentionYears) - { - this.retentionYears = retentionYears; - return (this); + if(exportBucketAlias == null || exportBucketAlias.isBlank() || exportCron == null || exportCron.isBlank() + || exportPrefix == null || !exportPrefix.matches("[A-Za-z0-9_-]+(/[A-Za-z0-9_-]+)*")) + { + throw (new IllegalArgumentException("Export bucket, prefix and schedule must be configured")); + } + if(backendName == null || backendName.isBlank() || retentionYears < 1 || chainIntervalSeconds < 1 + || lateDetailLookbackHours < 1 || settleHorizonSeconds < 0 || batchSize < 1 || checkpointEveryLinks < 1 || checkpointEveryMinutes < 1) + { + throw (new IllegalArgumentException("backendName is required and WORM audit limits must be positive (settle horizon may be zero)")); + } } } diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/WormAuditQBitProducer.java b/src/main/java/com/kingsrook/qbits/wormaudit/WormAuditQBitProducer.java index 15b5543..e4f6a14 100644 --- a/src/main/java/com/kingsrook/qbits/wormaudit/WormAuditQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/wormaudit/WormAuditQBitProducer.java @@ -21,14 +21,29 @@ package com.kingsrook.qbits.wormaudit; +import com.kingsrook.qbits.wormaudit.ddl.WormAuditDdl; +import com.kingsrook.qbits.wormaudit.guard.AppendOnlyGuard; +import com.kingsrook.qbits.wormaudit.model.LinkRangeWidget; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qbits.wormaudit.processes.ExportProcesses; +import com.kingsrook.qbits.wormaudit.processes.WormAuditProcesses; +import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer; +import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.quartz.QuartzSchedulerMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; /******************************************************************************* - ** Entry point for the WORM audit qbit; no tables are produced yet. + ** Produces append-only audit metadata, verification actions and schedules. *******************************************************************************/ public class WormAuditQBitProducer implements QBitMetaDataProducer { @@ -108,13 +123,97 @@ public class WormAuditQBitProducer implements QBitMetaDataProducer t.getName().equals(name)).findFirst().orElseThrow(); + app.withSectionOfChildren(new QAppSection().withName(name).withLabel(table.getLabel()), table); + } + output.add(app); + // RC.5 rejects underscores in declared alert type names. QAlerts still + // dispatches the specified worm_audit.chain.break signal to installed sinks. + output.addSelfToInstance(qInstance); + denyAssistant(qInstance); + return (new MetaDataProducerMultiOutput()); + } + + + /******************************************************************************* + ** RC.5 compatibility: replace with a direct call when the qbit builds on RC.6 (qqq#1141, #1163). + *******************************************************************************/ + private void denyAssistant(QInstance instance) throws QException + { + try + { + Class type = Class.forName("com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData"); + Object metadata = type.getMethod("ofOrWithNew", QInstance.class).invoke(null, instance); + type.getMethod("withDeniedPattern", String.class, String.class).invoke(metadata, "assistant", "worm_audit_*"); + } + catch(ClassNotFoundException expected) + { + // The assistant wall API is absent in RC.5. + } + catch(ReflectiveOperationException e) + { + throw (new QException("Cannot register the WORM audit assistant deny pattern", e)); + } } } diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/canonical/AuditCanonical.java b/src/main/java/com/kingsrook/qbits/wormaudit/canonical/AuditCanonical.java new file mode 100644 index 0000000..e734d1c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/canonical/AuditCanonical.java @@ -0,0 +1,74 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.canonical; + + +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** v1 audit content, exactly as stored. Subsets use the same document schema for + ** LATE_DETAIL links; no canonical content is persisted by this qbit. + *******************************************************************************/ +public final class AuditCanonical +{ + private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'").withZone(ZoneOffset.UTC); + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AuditCanonical() + { + } + + + /******************************************************************************* + ** Resolve names outside this function, without looking up application data. + *******************************************************************************/ + public static byte[] bytes(QRecord audit, String table, String user, List details) + { + Map header = new LinkedHashMap<>(); + header.put("clientChannel", audit.getValueString("clientChannel")); + header.put("id", audit.getValueLong("id")); + header.put("message", audit.getValueString("message")); + header.put("recordId", audit.getValueString("recordId")); + header.put("table", table); + header.put("timestamp", TIMESTAMP.format(audit.getValueInstant("timestamp"))); + header.put("user", user); + List> rows = details.stream().sorted(Comparator.comparing(r -> r.getValueLong("id"))).map(detail -> + { + Map row = new LinkedHashMap<>(); + row.put("id", detail.getValueLong("id")); + for(String field : List.of("fieldName", "message", "newValue", "oldValue")) + { + row.put(field, detail.getValueString(field)); + } + return (row); + }).toList(); + return (Jcs.bytes(Map.of("audit", header, "details", rows, "v", 1))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/canonical/Jcs.java b/src/main/java/com/kingsrook/qbits/wormaudit/canonical/Jcs.java new file mode 100644 index 0000000..d145c80 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/canonical/Jcs.java @@ -0,0 +1,130 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.canonical; + + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + + +/******************************************************************************* + ** RFC 8785 serialization of the audit schema: strings, integer longs, null, + ** objects and arrays. Java String ordering is UTF-16 code-unit ordering. Reject + ** malformed surrogate pairs instead of silently replacing them during UTF-8 encoding. + *******************************************************************************/ +public final class Jcs +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private Jcs() + { + } + + + /******************************************************************************* + ** Encode a document with no insignificant whitespace. + *******************************************************************************/ + public static byte[] bytes(Object value) + { + return (string(value).getBytes(StandardCharsets.UTF_8)); + } + + + /******************************************************************************* + ** Serialize only the value types supported by the v1 schema. + *******************************************************************************/ + public static String string(Object value) + { + if(value == null) + { + return ("null"); + } + if(value instanceof String text) + { + return (quote(text)); + } + if(value instanceof Long || value instanceof Integer) + { + return (value.toString()); + } + if(value instanceof List list) + { + return ("[" + String.join(",", list.stream().map(Jcs::string).toList()) + "]"); + } + if(value instanceof Map map) + { + TreeMap sorted = new TreeMap<>(); + map.forEach((key, item) -> sorted.put((String) key, item)); + return ("{" + String.join(",", sorted.entrySet().stream().map(e -> quote(e.getKey()) + ":" + string(e.getValue())).toList()) + "}"); + } + throw (new IllegalArgumentException("Unsupported canonical value type")); + } + + + /******************************************************************************* + ** JSON escaping mandated by JCS, including lowercase control escapes. + *******************************************************************************/ + private static String quote(String text) + { + StringBuilder out = new StringBuilder("\""); + for(int i = 0; i < text.length(); i++) + { + char c = text.charAt(i); + switch(c) + { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\b' -> out.append("\\b"); + case '\f' -> out.append("\\f"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + default -> + { + if(c < 32) + { + out.append(String.format(java.util.Locale.ROOT, "\\u%04x", (int) c)); + } + else if(Character.isHighSurrogate(c)) + { + if(i + 1 >= text.length() || !Character.isLowSurrogate(text.charAt(i + 1))) + { + throw (new IllegalArgumentException("Unpaired surrogate in canonical string")); + } + out.append(c).append(text.charAt(++i)); + } + else if(Character.isLowSurrogate(c)) + { + throw (new IllegalArgumentException("Unpaired surrogate in canonical string")); + } + else + { + out.append(c); + } + } + } + } + return (out.append('"').toString()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/chain/AuditStore.java b/src/main/java/com/kingsrook/qbits/wormaudit/chain/AuditStore.java new file mode 100644 index 0000000..28d72d6 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/chain/AuditStore.java @@ -0,0 +1,479 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.chain; + + +import java.io.Serializable; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import com.kingsrook.qbits.wormaudit.canonical.AuditCanonical; +import com.kingsrook.qbits.wormaudit.ddl.WormAuditDdl; +import com.kingsrook.qbits.wormaudit.guard.AppendOnlyGuard; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qbits.wormaudit.verify.VerificationResult; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSTransaction; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails; + + +/******************************************************************************* + ** QQQ storage shared by chainer, verifier and future export code. All reads use + ** the caller's transaction; only insert operations exist here. Bypass applies + ** to internal catalog work after process permission checks, never user requests. + *******************************************************************************/ +public class AuditStore +{ + private final QBackendTransaction transaction; + private final Map names = new LinkedHashMap<>(16, 0.75F, true); + private Boolean timestampWithZone; + + + /******************************************************************************* + ** Bind operations to one snapshot/transaction, or the memory backend. + *******************************************************************************/ + public AuditStore(QBackendTransaction transaction) + { + this.transaction = transaction; + } + + + /******************************************************************************* + ** Read in deterministic id order, explicitly fetching TEXT fields. + *******************************************************************************/ + public List query(String table, QQueryFilter filter) throws QException + { + QueryInput input = new QueryInput(table).withFilter(filter).withTransaction(transaction).withShouldFetchHeavyFields(true); + input.withPermissionBypass("WORM audit internal read"); + return (new QueryAction().execute(input).getRecords()); + } + + + /******************************************************************************* + ** All records ordered by id. + *******************************************************************************/ + public List all(String table) throws QException + { + return (query(table, new QQueryFilter().withOrderBy(new QFilterOrderBy("id", true)))); + } + + + /******************************************************************************* + ** An equality query. + *******************************************************************************/ + public List where(String table, String field, Serializable value) throws QException + { + // Core lookup keys are INTEGER while audit and chain keys are LONG. + // Memory equality is type-sensitive; normalize to the declared field type. + if(value instanceof Number number && QContext.getQInstance().getTable(table).getField(field).getType() + == com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.INTEGER) + { + value = Math.toIntExact(number.longValue()); + } + return (query(table, new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)).withOrderBy(new QFilterOrderBy("id", true)))); + } + + + /******************************************************************************* + ** Return a row or null if a tamper removed it. + *******************************************************************************/ + public QRecord get(String table, long id) throws QException + { + return (where(table, "id", id).stream().findFirst().orElse(null)); + } + + + /******************************************************************************* + ** Insert a batch, propagating failures so a transaction can roll back. + *******************************************************************************/ + public List insert(String table, List rows) throws QException + { + if(rows.isEmpty()) + { + return (rows); + } + try + { + if(WormAuditTables.ALL.contains(table) && QContext.getQInstance().getBackendForTable(table) + instanceof com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData) + { + // RC.5 binds Instant as a UTC LocalDateTime, which shifts timestamptz + // in non-UTC sessions. Its Timestamp path preserves the actual instant. + rows.forEach(row -> row.getValues().replaceAll((field, value) -> value instanceof Instant instant ? Timestamp.from(instant) : value)); + } + InsertInput input = new InsertInput(table).withRecords(rows).withTransaction(transaction).withOmitDmlAudit(true); + input.withPermissionBypass("WORM audit internal append"); + List inserted = new InsertAction().execute(input).getRecords(); + for(QRecord row : inserted) + { + if(row.getErrors() != null && !row.getErrors().isEmpty()) + { + // Never echo a rejected record value into logs or alerts. + throw (new QException("WORM audit append failed for " + table)); + } + } + return (inserted); + } + catch(Exception e) + { + throw (AppendOnlyGuard.map(e)); + } + } + + + /******************************************************************************* + ** Parse explicit coverage; duplicates are retained so verification sees them. + *******************************************************************************/ + public static List detailIds(QRecord link) + { + String ids = link.getValueString("detail_ids"); + return (ids == null || ids.isEmpty() ? List.of() : java.util.Arrays.stream(ids.split(",")).map(Long::valueOf).toList()); + } + + + /******************************************************************************* + ** Resolve a lookup name without touching application tables. + *******************************************************************************/ + public String name(String table, Long id) throws QException + { + if(id == null) + { + return (null); + } + String key = table + ":" + id; + if(names.containsKey(key)) + { + return (names.get(key)); + } + QRecord row = get(table, id); + if(row == null) + { + throw (new QException("Missing " + table + " " + id)); + } + if(names.size() >= 2000) + { + names.remove(names.keySet().iterator().next()); + } + names.put(key, row.getValueString("name")); + return (row.getValueString("name")); + } + + + /******************************************************************************* + ** Canonical bytes for the exact detail ids recorded on a link. + *******************************************************************************/ + public byte[] canonical(QRecord audit, List ids) throws QException + { + return (canonical(audit, ids, index(in("auditDetail", "id", ids)))); + } + + + /******************************************************************************* + ** Settled unchained headers. RDBMS uses an anti-join under the writer lock; + ** memory uses QQQ queries and the same ordering and cutoff semantics. + *******************************************************************************/ + public List unchained(Instant cutoff, int limit) throws QException + { + if(isRdbms()) + { + String sql = "SELECT a." + column("audit", "id") + " FROM " + physical("audit") + " a WHERE a." + column("audit", "timestamp") + + " < ? AND NOT EXISTS (SELECT 1 FROM worm_audit_chain c WHERE c.audit_id=a." + column("audit", "id") + + " AND c.kind='AUDIT') ORDER BY a." + column("audit", "timestamp") + ", a." + column("audit", "id") + " LIMIT " + limit; + Map rows = index(in("audit", "id", sqlIds(sql, cutoff))); + return (rows.values().stream().sorted(Comparator.comparing((QRecord r) -> r.getValueInstant("timestamp")) + .thenComparing(r -> r.getValueLong("id"))).toList()); + } + var covered = all(WormAuditTables.CHAIN).stream().filter(r -> "AUDIT".equals(r.getValueString("kind"))).map(r -> r.getValueLong("audit_id")).collect(java.util.stream.Collectors.toSet()); + return (all("audit").stream().filter(r -> r.getValueInstant("timestamp").isBefore(cutoff) && !covered.contains(r.getValueLong("id"))) + .sorted(Comparator.comparing((QRecord r) -> r.getValueInstant("timestamp")).thenComparing(r -> r.getValueLong("id"))).limit(limit).toList()); + } + + + /******************************************************************************* + ** Latest successful retention anchor, or genesis. + *******************************************************************************/ + public QRecord anchor() throws QException + { + List rows = query(WormAuditTables.PURGE, new QQueryFilter(new QFilterCriteria("outcome", QCriteriaOperator.EQUALS, "PURGED")) + .withOrderBy(new QFilterOrderBy("id", false)).withLimit(1)); + return (rows.isEmpty() ? new QRecord().withValue("anchor_link_id", 0L).withValue("anchor_hash", Hashes.GENESIS) : rows.getFirst()); + } + + + /******************************************************************************* + ** Whether SQL anti-joins are available in this snapshot. + *******************************************************************************/ + public boolean isRdbms() + { + return (transaction instanceof RDBMSTransaction); + } + + + /******************************************************************************* + ** Fetch one keyset page, never using an offset or a whole-chain read. + *******************************************************************************/ + public List page(String table, long after, int limit) throws QException + { + return (query(table, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, after)) + .withOrderBy(new QFilterOrderBy("id", true)).withLimit(limit))); + } + + + /******************************************************************************* + ** Fetch just the tail row. + *******************************************************************************/ + public QRecord last(String table) throws QException + { + List rows = query(table, new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false)).withLimit(1)); + return (rows.isEmpty() ? null : rows.getFirst()); + } + + + /******************************************************************************* + ** One batched IN query, with memory-backend integer key normalization. + *******************************************************************************/ + public List in(String table, String field, Collection ids) throws QException + { + if(ids.isEmpty()) + { + return (List.of()); + } + boolean integer = QContext.getQInstance().getTable(table).getField(field).getType() + == com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.INTEGER; + List values = ids.stream().distinct().map(id -> integer ? (Serializable) Math.toIntExact(id) : id).toList(); + return (query(table, new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.IN, values)).withOrderBy(new QFilterOrderBy("id", true)))); + } + + + /******************************************************************************* + ** Index one bounded batch by primary key. + *******************************************************************************/ + public static Map index(List rows) + { + return (rows.stream().collect(Collectors.toMap(r -> r.getValueLong("id"), r -> r))); + } + + + /******************************************************************************* + ** Canonicalize already fetched content; a missing detail never triggers a query. + *******************************************************************************/ + public byte[] canonical(QRecord audit, List ids, Map details) throws QException + { + List rows = new ArrayList<>(); + for(long id : ids) + { + QRecord detail = details.get(id); + if(detail == null || !audit.getValueLong("id").equals(detail.getValueLong("auditId"))) + { + throw (new QException("audit_detail " + id + " is missing or belongs to another audit")); + } + rows.add(detail); + } + return (AuditCanonical.bytes(audit, name("auditTable", audit.getValueLong("auditTableId")), name("auditUser", audit.getValueLong("auditUserId")), rows)); + } + + + /******************************************************************************* + ** Poll uncovered details in a bounded time window; memory retains its simple fallback. + *******************************************************************************/ + public List lateDetails(Instant cutoff, int hours, int limit) throws QException + { + if(isRdbms()) + { + String sql = "SELECT d." + column("auditDetail", "id") + " FROM " + physical("auditDetail") + " d JOIN " + physical("audit") + + " a ON a." + column("audit", "id") + "=d." + column("auditDetail", "auditId") + + " WHERE a." + column("audit", "timestamp") + " < ? AND a." + column("audit", "timestamp") + " >= ?" + + " AND EXISTS (SELECT 1 FROM worm_audit_chain h WHERE h.kind='AUDIT' AND h.audit_id=a." + column("audit", "id") + ")" + + " AND NOT EXISTS (SELECT 1 FROM worm_audit_chain c WHERE " + containsDetail() + ") ORDER BY d." + column("auditDetail", "id") + " LIMIT " + limit; + return (in("auditDetail", "id", sqlIds(sql, cutoff, cutoff.minusSeconds(hours * 3600L)))); + } + List links = all(WormAuditTables.CHAIN); + Set headers = links.stream().filter(r -> "AUDIT".equals(r.getValueString("kind"))).map(r -> r.getValueLong("audit_id")).collect(Collectors.toSet()); + Set covered = new HashSet<>(); + links.forEach(link -> covered.addAll(detailIds(link))); + Set settled = all("audit").stream().filter(a -> headers.contains(a.getValueLong("id")) && a.getValueInstant("timestamp").isBefore(cutoff)) + .map(a -> a.getValueLong("id")).collect(Collectors.toSet()); + return (all("auditDetail").stream().filter(d -> settled.contains(d.getValueLong("auditId")) && !covered.contains(d.getValueLong("id"))).toList()); + } + + + /******************************************************************************* + ** SQL completeness counts all offenders but returns only the first 100 of each type. + *******************************************************************************/ + public void completeness(Instant cutoff, VerificationResult result) throws QException + { + String auditId = "a." + column("audit", "id"); + String time = "a." + column("audit", "timestamp"); + String headers = "SELECT " + auditId + " AS id, (SELECT count(*) FROM worm_audit_chain c WHERE c.kind='AUDIT' AND c.audit_id=" + auditId + + ") AS coverage FROM " + physical("audit") + " a WHERE " + time + " < ? OR " + time + " IS NULL"; + String details = "SELECT d." + column("auditDetail", "id") + " AS id, (SELECT count(*) FROM worm_audit_chain c WHERE " + containsDetail() + + ") AS coverage FROM " + physical("auditDetail") + " d LEFT JOIN " + physical("audit") + " a ON " + auditId + "=d." + + column("auditDetail", "auditId") + " WHERE " + time + " < ? OR " + time + " IS NULL"; + reportCoverage(headers, "audit", cutoff, result); + reportCoverage(details, "audit_detail", cutoff, result); + } + + + /******************************************************************************* + ** Keep exact offender ids and total counts without materializing the result set. + *******************************************************************************/ + private void reportCoverage(String source, String table, Instant cutoff, VerificationResult result) throws QException + { + String sql = "SELECT id, coverage, count(*) OVER() AS total FROM (" + source + ") offenders WHERE coverage <> 1 ORDER BY id LIMIT 100"; + try(PreparedStatement statement = ((RDBMSTransaction) transaction).getConnection().prepareStatement(sql)) + { + bindCutoff(statement, 1, cutoff); + long total = 0; + int reported = 0; + try(ResultSet rows = statement.executeQuery()) + { + while(rows.next()) + { + long id = rows.getLong(1); + total = rows.getLong(3); + result.add(null, table.equals("audit") ? id : null, table + " " + com.kingsrook.qbits.wormaudit.verify.WormAuditVerifier.number(id) + + (rows.getLong(2) == 0 ? " is not covered by the chain" : " is covered twice or more by the chain")); + reported++; + } + } + result.omitted(total - reported); + if(total > reported) + { + result.warn(table + ": " + total + " coverage offenders; first 100 ids reported"); + } + } + catch(Exception e) + { + throw (AppendOnlyGuard.map(e)); + } + } + + + /******************************************************************************* + ** PostgreSQL's containment expression matches the GIN expression index. + *******************************************************************************/ + private String containsDetail() throws QException + { + try + { + String id = "d." + column("auditDetail", "id"); + if("PostgreSQL".equals(((RDBMSTransaction) transaction).getConnection().getMetaData().getDatabaseProductName())) + { + return ("string_to_array(c.detail_ids, ',') @> ARRAY[" + id + "::text]"); + } + return ("POSITION(',' || CAST(" + id + " AS varchar) || ',' IN ',' || c.detail_ids || ',') > 0"); + } + catch(Exception e) + { + throw (AppendOnlyGuard.map(e)); + } + } + + + /******************************************************************************* + ** Read ids from an anti-join, binding all instants according to column type. + *******************************************************************************/ + private List sqlIds(String sql, Instant... cutoffs) throws QException + { + try(PreparedStatement statement = ((RDBMSTransaction) transaction).getConnection().prepareStatement(sql)) + { + for(int i = 0; i < cutoffs.length; i++) + { + bindCutoff(statement, i + 1, cutoffs[i]); + } + List ids = new ArrayList<>(); + try(ResultSet rows = statement.executeQuery()) + { + while(rows.next()) + { + ids.add(rows.getLong(1)); + } + } + return (ids); + } + catch(Exception e) + { + throw (AppendOnlyGuard.map(e)); + } + } + + + /******************************************************************************* + ** UTC local timestamps for without-tz columns; offset timestamps for timestamptz. + *******************************************************************************/ + private void bindCutoff(PreparedStatement statement, int index, Instant cutoff) throws Exception + { + if(timestampWithZone == null) + { + try(var probe = ((RDBMSTransaction) transaction).getConnection().createStatement(); + var rows = probe.executeQuery("SELECT " + column("audit", "timestamp") + " FROM " + physical("audit") + " WHERE 1=0")) + { + var metadata = rows.getMetaData(); + timestampWithZone = metadata.getColumnType(1) == Types.TIMESTAMP_WITH_TIMEZONE + || "timestamptz".equalsIgnoreCase(metadata.getColumnTypeName(1)); + } + } + statement.setObject(index, timestampWithZone ? cutoff.atOffset(ZoneOffset.UTC) : LocalDateTime.ofInstant(cutoff, ZoneOffset.UTC)); + } + + + /******************************************************************************* + ** Resolve and quote physical core table names. + *******************************************************************************/ + private String physical(String table) + { + return (WormAuditDdl.identifier(((RDBMSTableBackendDetails) QContext.getQInstance().getTable(table).getBackendDetails()).getTableName())); + } + + + /******************************************************************************* + ** Resolve and quote physical field names for runtime queries. + *******************************************************************************/ + private String column(String table, String field) + { + String name = QContext.getQInstance().getTable(table).getField(field).getBackendName(); + return (WormAuditDdl.identifier(name == null ? field : name)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/chain/Hashes.java b/src/main/java/com/kingsrook/qbits/wormaudit/chain/Hashes.java new file mode 100644 index 0000000..3d62960 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/chain/Hashes.java @@ -0,0 +1,99 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.chain; + + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; + + +/******************************************************************************* + ** SHA-256 primitives. Link predecessors are the 64 lowercase ASCII hex bytes; + ** Merkle leaves are the decoded 32-byte link hashes, with odd nodes promoted. + *******************************************************************************/ +public final class Hashes +{ + public static final String GENESIS = "0".repeat(64); + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private Hashes() + { + } + + + /******************************************************************************* + ** SHA-256 digest of concatenated byte arrays. + *******************************************************************************/ + public static byte[] digest(byte[]... parts) + { + try + { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for(byte[] part : parts) + { + digest.update(part); + } + return (digest.digest()); + } + catch(NoSuchAlgorithmException e) + { + throw (new IllegalStateException("JDK lacks SHA-256", e)); + } + } + + + /******************************************************************************* + ** Link hash over predecessor, NUL delimiter and canonical bytes. + *******************************************************************************/ + public static String link(String previous, byte[] canonical) + { + return (HexFormat.of().formatHex(digest(previous.getBytes(StandardCharsets.US_ASCII), new byte[] { 0 }, canonical))); + } + + + /******************************************************************************* + ** Binary Merkle root; an empty range cannot be checkpointed. + *******************************************************************************/ + public static String merkle(List hashes) + { + if(hashes.isEmpty()) + { + throw (new IllegalArgumentException("A Merkle range must contain links")); + } + List level = hashes.stream().map(HexFormat.of()::parseHex).toList(); + while(level.size() > 1) + { + List next = new ArrayList<>(); + for(int i = 0; i < level.size(); i += 2) + { + next.add(i + 1 == level.size() ? level.get(i) : digest(level.get(i), level.get(i + 1))); + } + level = next; + } + return (HexFormat.of().formatHex(level.getFirst())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/chain/WormAuditChainer.java b/src/main/java/com/kingsrook/qbits/wormaudit/chain/WormAuditChainer.java new file mode 100644 index 0000000..94b1760 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/chain/WormAuditChainer.java @@ -0,0 +1,224 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.chain; + + +import java.sql.ResultSet; +import java.sql.Statement; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; +import com.kingsrook.qbits.wormaudit.WormAuditQBitConfig; +import com.kingsrook.qbits.wormaudit.checkpoint.Checkpoints; +import com.kingsrook.qbits.wormaudit.guard.AppendOnlyGuard; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSTransaction; + + +/******************************************************************************* + ** Single asynchronous writer. PostgreSQL uses a transaction-scoped advisory + ** lock shared with the retention function. Other backends use a JVM-wide lock: + ** those deployments must run their chainer in one JVM only. + *******************************************************************************/ +public class WormAuditChainer +{ + public static final long ADVISORY_LOCK = 86753090421L; + public static final ReentrantLock JVM_LOCK = new ReentrantLock(); + private final WormAuditQBitConfig config; + + /******************************************************************************* + ** Counts describe committed work; a contended writer tick is skipped. + *******************************************************************************/ + public record Counts(int audits, int lateDetails, int links, int checkpoints, boolean skipped) + { + } + + /******************************************************************************* + ** Bind host configuration. + *******************************************************************************/ + public WormAuditChainer(WormAuditQBitConfig config) + { + this.config = config; + } + + + /******************************************************************************* + ** Run a writer tick under one transaction; link ids cannot gap on rollback. + *******************************************************************************/ + public Counts chainOnce() throws QException + { + return (chainOnce(Instant.now())); + } + + + /******************************************************************************* + ** Clock-injected tick for deterministic settling and checkpoint tests. + *******************************************************************************/ + public Counts chainOnce(Instant now) throws QException + { + config.validate(); + JVM_LOCK.lock(); + try(QBackendTransaction transaction = QBackendTransaction.openFor(new QueryInput(WormAuditTables.CHAIN))) + { + try + { + if(transaction instanceof RDBMSTransaction rdbms && "PostgreSQL".equals(rdbms.getConnection().getMetaData().getDatabaseProductName())) + { + try(Statement statement = rdbms.getConnection().createStatement(); ResultSet result = statement.executeQuery("SELECT pg_try_advisory_xact_lock(" + ADVISORY_LOCK + ")")) + { + result.next(); + if(!result.getBoolean(1)) + { + return (new Counts(0, 0, 0, 0, true)); + } + } + } + Counts counts = append(new AuditStore(transaction), now); + transaction.commit(); + return (counts); + } + catch(Exception e) + { + transaction.rollback(); + throw (AppendOnlyGuard.map(e)); + } + } + finally + { + JVM_LOCK.unlock(); + } + } + + + /******************************************************************************* + ** Append settled headers and uncovered detail chunks, then checkpoint. + *******************************************************************************/ + private Counts append(AuditStore store, Instant now) throws Exception + { + QRecord tail = store.last(WormAuditTables.CHAIN); + QRecord anchor = store.anchor(); + long nextId = tail == null ? anchor.getValueLong("anchor_link_id") + 1 : tail.getValueLong("id") + 1; + String previous = tail == null ? anchor.getValueString("anchor_hash") : tail.getValueString("link_hash"); + List appended = new ArrayList<>(); + Instant cutoff = now.minusSeconds(config.getSettleHorizonSeconds()); + List audits = store.unchained(cutoff, config.getBatchSize()); + Map details = AuditStore.index(store.in("auditDetail", "auditId", audits.stream().map(r -> r.getValueLong("id")).toList())); + Map> grouped = details.values().stream().collect(Collectors.groupingBy(r -> r.getValueLong("auditId"))); + int lateDetails = 0; + for(QRecord audit : audits) + { + List ids = grouped.getOrDefault(audit.getValueLong("id"), List.of()).stream().map(r -> r.getValueLong("id")).sorted().toList(); + for(int start = 0; start == 0 || start < ids.size(); start += 250) + { + List chunk = ids.subList(start, Math.min(start + 250, ids.size())); + QRecord link = link(store, nextId++, start == 0 ? "AUDIT" : "LATE_DETAIL", audit, chunk, details, previous, now); + appended.add(link); + previous = link.getValueString("link_hash"); + if(start > 0) + { + lateDetails += chunk.size(); + } + } + } + insertLinks(store, appended); + int linkCount = appended.size(); + appended.clear(); + details = AuditStore.index(store.lateDetails(cutoff, config.getLateDetailLookbackHours(), config.getBatchSize())); + grouped = details.values().stream().collect(Collectors.groupingBy(r -> r.getValueLong("auditId"), TreeMap::new, Collectors.toList())); + Map headers = AuditStore.index(store.in("audit", "id", grouped.keySet())); + for(var entry : grouped.entrySet()) + { + List ids = entry.getValue().stream().map(r -> r.getValueLong("id")).sorted().toList(); + for(int start = 0; start < ids.size(); start += 250) + { + List chunk = ids.subList(start, Math.min(start + 250, ids.size())); + QRecord link = link(store, nextId++, "LATE_DETAIL", headers.get(entry.getKey()), chunk, details, previous, now); + appended.add(link); + previous = link.getValueString("link_hash"); + lateDetails += chunk.size(); + } + } + insertLinks(store, appended); + return (new Counts(audits.size(), lateDetails, linkCount + appended.size(), checkpoint(store, now), false)); + } + + + /******************************************************************************* + ** Persist bounded insert batches inside the writer transaction. + *******************************************************************************/ + private void insertLinks(AuditStore store, List links) throws QException + { + for(int start = 0; start < links.size(); start += config.getBatchSize()) + { + store.insert(WormAuditTables.CHAIN, links.subList(start, Math.min(start + config.getBatchSize(), links.size()))); + } + } + + + /******************************************************************************* + ** Build one immutable link from live stored content. + *******************************************************************************/ + private QRecord link(AuditStore store, long id, String kind, QRecord audit, List details, Map rows, String previous, Instant now) throws QException + { + return (new QRecord().withValue("id", id).withValue("kind", kind).withValue("audit_id", audit.getValueLong("id")) + .withValue("detail_ids", details.stream().map(Object::toString).collect(Collectors.joining(","))) + .withValue("prev_hash", previous).withValue("link_hash", Hashes.link(previous, store.canonical(audit, details, rows))).withValue("chained_at", now)); + } + + + /******************************************************************************* + ** Checkpoint the uncheckpointed suffix by count or elapsed time. + *******************************************************************************/ + private int checkpoint(AuditStore store, Instant now) throws Exception + { + if(config.getSigningKey() == null) + { + return (0); + } + QRecord latest = store.last(WormAuditTables.CHECKPOINT); + long lastId = latest == null ? store.anchor().getValueLong("anchor_link_id") : latest.getValueLong("to_link_id"); + int count = 0; + while(true) + { + List pending = store.page(WormAuditTables.CHAIN, lastId, config.getCheckpointEveryLinks()); + if(pending.isEmpty()) + { + return (count); + } + Instant since = latest == null ? pending.getFirst().getValueInstant("chained_at") : latest.getValueInstant("created_at"); + if(pending.size() < config.getCheckpointEveryLinks() && now.isBefore(since.plusSeconds(config.getCheckpointEveryMinutes() * 60L))) + { + return (count); + } + latest = store.insert(WormAuditTables.CHECKPOINT, List.of(Checkpoints.sign(pending, config, now))).getFirst(); + lastId = latest.getValueLong("to_link_id"); + count++; + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/checkpoint/Checkpoints.java b/src/main/java/com/kingsrook/qbits/wormaudit/checkpoint/Checkpoints.java new file mode 100644 index 0000000..4d91fe8 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/checkpoint/Checkpoints.java @@ -0,0 +1,120 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.checkpoint; + + +import java.security.GeneralSecurityException; +import java.security.PublicKey; +import java.security.Signature; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.wormaudit.WormAuditQBitConfig; +import com.kingsrook.qbits.wormaudit.canonical.Jcs; +import com.kingsrook.qbits.wormaudit.chain.Hashes; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** Ed25519 checkpoint envelopes. Trust comes exclusively from host-pinned keys, + ** never the public_key supplied by an untrusted checkpoint row. + *******************************************************************************/ +public final class Checkpoints +{ + private static final List SIGNED_FIELDS = List.of("from_link_id", "to_link_id", "link_count", "head_hash", "merkle_root", "algorithm", "key_id"); + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private Checkpoints() + { + } + + + /******************************************************************************* + ** The exact signed payload; public_key and signature are envelope fields. + *******************************************************************************/ + public static byte[] payload(QRecord checkpoint) + { + Map fields = new LinkedHashMap<>(); + for(String field : SIGNED_FIELDS) + { + fields.put(field, field.endsWith("_id") && !field.equals("key_id") || field.equals("link_count") + ? checkpoint.getValueLong(field) : checkpoint.getValueString(field)); + } + return (Jcs.bytes(fields)); + } + + + /******************************************************************************* + ** Create a checkpoint only when the configured private key matches its pin. + *******************************************************************************/ + public static QRecord sign(List links, WormAuditQBitConfig config, Instant now) throws GeneralSecurityException + { + WormAuditQBitConfig.SigningKey key = config.getSigningKey().get(); + PublicKey publicKey = config.getTrustedPublicKeys().get(key.keyId()); + if(publicKey == null) + { + throw (new GeneralSecurityException("Signing key id is not pinned")); + } + QRecord record = new QRecord().withValue("from_link_id", links.getFirst().getValueLong("id")) + .withValue("to_link_id", links.getLast().getValueLong("id")).withValue("link_count", (long) links.size()) + .withValue("head_hash", links.getLast().getValueString("link_hash")) + .withValue("merkle_root", Hashes.merkle(links.stream().map(r -> r.getValueString("link_hash")).toList())) + .withValue("algorithm", "Ed25519").withValue("key_id", key.keyId()) + .withValue("public_key", Base64.getEncoder().encodeToString(publicKey.getEncoded())).withValue("created_at", now); + Signature signature = Signature.getInstance("Ed25519"); + signature.initSign(key.privateKey()); + signature.update(payload(record)); + record.setValue("signature", Base64.getEncoder().encodeToString(signature.sign())); + if(!verify(record, publicKey)) + { + throw (new GeneralSecurityException("Signing private key does not match its pin")); + } + return (record); + } + + + /******************************************************************************* + ** Verify with a pinned key, rejecting substituted algorithms and public keys. + *******************************************************************************/ + public static boolean verify(QRecord record, PublicKey pin) + { + try + { + if(pin == null || !"Ed25519".equals(record.getValueString("algorithm")) + || !Base64.getEncoder().encodeToString(pin.getEncoded()).equals(record.getValueString("public_key"))) + { + return (false); + } + Signature signature = Signature.getInstance("Ed25519"); + signature.initVerify(pin); + signature.update(payload(record)); + return (signature.verify(Base64.getDecoder().decode(record.getValueString("signature")))); + } + catch(GeneralSecurityException | IllegalArgumentException e) + { + return (false); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/ddl/WormAuditDdl.java b/src/main/java/com/kingsrook/qbits/wormaudit/ddl/WormAuditDdl.java new file mode 100644 index 0000000..b098268 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/ddl/WormAuditDdl.java @@ -0,0 +1,112 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.ddl; + + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import com.kingsrook.qbits.wormaudit.WormAuditQBitConfig; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails; + + +/******************************************************************************* + ** Render a reviewed migration for the host's physical audit table names. + ** Core physical columns must use snake_case: audit.id/timestamp, + ** audit_detail.id/audit_id, audit_table.id/name/label/create_date/modify_date, + ** and audit_user.id/name/create_date/modify_date. Only table names are rendered; + ** QInstance field backend-name overrides do not change this DDL contract. + ** Identifiers are restricted and quoted; configuration is never executable SQL. + *******************************************************************************/ +public final class WormAuditDdl +{ + public static final Map DEFAULT_NAMES = Map.of("audit", "audit", "auditDetail", "audit_detail", "auditTable", "audit_table", "auditUser", "audit_user"); + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private WormAuditDdl() + { + } + + + /******************************************************************************* + ** A safe SQL identifier, optionally schema qualified. + *******************************************************************************/ + public static String identifier(String value) + { + if(value == null || !value.matches("[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?")) + { + throw (new IllegalArgumentException("Invalid SQL identifier")); + } + return ("\"" + value.replace(".", "\".\"") + "\""); + } + + + /******************************************************************************* + ** Resolve backend names while preserving explicit DDL overrides. + *******************************************************************************/ + public static void resolveNames(WormAuditQBitConfig config, QInstance instance) + { + Map names = new LinkedHashMap<>(); + for(String name : DEFAULT_NAMES.keySet()) + { + QTableMetaData table = instance.getTable(name); + if(table == null) + { + throw (new IllegalArgumentException("Core audit table is required: " + name)); + } + names.put(name, table.getBackendDetails() instanceof RDBMSTableBackendDetails details ? details.getTableName() : DEFAULT_NAMES.get(name)); + } + names.putAll(config.getCoreAuditTableNames()); + config.withCoreAuditTableNames(Map.copyOf(names)); + } + + + /******************************************************************************* + ** Render the template. Retention is compiled into the function, never a GUC. + *******************************************************************************/ + public static String postgres(WormAuditQBitConfig config) + { + if(config.getRetentionYears() < 1 || config.getAppRole().contains(".")) + { + throw (new IllegalArgumentException("Invalid retention or app role")); + } + try(var stream = WormAuditDdl.class.getResourceAsStream("/com/kingsrook/qbits/wormaudit/ddl/postgres/worm-audit.sql")) + { + String sql = new String(java.util.Objects.requireNonNull(stream).readAllBytes(), StandardCharsets.UTF_8); + Map names = new LinkedHashMap<>(DEFAULT_NAMES); + names.putAll(config.getCoreAuditTableNames()); + for(var entry : DEFAULT_NAMES.entrySet()) + { + sql = sql.replace("${" + entry.getValue() + "}", identifier(names.get(entry.getKey()))); + } + return (sql.replace("${app_role}", identifier(config.getAppRole())).replace("${retention}", config.getRetentionYears() + " years")); + } + catch(IOException e) + { + throw (new IllegalStateException("Cannot read WORM audit DDL", e)); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/export/ExportFiles.java b/src/main/java/com/kingsrook/qbits/wormaudit/export/ExportFiles.java new file mode 100644 index 0000000..93f9f1c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/export/ExportFiles.java @@ -0,0 +1,220 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.export; + + +import java.io.IOException; +import java.io.InputStream; +import java.io.Serializable; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kingsrook.qbits.objectstorage.api.StoredObject; +import com.kingsrook.qbits.wormaudit.chain.AuditStore; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** Version-one export encoding and temporary files. JSON errors never echo input. + *******************************************************************************/ +public class ExportFiles implements AutoCloseable +{ + public static final List NAMES = List.of("links.jsonl", "checkpoints.jsonl", "manifest.json"); + private static final ObjectMapper JSON = new ObjectMapper().enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) + .enable(DeserializationFeature.USE_LONG_FOR_INTS).enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + private final Path directory; + + /******************************************************************************* + ** Create a private temporary directory, removed on all normal failure paths. + *******************************************************************************/ + public ExportFiles() throws IOException + { + directory = Files.createTempDirectory("worm-audit-"); + } + + + /******************************************************************************* + ** Resolve only one of the fixed filenames, never a manifest-supplied path. + *******************************************************************************/ + public Path file(String name) + { + if(!NAMES.contains(name)) + { + throw (new IllegalArgumentException("Unknown export file")); + } + return (directory.resolve(name)); + } + + + /******************************************************************************* + ** Parse the JCS schema with strict duplicate and trailing-token rejection. + *******************************************************************************/ + public static Map parse(byte[] bytes) throws IOException + { + try + { + return (JSON.readValue(bytes, new TypeReference>() {})); + } + catch(Exception e) + { + throw (new IOException("Invalid sealed JSON")); + } + } + + + /******************************************************************************* + ** Wire representation of a chain row, excluding database-only timestamps. + *******************************************************************************/ + public static Map link(QRecord row) + { + return (Map.of("id", row.getValueLong("id"), "kind", row.getValueString("kind"), + "auditId", row.getValueLong("audit_id"), "detailIds", AuditStore.detailIds(row), + "prevHash", row.getValueString("prev_hash"), "linkHash", row.getValueString("link_hash"))); + } + + + /******************************************************************************* + ** Preserve every checkpoint field, representing the timestamp as UTC text. + *******************************************************************************/ + public static Map checkpoint(QRecord row) + { + Map fields = new LinkedHashMap<>(); + for(String key : List.of("id", "from_link_id", "to_link_id", "link_count")) + { + fields.put(key, row.getValueLong(key)); + } + for(String key : List.of("head_hash", "merkle_root", "algorithm", "key_id", "public_key", "signature")) + { + fields.put(key, row.getValueString(key)); + } + fields.put("created_at", row.getValueInstant("created_at").toString()); + return (fields); + } + + + /******************************************************************************* + ** Convert a checkpoint envelope back to the phase (a) signature input. + *******************************************************************************/ + public static QRecord record(Map fields) + { + QRecord row = new QRecord(); + fields.forEach((key, value) -> row.setValue(key, (Serializable) value)); + return (row); + } + + + /******************************************************************************* + ** Compute a digest without materializing a file. + *******************************************************************************/ + public static String sha256(Path path) throws Exception + { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try(InputStream input = Files.newInputStream(path)) + { + byte[] buffer = new byte[65536]; + int read; + while((read = input.read(buffer)) != -1) + { + digest.update(buffer, 0, read); + } + } + return (HexFormat.of().formatHex(digest.digest())); + } + + + /******************************************************************************* + ** A lock echo is required even if a provider advertises the capability. + *******************************************************************************/ + public static boolean locked(StoredObject object, java.time.Instant until) + { + return (object != null && "COMPLIANCE".equals(object.getLockMode()) && object.getLockRetainUntil() != null + && !object.getLockRetainUntil().isBefore(until)); + } + + + /******************************************************************************* + ** A null version belongs to unversioned storage and cannot be sealed. + *******************************************************************************/ + public static boolean validVersion(String versionId) + { + return (versionId != null && !versionId.isBlank() && !"null".equals(versionId)); + } + + + /******************************************************************************* + ** Fetch covered detail rows in bounded IN batches. + *******************************************************************************/ + public static Map details(AuditStore store, List links) throws Exception + { + List ids = new ArrayList<>(); + for(QRecord link : links) + { + List covered = AuditStore.detailIds(link); + if(covered.size() > 250) + { + throw (new IOException("Invalid detail coverage")); + } + ids.addAll(covered); + } + Map rows = new LinkedHashMap<>(); + for(int start = 0; start < ids.size(); start += 1000) + { + rows.putAll(AuditStore.index(store.in("auditDetail", "id", ids.subList(start, Math.min(start + 1000, ids.size()))))); + } + return (rows); + } + + + /******************************************************************************* + ** Remove only the three owned files and their directory. + *******************************************************************************/ + @Override + public void close() throws IOException + { + IOException failure = null; + for(String name : NAMES) + { + try + { + Files.deleteIfExists(file(name)); + } + catch(IOException e) + { + failure = new IOException("Cannot remove temporary export files"); + } + } + Files.deleteIfExists(directory); + if(failure != null) + { + throw (failure); + } + } +} + diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/export/ObjectStorageSealedStore.java b/src/main/java/com/kingsrook/qbits/wormaudit/export/ObjectStorageSealedStore.java new file mode 100644 index 0000000..cde2a01 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/export/ObjectStorageSealedStore.java @@ -0,0 +1,132 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.export; + + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import com.kingsrook.qbits.objectstorage.api.Capability; +import com.kingsrook.qbits.objectstorage.api.CapabilityStatus; +import com.kingsrook.qbits.objectstorage.api.ListRequest; +import com.kingsrook.qbits.objectstorage.api.LockMode; +import com.kingsrook.qbits.objectstorage.api.ObjectStorage; +import com.kingsrook.qbits.objectstorage.api.ObjectSummary; +import com.kingsrook.qbits.objectstorage.api.Page; +import com.kingsrook.qbits.objectstorage.api.PutRequest; +import com.kingsrook.qbits.objectstorage.api.StorageBucket; +import com.kingsrook.qbits.objectstorage.api.StorageKeys; +import com.kingsrook.qbits.objectstorage.api.StoredObject; +import com.kingsrook.qbits.objectstorage.config.BucketRole; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.internal.SecretRefs; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; + + +/******************************************************************************* + ** Thin system-caller adapter to qbit-object-storage. + *******************************************************************************/ +public class ObjectStorageSealedStore implements SealedStore +{ + private final StorageBucket bucket; + + /******************************************************************************* + ** Bind the recorded alias, so old exports survive configuration changes. + *******************************************************************************/ + public ObjectStorageSealedStore(String alias, String runId) + { + bucket = ObjectStorage.asSystem("worm_audit", runId).bucket(alias); + } + + + /******************************************************************************* + ** Refuse unknown, partial, or unsupported lock capabilities. + *******************************************************************************/ + @Override + public boolean supportsLock() + { + return (bucket.config().getRole() == BucketRole.BACKUP + && bucket.capability(Capability.OBJECT_LOCK) == CapabilityStatus.SUPPORTED); + } + + + /******************************************************************************* + ** Stream a bounded upload and close the source even when the provider fails. + *******************************************************************************/ + @Override + public String put(String key, Path file, Instant retainUntil) throws Exception + { + try(InputStream input = Files.newInputStream(file)) + { + return (bucket.put(PutRequest.of(key, input).withContentLength(Files.size(file)) + .withObjectLock(LockMode.COMPLIANCE, retainUntil)).getVersionId()); + } + } + + + /******************************************************************************* + ** Read retention metadata. + *******************************************************************************/ + @Override + public StoredObject head(String key) + { + return (bucket.head(key).orElse(null)); + } + + + /******************************************************************************* + ** RC.2 has no version GET facade. Reuse its configured system connection, + ** key normalization and limiter; closing the stream closes the S3 response. + *******************************************************************************/ + @Override + public InputStream getVersion(String key, String versionId) throws IOException + { + if(!ExportFiles.validVersion(versionId)) + { + throw (new IOException("Missing sealed object version")); + } + var connection = ObjectStorageRuntime.connection(bucket.config().getConnection()); + try(var ignored = connection.getRateLimiter().acquire()) + { + var input = connection.client().getObject(GetObjectRequest.builder() + .bucket(SecretRefs.resolveSetting(bucket.config().getBucketName())).key(StorageKeys.normalize(key)).versionId(versionId).build()); + if(!versionId.equals(input.response().versionId())) + { + input.close(); + throw (new IOException("Storage returned a different object version")); + } + return (input); + } + } + + + /******************************************************************************* + ** Forward bounded listing. + *******************************************************************************/ + @Override + public Page list(ListRequest request) + { + return (bucket.list(request)); + } +} + diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/export/SealedExportVerifier.java b/src/main/java/com/kingsrook/qbits/wormaudit/export/SealedExportVerifier.java new file mode 100644 index 0000000..a64b3e7 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/export/SealedExportVerifier.java @@ -0,0 +1,409 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.export; + + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.sql.Connection; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import com.kingsrook.qbits.wormaudit.WormAuditQBitConfig; +import com.kingsrook.qbits.wormaudit.canonical.Jcs; +import com.kingsrook.qbits.wormaudit.chain.AuditStore; +import com.kingsrook.qbits.wormaudit.chain.Hashes; +import com.kingsrook.qbits.wormaudit.chain.WormAuditChainer; +import com.kingsrook.qbits.wormaudit.checkpoint.Checkpoints; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qbits.wormaudit.processes.VerificationRuns; +import com.kingsrook.qbits.wormaudit.verify.VerificationResult; +import com.kingsrook.qbits.wormaudit.verify.WormAuditVerifier; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSTransaction; + + +/******************************************************************************* + ** Verify the sealed evidence before comparing it to a repeatable live snapshot. + ** No exported content or provider/parser exception text enters diagnostics. + *******************************************************************************/ +public class SealedExportVerifier +{ + private final WormAuditQBitConfig config; + private final SealedStore suppliedStorage; + + /******************************************************************************* + ** Findings plus the number verified without live rows after a retention purge. + *******************************************************************************/ + public record Report(VerificationResult result, long purged) + { + } + + /******************************************************************************* + ** Production entry point. + *******************************************************************************/ + public SealedExportVerifier(WormAuditQBitConfig config) + { + this(config, null); + } + + + /******************************************************************************* + ** Testable storage boundary. + *******************************************************************************/ + public SealedExportVerifier(WormAuditQBitConfig config, SealedStore storage) + { + this.config = config; + this.suppliedStorage = storage; + } + + + /******************************************************************************* + ** Persist exactly one verification row for this selected export, even on failure. + *******************************************************************************/ + public Report verify(QRecord export, String trigger) throws QException + { + VerificationResult failure = new VerificationResult(); + failure.scope(0, export.getValueLong("from_link_id"), export.getValueLong("to_link_id")); + Report report; + WormAuditChainer.JVM_LOCK.lock(); + try(QBackendTransaction transaction = QBackendTransaction.openFor(new QueryInput(WormAuditTables.CHAIN)); + ExportFiles files = new ExportFiles()) + { + try + { + if(transaction instanceof RDBMSTransaction rdbms) + { + rdbms.getConnection().setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ); + } + SealedStore storage = suppliedStorage == null + ? new ObjectStorageSealedStore(export.getValueString("bucket_alias"), "verify-" + export.getValueLong("id")) : suppliedStorage; + Map versions = ExportFiles.parse(export.getValueString("object_versions").getBytes(StandardCharsets.UTF_8)); + for(String name : ExportFiles.NAMES) + { + String key = export.getValueString("object_prefix") + name; + if(!(versions.get(name) instanceof String version) || !ExportFiles.validVersion(version)) + { + failure.add(null, null, "object " + name + " has no recorded sealed version id"); + throw (new IOException("Missing sealed object version")); + } + var head = storage.head(key); + if(head != null && !version.equals(head.getVersionId())) + { + failure.add(null, null, "object " + name + " was overwritten after sealing (current version " + head.getVersionId() + + ", sealed version " + version + " is still retained under Object Lock)"); + } + if(!ExportFiles.locked(head, export.getValueInstant("retain_until"))) + { + failure.add(null, null, "object " + name + " lacks the recorded COMPLIANCE retention"); + } + } + for(String name : ExportFiles.NAMES) + { + try(InputStream input = storage.getVersion(export.getValueString("object_prefix") + name, (String) versions.get(name))) + { + Files.copy(input, files.file(name)); + } + } + report = inspect(files, export, new AuditStore(transaction)); + for(var item : failure.getBreaks()) + { + report.result().add(item.linkId(), item.auditId(), item.reason()); + } + transaction.commit(); + } + catch(Exception e) + { + transaction.rollback(); + throw (e); + } + } + catch(Exception e) + { + failure.add(null, null, "Sealed export cannot be read or has invalid structure"); + report = new Report(failure, 0); + } + finally + { + WormAuditChainer.JVM_LOCK.unlock(); + } + VerificationRuns.save(report.result(), trigger, export.getValueLong("id")); + return (report); + } + + + /******************************************************************************* + ** Inspect local files, also used by the exporter before committing any objects. + *******************************************************************************/ + public Report inspect(ExportFiles files, QRecord export, AuditStore store) throws Exception + { + VerificationResult result = new VerificationResult(); + long from = export.getValueLong("from_link_id"); + long to = export.getValueLong("to_link_id"); + result.scope(0, from, to); + if(!Objects.equals(ExportFiles.sha256(files.file("manifest.json")), export.getValueString("manifest_sha256"))) + { + result.add(null, null, "object manifest.json hash does not match the export row"); + return (new Report(result, 0)); + } + if(Files.size(files.file("manifest.json")) > 65536) + { + result.add(null, null, "Manifest exceeds the format size limit"); + return (new Report(result, 0)); + } + Map manifest = ExportFiles.parse(Files.readAllBytes(files.file("manifest.json"))); + if(!"worm-audit-export/1".equals(manifest.get("format")) || from < 1 || to < from + || !Objects.equals(manifest.get("fromLinkId"), from) || !Objects.equals(manifest.get("toLinkId"), to) + || !Objects.equals(manifest.get("linkCount"), to - from + 1) + || !Objects.equals(manifest.get("createdAt"), export.getValueInstant("sealed_at").toString()) + || !"COMPLIANCE".equals(export.getValueString("lock_mode")) + || !Objects.equals(export.getValueString("manifest_key"), export.getValueString("object_prefix") + "manifest.json")) + { + result.add(null, null, "Manifest metadata does not match the export row"); + } + QQueryFilter previousFilter = new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false)).withLimit(1); + if(export.getValueLong("id") != null) + { + previousFilter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.LESS_THAN, export.getValueLong("id"))); + } + List previousRows = store.query(WormAuditTables.EXPORT, previousFilter); + QRecord previous = previousRows.isEmpty() ? null : previousRows.getFirst(); + String previousHash = previous == null ? null : previous.getValueString("manifest_sha256"); + if(!Objects.equals(manifest.get("prevManifestSha256"), previousHash) + || !Objects.equals(export.getValueString("prev_manifest_sha256"), previousHash) + || (previous != null && previous.getValueLong("to_link_id") != from - 1)) + { + result.add(null, null, "prevManifestSha256 does not match the previous export row"); + } + Map hashes = (Map) manifest.get("files"); + for(String name : List.of("links.jsonl", "checkpoints.jsonl")) + { + if(!Objects.equals(ExportFiles.sha256(files.file(name)), hashes.get(name))) + { + result.add(null, null, "object " + name + " hash does not match the manifest"); + } + } + if(!result.passed()) + { + return (new Report(result, 0)); + } + return (checkLines(files, export, store, manifest, result)); + } + + + /******************************************************************************* + ** Consume links alongside their checkpoint envelopes. Only one checkpoint's + ** hashes and one content page are retained, never the whole export. + *******************************************************************************/ + private Report checkLines(ExportFiles files, QRecord export, AuditStore store, Map manifest, VerificationResult result) throws Exception + { + long from = export.getValueLong("from_link_id"); + long to = export.getValueLong("to_link_id"); + long cursor = from - 1; + long checkpointCount = 0; + long checked = 0; + long purged = 0; + long anchor = store.anchor().getValueLong("anchor_link_id"); + String previousHash = from == 1 ? Hashes.GENESIS : null; + if(cursor == anchor) + { + previousHash = store.anchor().getValueString("anchor_hash"); + } + long lastCheckpoint = 0; + try(BufferedReader links = Files.newBufferedReader(files.file("links.jsonl"), StandardCharsets.UTF_8); + BufferedReader checkpoints = Files.newBufferedReader(files.file("checkpoints.jsonl"), StandardCharsets.UTF_8)) + { + String line; + while((line = checkpoints.readLine()) != null) + { + QRecord checkpoint = ExportFiles.record(ExportFiles.parse(line.getBytes(StandardCharsets.UTF_8))); + long checkpointId = checkpoint.getValueLong("id"); + String label = "checkpoint " + WormAuditVerifier.number(checkpointId) + ": "; + long end = checkpoint.getValueLong("to_link_id"); + long start = checkpoint.getValueLong("from_link_id"); + long count = checkpoint.getValueLong("link_count"); + if(start != cursor + 1 || end < start || end > to || count != end - start + 1 + || count > config.getCheckpointEveryLinks() || checkpointId <= lastCheckpoint) + { + result.add(null, null, label + "invalid or incomplete checkpoint range"); + break; + } + if(!Checkpoints.verify(checkpoint, config.getTrustedPublicKeys().get(checkpoint.getValueString("key_id")))) + { + result.add(null, null, label + "signature does not verify"); + } + if(checkpointCount == 0 && !Objects.equals(export.getValueLong("from_checkpoint_id"), checkpointId)) + { + result.add(null, null, label + "first checkpoint does not match the export row"); + } + List hashes = new ArrayList<>(); + while(cursor < end) + { + List> page = new ArrayList<>(); + for(int i = 0, size = (int) Math.min(1000, end - cursor); i < size; i++) + { + String linkLine = links.readLine(); + if(linkLine == null) + { + result.add(cursor + 1, null, "Sealed links are missing from the checkpoint range"); + result.scope(checked, from, to); + return (new Report(result, purged)); + } + page.add(ExportFiles.parse(linkLine.getBytes(StandardCharsets.UTF_8))); + } + compareLive(page, store, export, anchor, result); + for(Map document : page) + { + Map link = (Map) document.get("link"); + long id = (Long) link.get("id"); + long auditId = (Long) link.get("auditId"); + String prefix = "link " + WormAuditVerifier.number(id) + " (audit " + WormAuditVerifier.number(auditId) + "): "; + String prev = (String) link.get("prevHash"); + String hash = (String) link.get("linkHash"); + if(id != cursor + 1 || (previousHash != null && !Objects.equals(prev, previousHash))) + { + result.add(id, auditId, prefix + "sealed chain continuity differs"); + } + Map content = (Map) document.get("content"); + Map audit = (Map) content.get("audit"); + List detailIds = (List) link.get("detailIds"); + List details = (List) content.get("details"); + List contentIds = details.stream().map(d -> ((Map) d).get("id")).toList(); + if(!Objects.equals(audit.get("id"), auditId) || !Objects.equals(content.get("v"), 1L) + || !List.of("AUDIT", "LATE_DETAIL").contains(link.get("kind")) + || detailIds.size() > 250 || !detailIds.equals(contentIds) + || detailIds.stream().distinct().count() != detailIds.size() + || ("LATE_DETAIL".equals(link.get("kind")) && detailIds.isEmpty())) + { + result.add(id, auditId, prefix + "sealed content identifiers or coverage differ"); + } + if(!Hashes.link(prev, Jcs.bytes(content)).equals(hash)) + { + result.add(id, auditId, prefix + "sealed content differs from the chained hash"); + } + hashes.add(hash); + previousHash = hash; + cursor++; + checked++; + if(id <= anchor) + { + purged++; + } + } + } + if(!Objects.equals(Hashes.merkle(hashes), checkpoint.getValueString("merkle_root")) + || !Objects.equals(previousHash, checkpoint.getValueString("head_hash"))) + { + result.add(null, null, label + "Merkle root or head hash differs"); + } + lastCheckpoint = checkpointId; + checkpointCount++; + } + if(cursor != to || links.readLine() != null || !Objects.equals(manifest.get("checkpointCount"), checkpointCount) + || !Objects.equals(export.getValueLong("to_checkpoint_id"), lastCheckpoint) + || !Objects.equals(manifest.get("headHash"), previousHash)) + { + result.add(null, null, "Sealed range, counts or head hash differ from the manifest"); + } + } + result.scope(checked, from, to); + return (new Report(result, purged)); + } + + + /******************************************************************************* + ** Compare a bounded page by exported ids, so missing live rows cannot vanish + ** from the verification scope. Purged ids are checked from signed copies only. + *******************************************************************************/ + private void compareLive(List> page, AuditStore store, QRecord export, long anchor, VerificationResult result) throws Exception + { + List sealed = page.stream().map(document -> + { + Map link = (Map) document.get("link"); + List ids = (List) link.get("detailIds"); + return (new QRecord().withValue("id", (Long) link.get("id")).withValue("audit_id", (Long) link.get("auditId")) + .withValue("kind", (String) link.get("kind")).withValue("prev_hash", (String) link.get("prevHash")) + .withValue("link_hash", (String) link.get("linkHash")) + .withValue("detail_ids", ids.stream().map(Object::toString).collect(java.util.stream.Collectors.joining(",")))); + }).toList(); + List liveScope = sealed.stream().filter(r -> r.getValueLong("id") > anchor).toList(); + Map live = AuditStore.index(store.in(WormAuditTables.CHAIN, "id", liveScope.stream().map(r -> r.getValueLong("id")).toList())); + Map audits = AuditStore.index(store.in("audit", "id", liveScope.stream().map(r -> r.getValueLong("audit_id")).toList())); + Map details = ExportFiles.details(store, liveScope); + for(int i = 0; i < sealed.size(); i++) + { + QRecord link = sealed.get(i); + long id = link.getValueLong("id"); + long auditId = link.getValueLong("audit_id"); + if(id <= anchor) + { + continue; + } + String prefix = "link " + WormAuditVerifier.number(id) + " (audit " + WormAuditVerifier.number(auditId) + "): "; + QRecord current = live.get(id); + if(current == null) + { + result.add(id, auditId, "link " + WormAuditVerifier.number(id) + " is missing from the live chain"); + } + else + { + for(String field : List.of("kind", "audit_id", "detail_ids", "prev_hash", "link_hash")) + { + if(!Objects.equals(link.getValue(field), current.getValue(field))) + { + result.add(id, auditId, prefix + "live chain " + field + " differs from the sealed copy"); + } + } + } + try + { + QRecord audit = audits.get(auditId); + if(audit == null) + { + result.add(id, auditId, prefix + "live audit header is missing"); + } + else if(!Arrays.equals(store.canonical(audit, AuditStore.detailIds(link), details), Jcs.bytes(page.get(i).get("content")))) + { + result.add(id, auditId, prefix + "live content differs from the sealed copy (sealed " + + export.getValueInstant("sealed_at").atZone(ZoneOffset.UTC).toLocalDate() + ")"); + } + } + catch(Exception e) + { + result.add(id, auditId, prefix + "live content is missing or invalid"); + } + } + } +} + diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/export/SealedExporter.java b/src/main/java/com/kingsrook/qbits/wormaudit/export/SealedExporter.java new file mode 100644 index 0000000..e88ee8f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/export/SealedExporter.java @@ -0,0 +1,389 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.export; + + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.sql.ResultSet; +import java.sql.Statement; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.wormaudit.WormAuditQBitConfig; +import com.kingsrook.qbits.wormaudit.WormAuditQBitProducer; +import com.kingsrook.qbits.wormaudit.canonical.Jcs; +import com.kingsrook.qbits.wormaudit.chain.AuditStore; +import com.kingsrook.qbits.wormaudit.chain.Hashes; +import com.kingsrook.qbits.wormaudit.chain.WormAuditChainer; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qbits.wormaudit.verify.WormAuditVerifier; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; +import com.kingsrook.qqq.backend.core.alerts.AlertSignal; +import com.kingsrook.qqq.backend.core.alerts.QAlerts; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSTransaction; + + +/******************************************************************************* + ** Seal the checkpointed suffix under the chainer/purger lock. Only successful + ** uploads with verified lock echoes produce an export row. + *******************************************************************************/ +public class SealedExporter +{ + private final WormAuditQBitConfig config; + private final SealedStore suppliedStorage; + + /******************************************************************************* + ** A review scope; execution always recomputes it under the writer lock. + *******************************************************************************/ + public record Scope(long from, long to, long checkpoints, QRecord previous) + { + /******************************************************************************* + ** Number of links, including the empty checkpoint suffix. + *******************************************************************************/ + public long count() + { + return (Math.max(0, to - from + 1)); + } + + /******************************************************************************* + ** Value-free Review text. + *******************************************************************************/ + public String review() + { + return (count() == 0 ? "Nothing to seal: no new checkpoint" : WormAuditVerifier.number(count()) + + " chain links will be sealed (links " + WormAuditVerifier.number(from) + "–" + WormAuditVerifier.number(to) + + ", " + WormAuditVerifier.number(checkpoints) + " checkpoints)"); + } + } + + /******************************************************************************* + ** A completed, empty, or failed run suitable for a Result line. + *******************************************************************************/ + public record Result(QRecord row, String message, boolean failed) + { + } + + /******************************************************************************* + ** Production storage is resolved lazily, so an empty run needs no bucket. + *******************************************************************************/ + public SealedExporter(WormAuditQBitConfig config) + { + this(config, null); + } + + + /******************************************************************************* + ** Inject the tiny storage boundary for deterministic tests. + *******************************************************************************/ + public SealedExporter(WormAuditQBitConfig config, SealedStore storage) + { + this.config = config; + this.suppliedStorage = storage; + } + + + /******************************************************************************* + ** Select the contiguous suffix, never silently skipping an unexported purge. + *******************************************************************************/ + public static Scope scope(AuditStore store) throws QException + { + QRecord previous = store.last(WormAuditTables.EXPORT); + long anchor = store.anchor().getValueLong("anchor_link_id"); + long after = previous == null ? anchor : previous.getValueLong("to_link_id"); + if(after < anchor) + { + throw (new QException("Unexported links were purged")); + } + QRecord checkpoint = store.last(WormAuditTables.CHECKPOINT); + long to = checkpoint == null ? after : Math.max(after, checkpoint.getValueLong("to_link_id")); + long count = 0; + long cursor = 0; + while(true) + { + List page = checkpoints(store, after + 1, to, cursor); + if(page.isEmpty()) + { + break; + } + count += page.size(); + cursor = page.getLast().getValueLong("id"); + } + return (new Scope(after + 1, to, count, previous)); + } + + + /******************************************************************************* + ** Select checkpoint envelopes wholly contained in the export range. + *******************************************************************************/ + private static List checkpoints(AuditStore store, long from, long to, long after) throws QException + { + return (store.query(WormAuditTables.CHECKPOINT, new QQueryFilter( + new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, after), + new QFilterCriteria("from_link_id", QCriteriaOperator.GREATER_THAN_OR_EQUALS, from), + new QFilterCriteria("to_link_id", QCriteriaOperator.LESS_THAN_OR_EQUALS, to)) + .withOrderBy(new QFilterOrderBy("id", true)).withLimit(1000))); + } + + + /******************************************************************************* + ** Execute with alerts on user and scheduled failures; schedules always beat. + *******************************************************************************/ + public Result run(String trigger, Instant now) + { + try + { + config.validate(); + Result result = lockedRun(now); + QAlerts.resolve("worm_audit.export.failed", "export", "Sealed audit export completed"); + return (result); + } + catch(Exception e) + { + // All exposed failures originate here or use a fixed, value-free code. + String reason = e instanceof ExportFailure ? e.getMessage() : "Sealed export failed: storage or database operation failed"; + QAlerts.raise(new AlertSignal("worm_audit.export.failed").withDedupKey("export").withTitle(reason)); + return (new Result(null, reason, true)); + } + finally + { + if("SCHEDULE".equals(trigger)) + { + QAlerts.heartbeat("worm_audit.export.daily"); + } + } + } + + + /******************************************************************************* + ** Serialize with writers in this JVM and PostgreSQL writers on other hosts. + *******************************************************************************/ + private Result lockedRun(Instant now) throws Exception + { + WormAuditChainer.JVM_LOCK.lock(); + try(QBackendTransaction transaction = QBackendTransaction.openFor(new QueryInput(WormAuditTables.CHAIN))) + { + try + { + if(transaction instanceof RDBMSTransaction rdbms && "PostgreSQL".equals(rdbms.getConnection().getMetaData().getDatabaseProductName())) + { + try(Statement statement = rdbms.getConnection().createStatement(); + ResultSet result = statement.executeQuery("SELECT pg_advisory_xact_lock(" + WormAuditChainer.ADVISORY_LOCK + ")")) + { + result.next(); + } + } + AuditStore store = new AuditStore(transaction); + Scope scope = scope(store); + Result result; + if(scope.count() == 0) + { + result = new Result(null, scope.review(), false); + } + else + { + SealedStore storage = suppliedStorage == null + ? new ObjectStorageSealedStore(config.getExportBucketAlias(), java.util.UUID.randomUUID().toString()) : suppliedStorage; + result = seal(store, storage, scope, now.truncatedTo(ChronoUnit.MILLIS)); + } + transaction.commit(); + return (result); + } + catch(Exception e) + { + transaction.rollback(); + throw (e); + } + } + finally + { + WormAuditChainer.JVM_LOCK.unlock(); + } + } + + + /******************************************************************************* + ** Build locally before uploading. Partial remote uploads have no database row. + *******************************************************************************/ + private Result seal(AuditStore store, SealedStore storage, Scope scope, Instant now) throws Exception + { + if(!storage.supportsLock()) + { + throw (new ExportFailure("Export refused: bucket does not support Object Lock")); + } + Instant until = now.atZone(ZoneOffset.UTC).plusYears(config.getRetentionYears()).toInstant(); + String prefix = config.getExportPrefix() + "/" + DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneOffset.UTC).format(now) + + "/" + scope.from() + "-" + scope.to() + "/"; + QRecord row; + try(ExportFiles files = new ExportFiles()) + { + String head = writeLinks(files, store, scope); + long[] checkpointIds = writeCheckpoints(files, store, scope); + Map manifest = new LinkedHashMap<>(); + manifest.put("format", "worm-audit-export/1"); + manifest.put("fromLinkId", scope.from()); + manifest.put("toLinkId", scope.to()); + manifest.put("linkCount", scope.count()); + manifest.put("checkpointCount", scope.checkpoints()); + manifest.put("files", Map.of("links.jsonl", ExportFiles.sha256(files.file("links.jsonl")), + "checkpoints.jsonl", ExportFiles.sha256(files.file("checkpoints.jsonl")))); + manifest.put("headHash", head); + String previous = scope.previous() == null ? null : scope.previous().getValueString("manifest_sha256"); + manifest.put("prevManifestSha256", previous); + manifest.put("qbitVersion", WormAuditQBitProducer.VERSION); + manifest.put("createdAt", now.toString()); + Files.write(files.file("manifest.json"), Jcs.bytes(manifest)); + row = new QRecord().withValue("from_link_id", scope.from()).withValue("to_link_id", scope.to()) + .withValue("from_checkpoint_id", checkpointIds[0]).withValue("to_checkpoint_id", checkpointIds[1]) + .withValue("bucket_alias", config.getExportBucketAlias()).withValue("object_prefix", prefix) + .withValue("manifest_key", prefix + "manifest.json").withValue("manifest_sha256", ExportFiles.sha256(files.file("manifest.json"))) + .withValue("prev_manifest_sha256", previous).withValue("object_count", 3L) + .withValue("lock_mode", "COMPLIANCE").withValue("retain_until", until).withValue("sealed_at", now); + if(!new SealedExportVerifier(config, storage).inspect(files, row, store).result().passed()) + { + throw (new ExportFailure("Export refused: source does not match the signed chain")); + } + long bytes = 0; + Map versions = new LinkedHashMap<>(); + for(String name : ExportFiles.NAMES) + { + String version = storage.put(prefix + name, files.file(name), until); + if(!ExportFiles.validVersion(version)) + { + throw (new ExportFailure("Export refused: object " + name + " returned no version id")); + } + var headObject = storage.head(prefix + name); + if(!ExportFiles.locked(headObject, until)) + { + throw (new ExportFailure("Export refused: object " + name + " did not echo COMPLIANCE retention")); + } + if(!version.equals(headObject.getVersionId())) + { + throw (new ExportFailure("Export refused: object " + name + " version changed after upload")); + } + versions.put(name, version); + bytes += Files.size(files.file(name)); + } + row.withValue("byte_count", bytes).withValue("object_versions", Jcs.string(versions)); + } + row = store.insert(WormAuditTables.EXPORT, List.of(row)).getFirst(); + return (new Result(row, WormAuditVerifier.number(scope.count()) + " links sealed in export " + row.getValueLong("id") + + " (retained until " + until + ")", false)); + } + + + /******************************************************************************* + ** Stream canonical lines in pages of 1000 with batched content lookups. + *******************************************************************************/ + private String writeLinks(ExportFiles files, AuditStore store, Scope scope) throws Exception + { + long cursor = scope.from() - 1; + String head = null; + try(BufferedWriter writer = Files.newBufferedWriter(files.file("links.jsonl"), StandardCharsets.UTF_8)) + { + while(cursor < scope.to()) + { + List links = store.page(WormAuditTables.CHAIN, cursor, (int) Math.min(1000, scope.to() - cursor)); + if(links.isEmpty()) + { + throw (new ExportFailure("Export refused: chain range is incomplete")); + } + Map audits = AuditStore.index(store.in("audit", "id", links.stream().map(r -> r.getValueLong("audit_id")).toList())); + Map details = ExportFiles.details(store, links); + for(QRecord link : links) + { + if(link.getValueLong("id") != cursor + 1 || link.getValueLong("id") > scope.to()) + { + throw (new ExportFailure("Export refused: chain range is incomplete")); + } + byte[] content = store.canonical(audits.get(link.getValueLong("audit_id")), AuditStore.detailIds(link), details); + if(!Hashes.link(link.getValueString("prev_hash"), content).equals(link.getValueString("link_hash"))) + { + throw (new ExportFailure("Export refused: content differs at link " + link.getValueLong("id"))); + } + writer.write(Jcs.string(Map.of("link", ExportFiles.link(link), "content", ExportFiles.parse(content)))); + writer.write('\n'); + head = link.getValueString("link_hash"); + cursor = link.getValueLong("id"); + } + } + } + return (head); + } + + + /******************************************************************************* + ** Stream complete signed checkpoint envelopes in keyset pages. + *******************************************************************************/ + private long[] writeCheckpoints(ExportFiles files, AuditStore store, Scope scope) throws Exception + { + long first = 0; + long cursor = 0; + try(BufferedWriter writer = Files.newBufferedWriter(files.file("checkpoints.jsonl"), StandardCharsets.UTF_8)) + { + while(true) + { + List page = checkpoints(store, scope.from(), scope.to(), cursor); + if(page.isEmpty()) + { + break; + } + for(QRecord row : page) + { + first = first == 0 ? row.getValueLong("id") : first; + cursor = row.getValueLong("id"); + writer.write(Jcs.string(ExportFiles.checkpoint(row))); + writer.write('\n'); + } + } + } + return (new long[] { first, cursor }); + } + + + /******************************************************************************* + ** Only fixed messages constructed here may reach an alert or Result screen. + *******************************************************************************/ + private static class ExportFailure extends IOException + { + /******************************************************************************* + ** Carry a value-free exporter reason without a provider exception cause. + *******************************************************************************/ + ExportFailure(String reason) + { + super(reason); + } + } +} + diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/export/SealedStore.java b/src/main/java/com/kingsrook/qbits/wormaudit/export/SealedStore.java new file mode 100644 index 0000000..5097870 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/export/SealedStore.java @@ -0,0 +1,63 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.export; + + +import java.io.InputStream; +import java.nio.file.Path; +import java.time.Instant; +import com.kingsrook.qbits.objectstorage.api.ListRequest; +import com.kingsrook.qbits.objectstorage.api.ObjectSummary; +import com.kingsrook.qbits.objectstorage.api.Page; +import com.kingsrook.qbits.objectstorage.api.StoredObject; + + +/******************************************************************************* + ** Minimal storage boundary. The application never deletes sealed objects. + *******************************************************************************/ +public interface SealedStore +{ + /******************************************************************************* + ** Only positively confirmed Object Lock support is acceptable. + *******************************************************************************/ + boolean supportsLock(); + + /******************************************************************************* + ** Upload a file with COMPLIANCE retention. + *******************************************************************************/ + String put(String key, Path file, Instant retainUntil) throws Exception; + + /******************************************************************************* + ** Read current object metadata, or null for an absent object. + *******************************************************************************/ + StoredObject head(String key) throws Exception; + + /******************************************************************************* + ** Open the exact sealed version; the caller owns and closes the stream. + *******************************************************************************/ + InputStream getVersion(String key, String versionId) throws Exception; + + /******************************************************************************* + ** List one bounded page. + *******************************************************************************/ + Page list(ListRequest request) throws Exception; +} + diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/guard/AppendOnlyGuard.java b/src/main/java/com/kingsrook/qbits/wormaudit/guard/AppendOnlyGuard.java new file mode 100644 index 0000000..20e2a6c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/guard/AppendOnlyGuard.java @@ -0,0 +1,81 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.guard; + + +import java.sql.SQLException; +import java.util.List; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** Metadata defense and a value-free mapping of the database write-once error. + *******************************************************************************/ +public class AppendOnlyGuard implements MetaDataCustomizerInterface +{ + public static final List CORE_TABLES = List.of("audit", "auditDetail", "auditTable", "auditUser"); + public static final String MESSAGE = "Audit records are write-once and cannot be changed or deleted."; + + /******************************************************************************* + ** Protect core and sidecar audit metadata after host customization. + *******************************************************************************/ + @Override + public QTableMetaData customizeMetaData(QInstance instance, QTableMetaData table) + { + if(CORE_TABLES.contains(table.getName()) || table.getName().startsWith("worm_audit_")) + { + table.withoutCapabilities(Capability.TABLE_UPDATE, Capability.TABLE_DELETE); + } + if(table.getName().startsWith("worm_audit_")) + { + table.withoutCapabilities(Capability.TABLE_INSERT); + } + return (table); + } + + + /******************************************************************************* + ** Preserve unrelated failures, map only the documented SQLSTATE and prefix. + *******************************************************************************/ + public static QException map(Exception error) + { + for(Throwable cause = error; cause != null; cause = cause.getCause()) + { + if(cause instanceof SQLException sql && "P0001".equals(sql.getSQLState()) + && sql.getMessage() != null && sql.getMessage().startsWith("worm_audit:")) + { + return (new QUserFacingException(MESSAGE)); + } + // The PostgreSQL driver prepends ERROR: to server error messages. + if(cause instanceof SQLException sql && "P0001".equals(sql.getSQLState()) + && sql.getMessage() != null && sql.getMessage().startsWith("ERROR: worm_audit:")) + { + return (new QUserFacingException(MESSAGE)); + } + } + return (error instanceof QException q ? q : new QException("WORM audit operation failed", error)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/model/LinkRangeWidget.java b/src/main/java/com/kingsrook/qbits/wormaudit/model/LinkRangeWidget.java new file mode 100644 index 0000000..2c74f7f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/model/LinkRangeWidget.java @@ -0,0 +1,88 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.model; + + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChildRecordListData; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; + + +/******************************************************************************* + ** A standard child-record list for a checkpoint/export/verification link range. + ** This is a record-screen relation, not an app-home widget. Reads retain user + ** permissions and use a bounded page with a filtered View All link. + *******************************************************************************/ +public class LinkRangeWidget extends AbstractWidgetRenderer +{ + /******************************************************************************* + ** Render the inclusive range stored on the parent record. + *******************************************************************************/ + @Override + public RenderWidgetOutput render(RenderWidgetInput input) throws QException + { + String parent = (String) input.getWidgetMetaData().getDefaultValues().get("parentTable"); + GetInput get = new GetInput().withPrimaryKey(input.getQueryParams().get("id")).withInputSource(QInputSource.USER); + get.setTableName(parent); + get.withPermissionEnforcement(PermissionEnforcement.ENFORCE); + QRecord record = new GetAction().execute(get).getRecord(); + if(record == null) + { + throw (new QNotFoundException("Audit range record was not found")); + } + Long from = record.getValueLong("from_link_id"); + Long to = record.getValueLong("to_link_id"); + QQueryFilter filter = new QQueryFilter() + .withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN_OR_EQUALS, from == null ? 1L : from)) + .withCriteria(new QFilterCriteria("id", QCriteriaOperator.LESS_THAN_OR_EQUALS, to == null ? 0L : to)) + .withOrderBy(new QFilterOrderBy("id", true)); + CountInput count = new CountInput(WormAuditTables.CHAIN).withFilter(filter).withInputSource(QInputSource.USER); + count.withPermissionEnforcement(PermissionEnforcement.ENFORCE); + int total = new CountAction().execute(count).getCount(); + String path = QContext.getQInstance().getTablePath(WormAuditTables.CHAIN); + String viewAll = path + "?filter=" + URLEncoder.encode(JsonUtils.toJson(filter), StandardCharsets.UTF_8); + QueryInput query = new QueryInput(WormAuditTables.CHAIN).withFilter(filter.clone().withLimit(100)).withInputSource(QInputSource.USER); + query.withPermissionEnforcement(PermissionEnforcement.ENFORCE); + return (new RenderWidgetOutput(new ChildRecordListData("Chain links", new QueryAction().execute(query), + QContext.getQInstance().getTable(WormAuditTables.CHAIN), path, viewAll, total))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/model/WormAuditTables.java b/src/main/java/com/kingsrook/qbits/wormaudit/model/WormAuditTables.java new file mode 100644 index 0000000..6208844 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/model/WormAuditTables.java @@ -0,0 +1,124 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.model; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.wormaudit.WormAuditQBitConfig; +import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel; +import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails; + + +/******************************************************************************* + ** Append-only sidecar tables. Snake-case fields map directly to DDL columns. + ** Export metadata is the integration hook for phase (b); no export runs here. + *******************************************************************************/ +public final class WormAuditTables +{ + public static final String CHAIN = "worm_audit_chain"; + public static final String CHECKPOINT = "worm_audit_checkpoint"; + public static final String EXPORT = "worm_audit_export"; + public static final String VERIFICATION = "worm_audit_verification"; + public static final String PURGE = "worm_audit_purge"; + public static final List ALL = List.of(CHAIN, CHECKPOINT, EXPORT, VERIFICATION, PURGE); + public static final List NAV = List.of(VERIFICATION, EXPORT, CHECKPOINT, CHAIN, PURGE); + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private WormAuditTables() + { + } + + + /******************************************************************************* + ** Construct full record screens, with no recursive audit writes. + *******************************************************************************/ + public static List tables(WormAuditQBitConfig config) + { + List tables = new ArrayList<>(); + tables.add(table(config, CHAIN, "Audit Chain Link", "id:L kind:S audit_id:L detail_ids:T prev_hash:S link_hash:S chained_at:D")); + tables.add(table(config, CHECKPOINT, "Checkpoint", "id:L from_link_id:L to_link_id:L link_count:L head_hash:S merkle_root:S algorithm:S key_id:S public_key:T signature:T created_at:D")); + tables.add(table(config, EXPORT, "Sealed Export", "id:L from_link_id:L to_link_id:L from_checkpoint_id:L to_checkpoint_id:L bucket_alias:S object_prefix:T object_versions:T manifest_key:T manifest_sha256:S prev_manifest_sha256:S object_count:L byte_count:L lock_mode:S retain_until:D sealed_at:D")); + tables.add(table(config, VERIFICATION, "Verification", "id:L kind:S trigger:S started_at:D finished_at:D from_link_id:L to_link_id:L links_checked:L outcome:S break_count:L first_break_link_id:L first_break_audit_id:L first_break_reason:T export_id:L run_by:S")); + tables.add(table(config, PURGE, "Purge", "id:L requested_at:D requested_by:S cutoff:D retention_interval:S outcome:S reason:T audits_deleted:L details_deleted:L links_deleted:L anchor_link_id:L anchor_hash:S")); + return (tables); + } + + + /******************************************************************************* + ** Common metadata for a process-written table. + *******************************************************************************/ + private static QTableMetaData table(WormAuditQBitConfig config, String name, String label, String fields) + { + QTableMetaData table = new QTableMetaData().withName(name).withLabel(label) + .withBackendName(config.getBackendName()).withBackendDetails(new RDBMSTableBackendDetails().withTableName(name)) + .withPrimaryKeyField("id").withRecordLabelFields("id").withRecordLabelFormat("%s") + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE)) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS).withPermissionBaseName(name)); + for(String spec : fields.split(" ")) + { + String[] parts = spec.split(":"); + QFieldType type = switch(parts[1]) + { + case "L" -> QFieldType.LONG; + case "D" -> QFieldType.DATE_TIME; + case "T" -> QFieldType.TEXT; + default -> QFieldType.STRING; + }; + table.withField(new QFieldMetaData(parts[0], type).withBackendName(parts[0])); + } + for(String reference : List.of("from_link_id", "to_link_id", "first_break_link_id", "anchor_link_id")) + { + if(table.getFields().containsKey(reference)) + { + table.getField(reference).withPossibleValueSourceName(CHAIN); + } + } + for(String reference : List.of("from_checkpoint_id", "to_checkpoint_id")) + { + if(table.getFields().containsKey(reference)) + { + table.getField(reference).withPossibleValueSourceName(CHECKPOINT); + } + } + if(table.getFields().containsKey("export_id")) + { + table.getField("export_id").withPossibleValueSourceName(EXPORT); + } + table.withSection(new QFieldSection("record", label, new QIcon("fact_check"), Tier.T1, new ArrayList<>(table.getFields().keySet()))); + if(List.of(CHECKPOINT, EXPORT, VERIFICATION).contains(name)) + { + table.withSection(new QFieldSection("links", new QIcon("link"), Tier.T2).withLabel("Chain links").withWidgetName(name + "_links")); + } + return (table); + } +} diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/processes/ExportProcesses.java b/src/main/java/com/kingsrook/qbits/wormaudit/processes/ExportProcesses.java new file mode 100644 index 0000000..8e760bc --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/processes/ExportProcesses.java @@ -0,0 +1,376 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.processes; + + +import java.io.Serializable; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.wormaudit.WormAuditQBitConfig; +import com.kingsrook.qbits.wormaudit.chain.AuditStore; +import com.kingsrook.qbits.wormaudit.export.SealedExportVerifier; +import com.kingsrook.qbits.wormaudit.export.SealedExporter; +import com.kingsrook.qbits.wormaudit.export.SealedStore; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qbits.wormaudit.verify.WormAuditVerifier; +import com.kingsrook.qqq.backend.core.actions.permissions.UseOtherPermissionNameCustomPermissionChecker; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.BackendStepPostRunInput; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.BackendStepPostRunOutput; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; + + +/******************************************************************************* + ** Standard Review/Result actions for sealing and verifying exports. + *******************************************************************************/ +public final class ExportProcesses +{ + public static final String EXPORT = "worm_audit_export_now"; + public static final String VERIFY = "worm_audit_verify_export"; + public static final String DAILY = "worm_audit_export_daily"; + public static final String PERMISSION = "worm_audit.export"; + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ExportProcesses() + { + } + + + /******************************************************************************* + ** Export is a table action; verification requires one or more selected exports. + *******************************************************************************/ + public static List processes(WormAuditQBitConfig config) + { + Map values = new HashMap<>(); + values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, WormAuditTables.EXPORT); + values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, WormAuditTables.EXPORT); + values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true); + QProcessMetaData export = StreamedETLWithFrontendProcess.defineProcessMetaData(ExportExtract.class, ExportReview.class, ExportLoad.class, new HashMap<>(values)) + .withName(EXPORT).withLabel("Export now").withTableName(WormAuditTables.EXPORT).withPermissionRules(permission(PERMISSION)); + QProcessMetaData verify = StreamedETLWithFrontendProcess.defineProcessMetaData(VerifyExtract.class, VerifyReview.class, VerifyLoad.class, new HashMap<>(values)) + .withName(VERIFY).withLabel("Verify sealed export").withTableName(WormAuditTables.EXPORT).withMinInputRecords(1) + .withPermissionRules(permission(WormAuditProcesses.PERMISSION)); + QProcessMetaData daily = new QProcessMetaData().withName(DAILY).withLabel("Daily sealed audit export") + .withStep(new QBackendStepMetaData().withName("export").withCode(new QCodeReference(Daily.class))) + .withPermissionRules(permission(PERMISSION)).withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()) + .withCronExpression(config.getExportCron()).withCronTimeZoneId("UTC")); + return (List.of(export, verify, daily)); + } + + + /******************************************************************************* + ** Reuse the phase (a) explicit permission-name checker. + *******************************************************************************/ + private static QPermissionRules permission(String name) + { + return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION) + .withCustomPermissionChecker(UseOtherPermissionNameCustomPermissionChecker.build(name))); + } + + + /******************************************************************************* + ** Plain text summaries must not acquire the framework's default zero count. + *******************************************************************************/ + private static ProcessSummaryLine text(Status status, String message) + { + ProcessSummaryLine line = new ProcessSummaryLine(status, message); + line.setCount(null); + return (line); + } + + + /******************************************************************************* + ** One synthetic work item allows the first export on an empty export table. + *******************************************************************************/ + public static class ExportExtract extends AbstractExtractStep + { + /******************************************************************************* + ** Count the single export operation, not previous export rows. + *******************************************************************************/ + @Override + public Integer doCount(RunBackendStepInput input) + { + return (1); + } + + + /******************************************************************************* + ** Feed one work item through the standard stream. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + getRecordPipe().addRecord(new QRecord().withValue("id", 0L)); + } + } + + + /******************************************************************************* + ** Review is read-only; the writer reselects the suffix after confirmation. + *******************************************************************************/ + public static class ExportReview extends AbstractTransformStep implements ProcessSummaryProviderInterface + { + private String message; + private boolean failed; + + /******************************************************************************* + ** Read the planned range once without uploading or inserting. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + try + { + message = SealedExporter.scope(new AuditStore(null)).review(); + failed = false; + } + catch(QException e) + { + message = "Cannot select a contiguous export range"; + failed = true; + } + output.getRecords().addAll(input.getRecords()); + } + + + /******************************************************************************* + ** Show the range, including the explicit no-checkpoint case. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + return (new ArrayList<>(List.of(text(failed ? Status.ERROR : Status.INFO, message == null ? "Nothing to seal: no new checkpoint" : message)))); + } + } + + + /******************************************************************************* + ** Seal once after the stream is complete. + *******************************************************************************/ + public static class ExportLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface + { + private final SealedStore storage; + private SealedExporter.Result result; + + /******************************************************************************* + ** Framework constructor. + *******************************************************************************/ + public ExportLoad() + { + this(null); + } + + /******************************************************************************* + ** Storage-injected action for unit tests. + *******************************************************************************/ + public ExportLoad(SealedStore storage) + { + this.storage = storage; + } + + + /******************************************************************************* + ** Forward the work item without retaining content. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) + { + output.getRecords().addAll(input.getRecords()); + } + + + /******************************************************************************* + ** The same execution path as the daily schedule. + *******************************************************************************/ + @Override + public void postRun(BackendStepPostRunInput input, BackendStepPostRunOutput output) + { + result = new SealedExporter(VerificationRuns.config(), storage).run("USER", Instant.now()); + } + + + /******************************************************************************* + ** Result text is value-free even when the provider fails. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + return (result == null ? new ArrayList<>() : new ArrayList<>(List.of(text(result.failed() ? Status.ERROR : Status.OK, result.message())))); + } + } + + + /******************************************************************************* + ** Respect core's selected-record/filter interpretation; no implicit all-export run. + *******************************************************************************/ + public static class VerifyExtract extends ExtractViaQueryStep + { + /******************************************************************************* + ** Deterministic selection with full text fields from the export table. + *******************************************************************************/ + @Override + public QQueryFilter getQueryFilter(RunBackendStepInput input) throws QException + { + input.addValue(FIELD_SOURCE_TABLE, WormAuditTables.EXPORT); + QQueryFilter filter = super.getQueryFilter(input).clone(); + filter.setOrderBys(List.of(new QFilterOrderBy("id", true))); + return (filter); + } + } + + + /******************************************************************************* + ** Review selected exports without reading their content. + *******************************************************************************/ + public static class VerifyReview extends AbstractTransformStep implements ProcessSummaryProviderInterface + { + private long count; + + /******************************************************************************* + ** Count and forward the selected export records. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) + { + count += input.getRecords().size(); + output.getRecords().addAll(input.getRecords()); + } + + + /******************************************************************************* + ** One Review summary. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + return (new ArrayList<>(List.of(text(Status.INFO, WormAuditVerifier.number(count) + " sealed exports will be verified")))); + } + } + + + /******************************************************************************* + ** Verify each selected export and persist its independent result. + *******************************************************************************/ + public static class VerifyLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface + { + private final SealedStore storage; + private final ArrayList lines = new ArrayList<>(); + + /******************************************************************************* + ** Framework constructor. + *******************************************************************************/ + public VerifyLoad() + { + this(null); + } + + /******************************************************************************* + ** Storage-injected action for unit tests. + *******************************************************************************/ + public VerifyLoad(SealedStore storage) + { + this.storage = storage; + } + + + /******************************************************************************* + ** Process the current page without retaining exported content. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + for(QRecord selected : input.getRecords()) + { + QRecord row = new AuditStore(null).get(WormAuditTables.EXPORT, selected.getValueLong("id")); + if(row == null) + { + lines.add(text(Status.ERROR, "Selected export " + selected.getValueLong("id") + " is missing")); + continue; + } + var report = new SealedExportVerifier(VerificationRuns.config(), storage).verify(row, "USER"); + if(report.result().passed()) + { + lines.add(text(Status.OK, WormAuditVerifier.number(report.result().getLinksChecked()) + " links verified in export " + row.getValueLong("id"))); + } + report.result().getBreaks().forEach(b -> lines.add(text(Status.ERROR, b.reason()))); + if(report.purged() > 0 && report.result().passed()) + { + lines.add(text(Status.INFO, WormAuditVerifier.number(report.purged()) + " links verified from the sealed copy only (purged from the live database)")); + } + } + } + + + /******************************************************************************* + ** Exact per-link differences and purged-copy information. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + return (lines); + } + } + + + /******************************************************************************* + ** Daily export sends heartbeat even when sealing fails. + *******************************************************************************/ + public static class Daily implements BackendStep + { + /******************************************************************************* + ** Run the exporter with the installed host configuration. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) + { + var result = new SealedExporter(VerificationRuns.config()).run("SCHEDULE", Instant.now()); + output.addValue("outcome", result.failed() ? "FAIL" : "PASS"); + output.addValue("result", result.message()); + } + } +} + diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/processes/VerificationRuns.java b/src/main/java/com/kingsrook/qbits/wormaudit/processes/VerificationRuns.java new file mode 100644 index 0000000..286e58b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/processes/VerificationRuns.java @@ -0,0 +1,125 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.processes; + + +import java.time.Instant; +import java.util.List; +import java.util.Set; +import com.kingsrook.qbits.wormaudit.WormAuditQBitConfig; +import com.kingsrook.qbits.wormaudit.chain.AuditStore; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qbits.wormaudit.verify.VerificationResult; +import com.kingsrook.qbits.wormaudit.verify.WormAuditVerifier; +import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef; +import com.kingsrook.qqq.backend.core.alerts.AlertSignal; +import com.kingsrook.qqq.backend.core.alerts.QAlerts; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** Verification run persistence and the core alerts SPI. Both UI and schedule + ** write exactly one immutable result row; only schedules change alert state. + *******************************************************************************/ +public final class VerificationRuns +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private VerificationRuns() + { + } + + + /******************************************************************************* + ** Resolve this instance's configuration, never a global cross-instance cache. + *******************************************************************************/ + public static WormAuditQBitConfig config() + { + return (QContext.getQInstance().getQBits().values().stream().map(q -> q.getConfig()).filter(WormAuditQBitConfig.class::isInstance) + .map(WormAuditQBitConfig.class::cast).findFirst().orElseThrow(() -> new IllegalStateException("WORM audit qbit is not installed"))); + } + + + /******************************************************************************* + ** Verify and persist, sending a heartbeat even if a scheduled run errors. + *******************************************************************************/ + public static VerificationResult run(Set selection, String trigger) throws QException + { + boolean scheduled = "SCHEDULE".equals(trigger); + try + { + VerificationResult result = new WormAuditVerifier(config()).verify(selection); + QRecord row = save(result, trigger); + if(scheduled) + { + if(result.passed()) + { + QAlerts.resolve("worm_audit.chain.break", "chain", "Audit chain verification passed"); + } + else + { + QAlerts.raise(new AlertSignal("worm_audit.chain.break").withDedupKey("chain") + .withTitle(result.getBreaks().getFirst().reason()).withRecordRef(new AlertRecordRef(WormAuditTables.VERIFICATION, row.getValue("id")))); + } + } + return (result); + } + finally + { + if(scheduled) + { + QAlerts.heartbeat("worm_audit.verify.daily"); + } + } + } + + + /******************************************************************************* + ** Persist identifiers and a value-free first reason, never audit content. + *******************************************************************************/ + public static QRecord save(VerificationResult result, String trigger) throws QException + { + return (save(result, trigger, null)); + } + + + /******************************************************************************* + ** Append a chain or sealed-export result using the same immutable schema. + *******************************************************************************/ + public static QRecord save(VerificationResult result, String trigger, Long exportId) throws QException + { + String user = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? "system" : QContext.getQSession().getUser().getIdReference(); + QRecord row = new QRecord().withValue("kind", exportId == null ? "CHAIN" : "SEALED_EXPORT").withValue("export_id", exportId).withValue("trigger", trigger) + .withValue("started_at", result.getStartedAt()).withValue("finished_at", Instant.now()) + .withValue("from_link_id", result.getFromLinkId()).withValue("to_link_id", result.getToLinkId()) + .withValue("links_checked", result.getLinksChecked()).withValue("outcome", result.passed() ? "PASS" : "FAIL") + .withValue("break_count", result.getBreakCount()).withValue("run_by", user); + if(!result.passed()) + { + var first = result.getBreaks().getFirst(); + row.withValue("first_break_link_id", first.linkId()).withValue("first_break_audit_id", first.auditId()).withValue("first_break_reason", first.reason()); + } + return (new AuditStore(null).insert(WormAuditTables.VERIFICATION, List.of(row)).getFirst()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/processes/WormAuditProcesses.java b/src/main/java/com/kingsrook/qbits/wormaudit/processes/WormAuditProcesses.java new file mode 100644 index 0000000..c610e37 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/processes/WormAuditProcesses.java @@ -0,0 +1,312 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.processes; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.wormaudit.WormAuditQBitConfig; +import com.kingsrook.qbits.wormaudit.chain.AuditStore; +import com.kingsrook.qbits.wormaudit.chain.WormAuditChainer; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qbits.wormaudit.verify.VerificationResult; +import com.kingsrook.qbits.wormaudit.verify.WormAuditVerifier; +import com.kingsrook.qqq.backend.core.actions.permissions.UseOtherPermissionNameCustomPermissionChecker; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.BackendStepPostRunInput; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.BackendStepPostRunOutput; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.CouldNotFindQueryFilterForExtractStepException; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; + + +/******************************************************************************* + ** Standard streamed ETL Review/Result action plus two scheduled-only processes. + ** No process is an app navigation entry. + *******************************************************************************/ +public final class WormAuditProcesses +{ + public static final String VERIFY = "worm_audit_verify_chain"; + public static final String CHAIN = "worm_audit_chain_tick"; + public static final String DAILY = "worm_audit_verify_daily"; + public static final String PERMISSION = "worm_audit.verify"; + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private WormAuditProcesses() + { + } + + + /******************************************************************************* + ** Metadata for table/record verification and scheduled background work. + *******************************************************************************/ + public static List processes(WormAuditQBitConfig config) + { + Map values = new HashMap<>(); + values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, WormAuditTables.CHAIN); + values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, WormAuditTables.CHAIN); + values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, "Verify live audit content, link continuity and signed checkpoints."); + QProcessMetaData verify = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Review.class, Load.class, values) + .withName(VERIFY).withLabel("Verify audit chain").withTableName(WormAuditTables.CHAIN).withPermissionRules(permission()); + QProcessMetaData chain = new QProcessMetaData().withName(CHAIN).withLabel("Chain settled audit records") + .withStep(new QBackendStepMetaData().withName("chain").withCode(new QCodeReference(ChainTick.class))) + .withPermissionRules(permission()).withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()) + .withRepeatSeconds(config.getChainIntervalSeconds()).withInitialDelaySeconds(config.getChainIntervalSeconds())); + QProcessMetaData daily = new QProcessMetaData().withName(DAILY).withLabel("Daily audit chain verification") + .withStep(new QBackendStepMetaData().withName("verify").withCode(new QCodeReference(Daily.class))) + .withPermissionRules(permission()).withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()) + .withCronExpression(config.getVerifyCron()).withCronTimeZoneId("UTC")); + return (List.of(verify, chain, daily)); + } + + + /******************************************************************************* + ** One explicit permission, not process-name access suffixes. + *******************************************************************************/ + private static QPermissionRules permission() + { + return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION) + .withCustomPermissionChecker(UseOtherPermissionNameCustomPermissionChecker.build(PERMISSION))); + } + + + /******************************************************************************* + ** Use core selection/filter interpretation, with a full-chain default. An + ** empty frontend filter means a full run, so completeness is still checked. + *******************************************************************************/ + public static class Extract extends ExtractViaQueryStep + { + /******************************************************************************* + ** Extract links in id order and make the effective scope available to Load. + *******************************************************************************/ + @Override + public QQueryFilter getQueryFilter(RunBackendStepInput input) throws QException + { + input.addValue(FIELD_SOURCE_TABLE, WormAuditTables.CHAIN); + QQueryFilter filter; + try + { + filter = super.getQueryFilter(input).clone(); + } + catch(CouldNotFindQueryFilterForExtractStepException expected) + { + filter = new QQueryFilter(); + } + filter.setOrderBys(List.of(new QFilterOrderBy("id", true))); + return (filter); + } + } + + + /******************************************************************************* + ** Review counts the streamed selection without verifying or writing rows. + *******************************************************************************/ + public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface + { + private long count; + private long min = Long.MAX_VALUE; + private long max = Long.MIN_VALUE; + private Long checkpoints; + + /******************************************************************************* + ** Accumulate the scope and forward links to the loader. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + for(QRecord record : input.getRecords()) + { + count++; + min = Math.min(min, record.getValueLong("id")); + max = Math.max(max, record.getValueLong("id")); + } + checkpoints = null; + output.getRecords().addAll(input.getRecords()); + } + + + /******************************************************************************* + ** Standard Review line, including the link range and checkpoint count. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + if(checkpoints == null) + { + try + { + CountInput input = new CountInput(WormAuditTables.CHECKPOINT).withFilter(new QQueryFilter( + new QFilterCriteria("from_link_id", QCriteriaOperator.LESS_THAN_OR_EQUALS, max), + new QFilterCriteria("to_link_id", QCriteriaOperator.GREATER_THAN_OR_EQUALS, min))); + input.withPermissionBypass("WORM audit internal review"); + checkpoints = count == 0 ? 0L : new CountAction().execute(input).getCount().longValue(); + } + catch(QException e) + { + return (new ArrayList<>(List.of(new ProcessSummaryLine(Status.ERROR, "Cannot count overlapping audit checkpoints")))); + } + } + String range = count == 0 ? "none" : WormAuditVerifier.number(min) + "–" + WormAuditVerifier.number(max); + return (new ArrayList<>(List.of(new ProcessSummaryLine(Status.INFO, WormAuditVerifier.number(count) + + " chain links will be verified (links " + range + ", " + WormAuditVerifier.number(checkpoints) + " checkpoints)")))); + } + } + + + /******************************************************************************* + ** Load collects selected ids, then performs one snapshot verification in + ** postRun and appends one result row, including for an empty chain. + *******************************************************************************/ + public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface + { + private Set ids; + private VerificationResult result; + + /******************************************************************************* + ** Retain ids only, not audit content, across streamed pages. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + if(selected(new Extract().getQueryFilter(input))) + { + if(ids == null) + { + ids = new HashSet<>(); + } + input.getRecords().forEach(r -> ids.add(r.getValueLong("id"))); + } + output.getRecords().addAll(input.getRecords()); + } + + + /******************************************************************************* + ** Run once after all pages. Explicit filters are selection runs. + *******************************************************************************/ + @Override + public void postRun(BackendStepPostRunInput input, BackendStepPostRunOutput output) throws QException + { + QQueryFilter filter = new Extract().getQueryFilter(input); + if(selected(filter) && ids == null) + { + // Some callers supply the effective selection only to postRun. + ids = new HashSet<>(); + new AuditStore(null).query(WormAuditTables.CHAIN, filter).forEach(r -> ids.add(r.getValueLong("id"))); + } + result = VerificationRuns.run(selected(filter) ? ids : null, "USER"); + } + + + /******************************************************************************* + ** Empty filters preserve full completeness verification and retain no ids. + *******************************************************************************/ + private boolean selected(QQueryFilter filter) + { + return ((filter.getCriteria() != null && !filter.getCriteria().isEmpty()) + || (filter.getSubFilters() != null && !filter.getSubFilters().isEmpty()) + || filter.getLimit() != null || (filter.getSkip() != null && filter.getSkip() > 0)); + } + + + /******************************************************************************* + ** One ERROR per exact break; warnings remain visible on a passing run. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList lines = new ArrayList<>(); + if(result == null) + { + return (lines); + } + if(result.passed()) + { + lines.add(new ProcessSummaryLine(Status.OK, WormAuditVerifier.number(result.getLinksChecked()) + " links verified")); + } + result.getBreaks().forEach(b -> lines.add(new ProcessSummaryLine(Status.ERROR, b.reason()))); + result.getWarnings().forEach(w -> lines.add(new ProcessSummaryLine(Status.WARNING, w))); + return (lines); + } + } + + + /******************************************************************************* + ** Scheduled writer tick. + *******************************************************************************/ + public static class ChainTick implements BackendStep + { + /******************************************************************************* + ** Append settled work under the shared writer lock. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + output.addValue("linksChained", new WormAuditChainer(VerificationRuns.config()).chainOnce().links()); + } + } + + + /******************************************************************************* + ** Daily verification, including alerts and heartbeat. + *******************************************************************************/ + public static class Daily implements BackendStep + { + /******************************************************************************* + ** Verify the full settled chain. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + output.addValue("outcome", VerificationRuns.run(null, "SCHEDULE").passed() ? "PASS" : "FAIL"); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/verify/VerificationResult.java b/src/main/java/com/kingsrook/qbits/wormaudit/verify/VerificationResult.java new file mode 100644 index 0000000..f2e8619 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/verify/VerificationResult.java @@ -0,0 +1,165 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.verify; + + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + + +/******************************************************************************* + ** Ordered, value-free findings. Break identifiers are separately available for + ** record navigation and alert correlation without parsing human-readable text. + *******************************************************************************/ +public class VerificationResult +{ + /******************************************************************************* + ** One break; null ids apply to coverage and checkpoint findings. + *******************************************************************************/ + public record Break(Long linkId, Long auditId, String reason) + { + } + + private final List breaks = new ArrayList<>(); + private final List warnings = new ArrayList<>(); + private final Instant startedAt = Instant.now(); + private long breakCount; + private long linksChecked; + private Long fromLinkId; + private Long toLinkId; + + /******************************************************************************* + ** Add a finding in deterministic verification order. + *******************************************************************************/ + public void add(Long linkId, Long auditId, String reason) + { + breakCount++; + if(breaks.size() < 1000) + { + breaks.add(new Break(linkId, auditId, reason)); + } + } + + + /******************************************************************************* + ** Count additional SQL offenders without retaining their content. + *******************************************************************************/ + public void omitted(long count) + { + breakCount += count; + } + + + /******************************************************************************* + ** Total findings, including diagnostics omitted by the bounded display limit. + *******************************************************************************/ + public long getBreakCount() + { + return (breakCount); + } + + + /******************************************************************************* + ** Keep warnings bounded even when many checkpoint ranges have been purged. + *******************************************************************************/ + public void warn(String warning) + { + if(warnings.size() < 100) + { + warnings.add(warning); + } + } + + + /******************************************************************************* + ** Record the scope that was checked. + *******************************************************************************/ + public void scope(long count, Long from, Long to) + { + linksChecked = count; + fromLinkId = from; + toLinkId = to; + } + + + /******************************************************************************* + ** Ordered findings. + *******************************************************************************/ + public List getBreaks() + { + return (List.copyOf(breaks)); + } + + + /******************************************************************************* + ** Warnings do not break the chain. + *******************************************************************************/ + public List getWarnings() + { + return (warnings); + } + + + /******************************************************************************* + ** Whether all checked invariants hold. + *******************************************************************************/ + public boolean passed() + { + return (breakCount == 0); + } + + + /******************************************************************************* + ** Number of checked links. + *******************************************************************************/ + public long getLinksChecked() + { + return (linksChecked); + } + + + /******************************************************************************* + ** First checked link, null for an empty scope. + *******************************************************************************/ + public Long getFromLinkId() + { + return (fromLinkId); + } + + + /******************************************************************************* + ** Last checked link, null for an empty scope. + *******************************************************************************/ + public Long getToLinkId() + { + return (toLinkId); + } + + + /******************************************************************************* + ** Verification start time. + *******************************************************************************/ + public Instant getStartedAt() + { + return (startedAt); + } +} diff --git a/src/main/java/com/kingsrook/qbits/wormaudit/verify/WormAuditVerifier.java b/src/main/java/com/kingsrook/qbits/wormaudit/verify/WormAuditVerifier.java new file mode 100644 index 0000000..0ccd764 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/wormaudit/verify/WormAuditVerifier.java @@ -0,0 +1,477 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit.verify; + + +import java.sql.Connection; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import com.kingsrook.qbits.wormaudit.WormAuditQBitConfig; +import com.kingsrook.qbits.wormaudit.chain.AuditStore; +import com.kingsrook.qbits.wormaudit.chain.Hashes; +import com.kingsrook.qbits.wormaudit.chain.WormAuditChainer; +import com.kingsrook.qbits.wormaudit.checkpoint.Checkpoints; +import com.kingsrook.qbits.wormaudit.guard.AppendOnlyGuard; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSTransaction; + + +/******************************************************************************* + ** Snapshot verification of live content, continuity, exact coverage and signed + ** checkpoints. Selection runs verify predecessor boundaries but deliberately + ** omit global completeness; full runs begin at the latest retention anchor. + *******************************************************************************/ +public class WormAuditVerifier +{ + public static final int PAGE_SIZE = 1000; + private final WormAuditQBitConfig config; + + /******************************************************************************* + ** Bind trust pins and settle horizon. + *******************************************************************************/ + public WormAuditVerifier(WormAuditQBitConfig config) + { + this.config = config; + } + + + /******************************************************************************* + ** Verify all live links and settled coverage. + *******************************************************************************/ + public VerificationResult verify() throws QException + { + return (verify(null)); + } + + + /******************************************************************************* + ** Null means full verification; an empty set is an empty selection. + *******************************************************************************/ + public VerificationResult verify(Set selection) throws QException + { + WormAuditChainer.JVM_LOCK.lock(); + try(QBackendTransaction transaction = QBackendTransaction.openFor(new QueryInput(WormAuditTables.CHAIN))) + { + try + { + if(transaction instanceof RDBMSTransaction rdbms) + { + rdbms.getConnection().setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ); + } + VerificationResult result = verify(new AuditStore(transaction), selection, Instant.now()); + transaction.commit(); + return (result); + } + catch(Exception e) + { + transaction.rollback(); + throw (AppendOnlyGuard.map(e)); + } + } + finally + { + WormAuditChainer.JVM_LOCK.unlock(); + } + } + + + /******************************************************************************* + ** Verify one consistent storage snapshot. + *******************************************************************************/ + private VerificationResult verify(AuditStore store, Set selection, Instant now) throws QException + { + VerificationResult result = new VerificationResult(); + if(config.getSigningKey() == null) + { + result.warn("no signing key configured"); + } + QRecord anchor = store.anchor(); + long anchorId = anchor.getValueLong("anchor_link_id"); + long lastId = anchorId; + String previous = anchor.getValueString("anchor_hash"); + long checked = 0; + Long first = null; + Long last = null; + Set missing = selection == null ? null : new HashSet<>(selection); + CheckpointCursor checkpoints = new CheckpointCursor(store, selection, anchor, result); + while(true) + { + List page = store.page(WormAuditTables.CHAIN, lastId, PAGE_SIZE); + if(page.isEmpty()) + { + break; + } + List selected = page.stream().filter(r -> selection == null || selection.contains(r.getValueLong("id"))).toList(); + Map headers = AuditStore.index(store.in("audit", "id", selected.stream().map(r -> r.getValueLong("audit_id")).toList())); + Set detailIds = new HashSet<>(); + for(QRecord link : selected) + { + try + { + List ids = AuditStore.detailIds(link); + if(ids.size() <= 250) + { + detailIds.addAll(ids); + } + } + catch(IllegalArgumentException e) + { + // Content verification reports malformed coverage with its link id. + } + } + Map details = new HashMap<>(); + List ids = new ArrayList<>(detailIds); + for(int start = 0; start < ids.size(); start += PAGE_SIZE) + { + details.putAll(AuditStore.index(store.in("auditDetail", "id", ids.subList(start, Math.min(start + PAGE_SIZE, ids.size()))))); + } + for(QRecord link : page) + { + long id = link.getValueLong("id"); + long auditId = link.getValueLong("audit_id"); + if(selection == null || selection.contains(id)) + { + if(missing != null) + { + missing.remove(id); + } + checked++; + first = first == null ? id : first; + last = id; + if(selection == null && id != lastId + 1) + { + result.add(id, auditId, "Missing link ids " + number(lastId + 1) + "–" + number(id - 1) + " (gap)"); + } + if(id != lastId + 1 || previous == null || !previous.equals(link.getValueString("prev_hash"))) + { + result.add(id, auditId, "Break at link " + number(id) + " (audit " + number(auditId) + "): prev-hash discontinuity"); + } + verifyContent(store, link, headers.get(auditId), details, result); + } + previous = link.getValueString("link_hash"); + lastId = id; + } + checkpoints.through(lastId); + } + checkpoints.through(Long.MAX_VALUE); + result.scope(checked, first, last); + if(missing != null) + { + missing.stream().sorted().forEach(id -> result.add(id, null, "Missing selected link " + number(id))); + } + if(selection == null) + { + Instant cutoff = now.minusSeconds(config.getSettleHorizonSeconds()); + if(store.isRdbms()) + { + store.completeness(cutoff, result); + } + else + { + completeness(store, store.all(WormAuditTables.CHAIN), cutoff, result); + } + } + return (result); + } + + + /******************************************************************************* + ** Recompute exact stored content. Diagnostics name covered detail ids because + ** v1 stores a document hash, not per-field hashes or prior values. + *******************************************************************************/ + private void verifyContent(AuditStore store, QRecord link, QRecord audit, Map rows, VerificationResult result) throws QException + { + long id = link.getValueLong("id"); + long auditId = link.getValueLong("audit_id"); + if(List.of("kind", "detail_ids", "prev_hash", "link_hash", "chained_at").stream().anyMatch(field -> link.getValue(field) == null)) + { + result.add(id, auditId, "Break at link " + number(id) + " (audit " + number(auditId) + "): malformed link"); + return; + } + if(audit == null) + { + result.add(id, auditId, "Break at link " + number(id) + " (audit " + number(auditId) + "): audit header is missing"); + return; + } + if(audit.getValueInstant("timestamp") == null) + { + result.add(id, auditId, "Break at link " + number(id) + " (audit " + number(auditId) + "): missing audit timestamp"); + return; + } + try + { + List details = AuditStore.detailIds(link); + String kind = link.getValueString("kind"); + if(!List.of("AUDIT", "LATE_DETAIL").contains(kind) || details.size() > 250 || ("LATE_DETAIL".equals(kind) && details.isEmpty())) + { + result.add(id, auditId, "Break at link " + number(id) + ": invalid kind or detail coverage"); + } + if(details.size() > 250) + { + return; + } + Set unique = new HashSet<>(); + for(long detail : details) + { + if(!unique.add(detail)) + { + result.add(id, auditId, "audit_detail " + number(detail) + " is covered twice or more by the chain"); + } + } + String hash = Hashes.link(link.getValueString("prev_hash"), store.canonical(audit, details, rows)); + if(!hash.equals(link.getValueString("link_hash"))) + { + String table = store.name("auditTable", audit.getValueLong("auditTableId")); + result.add(id, auditId, "Break at link " + number(id) + " (audit " + number(auditId) + ", table " + table + ", record " + + audit.getValueString("recordId") + "): content differs from the chained hash" + + (details.isEmpty() ? "" : " (audit_detail " + details.stream().map(WormAuditVerifier::number).collect(java.util.stream.Collectors.joining(", ")) + ")")); + } + } + catch(QException | IllegalArgumentException e) + { + result.add(id, auditId, "Break at link " + number(id) + " (audit " + number(auditId) + "): " + e.getMessage()); + } + } + + + /******************************************************************************* + ** Every settled header and detail must occur once; orphan details also fail. + *******************************************************************************/ + private void completeness(AuditStore store, List links, Instant cutoff, VerificationResult result) throws QException + { + Map headers = new HashMap<>(); + Map details = new HashMap<>(); + for(QRecord link : links) + { + if("AUDIT".equals(link.getValueString("kind"))) + { + headers.merge(link.getValueLong("audit_id"), 1, Integer::sum); + } + try + { + AuditStore.detailIds(link).forEach(id -> details.merge(id, 1, Integer::sum)); + } + catch(IllegalArgumentException e) + { + // Invalid coverage was already reported while checking link content. + } + } + Map audits = new HashMap<>(); + for(QRecord audit : store.all("audit")) + { + long id = audit.getValueLong("id"); + audits.put(id, audit); + if(audit.getValueInstant("timestamp") == null) + { + result.add(null, id, "audit " + number(id) + " has no timestamp"); + } + else if(audit.getValueInstant("timestamp").isBefore(cutoff)) + { + coverage("audit", id, headers.getOrDefault(id, 0), result); + } + } + for(QRecord detail : store.all("auditDetail")) + { + QRecord audit = audits.get(detail.getValueLong("auditId")); + if(audit == null || audit.getValueInstant("timestamp") == null || audit.getValueInstant("timestamp").isBefore(cutoff)) + { + coverage("audit_detail", detail.getValueLong("id"), details.getOrDefault(detail.getValueLong("id"), 0), result); + } + } + } + + + /******************************************************************************* + ** Report coverage without printing any audit content. + *******************************************************************************/ + private void coverage(String table, long id, int count, VerificationResult result) + { + if(count != 1) + { + result.add(null, table.equals("audit") ? id : null, table + " " + number(id) + + (count == 0 ? " is not covered by the chain" : " is covered twice or more by the chain")); + } + } + + + /******************************************************************************* + ** Check pinned signatures even for purged ranges. A partially purged range's + ** Merkle tree requires its sealed export; never claim to recompute it live. + *******************************************************************************/ + private class CheckpointCursor + { + private final AuditStore store; + private final Set selection; + private final QRecord anchor; + private final VerificationResult result; + private long after; + private QRecord next; + + /******************************************************************************* + ** Keep only one checkpoint pending as pages complete. + *******************************************************************************/ + CheckpointCursor(AuditStore store, Set selection, QRecord anchor, VerificationResult result) + { + this.store = store; + this.selection = selection; + this.anchor = anchor; + this.result = result; + } + + + /******************************************************************************* + ** Verify completed ranges, including missing tails at end of the scan. + *******************************************************************************/ + void through(long lastId) throws QException + { + while(true) + { + if(next == null) + { + List rows = store.page(WormAuditTables.CHECKPOINT, after, 1); + if(rows.isEmpty()) + { + return; + } + next = rows.getFirst(); + } + Long to = next.getValueLong("to_link_id"); + if(to != null && to > lastId) + { + return; + } + check(next); + after = next.getValueLong("id"); + next = null; + } + } + + + /******************************************************************************* + ** Re-read at most one capped range of hashes in the same snapshot. + *******************************************************************************/ + private void check(QRecord checkpoint) throws QException + { + String prefix = "checkpoint " + number(checkpoint.getValueLong("id")) + ": "; + if(List.of("from_link_id", "to_link_id", "link_count", "head_hash", "merkle_root", "algorithm", "key_id", "public_key", "signature") + .stream().anyMatch(field -> checkpoint.getValue(field) == null)) + { + result.add(null, null, prefix + "malformed checkpoint"); + return; + } + long from = checkpoint.getValueLong("from_link_id"); + long to = checkpoint.getValueLong("to_link_id"); + long anchorId = anchor.getValueLong("anchor_link_id"); + if(selection != null && selection.stream().noneMatch(id -> id >= from && id <= to)) + { + return; + } + var pin = config.getTrustedPublicKeys().get(checkpoint.getValueString("key_id")); + if(pin == null) + { + result.add(null, null, prefix + "untrusted key"); + } + else if(!Checkpoints.verify(checkpoint, pin)) + { + result.add(null, null, prefix + "signature does not verify"); + } + if(from < 1 || to < from || checkpoint.getValueLong("link_count") != to - from + 1 + || to - from + 1 > config.getCheckpointEveryLinks()) + { + result.add(null, null, prefix + "invalid link range or count"); + return; + } + if(from <= anchorId) + { + QRecord head = to > anchorId ? store.get(WormAuditTables.CHAIN, to) : null; + if(to > anchorId && (head == null || !Objects.equals(head.getValueString("link_hash"), checkpoint.getValueString("head_hash")))) + { + result.add(to, null, prefix + "head mismatch"); + } + if(to == anchorId && !Objects.equals(anchor.getValueString("anchor_hash"), checkpoint.getValueString("head_hash"))) + { + result.add(to, null, prefix + "head mismatch with purge anchor"); + } + result.warn(prefix + "purged range; Merkle proof requires the sealed export"); + return; + } + List hashes = new ArrayList<>(); + long cursor = from - 1; + String head = null; + while(cursor < to) + { + List page = store.page(WormAuditTables.CHAIN, cursor, (int) Math.min(PAGE_SIZE, to - cursor)); + if(page.isEmpty()) + { + break; + } + for(QRecord link : page) + { + cursor = link.getValueLong("id"); + if(cursor <= to) + { + hashes.add(link.getValueString("link_hash")); + } + if(cursor == to) + { + head = link.getValueString("link_hash"); + } + } + } + if(!Objects.equals(head, checkpoint.getValueString("head_hash"))) + { + result.add(to, null, prefix + "head mismatch"); + } + try + { + if(hashes.size() != checkpoint.getValueLong("link_count") || hashes.isEmpty() + || hashes.stream().anyMatch(Objects::isNull) || !Hashes.merkle(hashes).equals(checkpoint.getValueString("merkle_root"))) + { + result.add(null, null, prefix + "merkle mismatch"); + } + } + catch(IllegalArgumentException e) + { + result.add(null, null, prefix + "merkle mismatch"); + } + } + } + + + /******************************************************************************* + ** Stable human-readable ids matching the process summary contract. + *******************************************************************************/ + public static String number(long value) + { + return (String.format(Locale.US, "%,d", value)); + } +} diff --git a/src/main/resources/com/kingsrook/qbits/wormaudit/ddl/postgres/worm-audit.sql b/src/main/resources/com/kingsrook/qbits/wormaudit/ddl/postgres/worm-audit.sql new file mode 100644 index 0000000..343da5e --- /dev/null +++ b/src/main/resources/com/kingsrook/qbits/wormaudit/ddl/postgres/worm-audit.sql @@ -0,0 +1,326 @@ +-- liquibase formatted sql + +-- changeset worm-audit:1 +CREATE TABLE IF NOT EXISTS public.worm_audit_chain ( + id bigint PRIMARY KEY, + kind text NOT NULL, + audit_id bigint NOT NULL, + detail_ids text NOT NULL, + prev_hash text NOT NULL, + link_hash text NOT NULL, + chained_at timestamptz NOT NULL, + CHECK (id > 0), + CHECK (kind IN ('AUDIT', 'LATE_DETAIL')), + CHECK (prev_hash ~ '^[0-9a-f]{64}$' AND link_hash ~ '^[0-9a-f]{64}$'), + CHECK (detail_ids = '' OR (detail_ids ~ '^(0|[1-9][0-9]*)(,(0|[1-9][0-9]*))*$' AND cardinality(string_to_array(detail_ids, ',')) <= 250)) +); + +CREATE TABLE IF NOT EXISTS public.worm_audit_checkpoint ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + from_link_id bigint, + to_link_id bigint, + link_count bigint, + head_hash text, + merkle_root text, + algorithm text, + key_id text, + public_key text, + signature text, + created_at timestamptz +); + +CREATE TABLE IF NOT EXISTS public.worm_audit_export ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + from_link_id bigint, + to_link_id bigint, + from_checkpoint_id bigint, + to_checkpoint_id bigint, + bucket_alias text, + object_prefix text, + object_versions text, + manifest_key text, + manifest_sha256 text, + prev_manifest_sha256 text, + object_count bigint, + byte_count bigint, + lock_mode text, + retain_until timestamptz, + sealed_at timestamptz +); + +CREATE TABLE IF NOT EXISTS public.worm_audit_verification ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + kind text, + "trigger" text, + started_at timestamptz, + finished_at timestamptz, + from_link_id bigint, + to_link_id bigint, + links_checked bigint, + outcome text, + break_count bigint, + first_break_link_id bigint, + first_break_audit_id bigint, + first_break_reason text, + export_id bigint, + run_by text +); + +CREATE TABLE IF NOT EXISTS public.worm_audit_purge ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + requested_at timestamptz, + requested_by text, + cutoff timestamptz, + retention_interval text, + outcome text, + reason text, + audits_deleted bigint, + details_deleted bigint, + links_deleted bigint, + anchor_link_id bigint, + anchor_hash text +); + +CREATE UNIQUE INDEX IF NOT EXISTS worm_audit_chain_audit ON public.worm_audit_chain(audit_id) WHERE kind = 'AUDIT'; +CREATE INDEX IF NOT EXISTS worm_audit_chain_audit_all ON public.worm_audit_chain(audit_id); + +-- changeset worm-audit:2 splitStatements:false +CREATE OR REPLACE FUNCTION public.worm_audit_reject() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp SET timezone = 'UTC' AS $$ +DECLARE + old_time timestamptz; + function_owner name; + stack text; + row_identity text := 'all'; +BEGIN + IF TG_OP = 'DELETE' AND current_setting('worm_audit.purging', true) = 'on' THEN + SELECT pg_get_userbyid(proowner) INTO function_owner FROM pg_proc + WHERE oid = 'public.worm_audit_purge(timestamptz)'::regprocedure; + GET DIAGNOSTICS stack = PG_CONTEXT; + IF current_user = function_owner AND position('function worm_audit_purge(timestamp with time zone)' in stack) > 0 THEN + IF TG_RELID = '${audit}'::regclass THEN + old_time := OLD.timestamp; + ELSIF TG_RELID = '${audit_detail}'::regclass THEN + SELECT timestamp INTO old_time FROM ${audit} WHERE id = OLD.audit_id; + ELSIF TG_RELID = 'public.worm_audit_chain'::regclass THEN + SELECT timestamp INTO old_time FROM ${audit} WHERE id = OLD.audit_id; + END IF; + IF old_time < now() - interval '${retention}' THEN + RETURN OLD; + END IF; + END IF; + END IF; + IF TG_LEVEL = 'ROW' THEN + row_identity := OLD.id::text; + END IF; + RAISE EXCEPTION USING ERRCODE = 'P0001', + MESSAGE = format('worm_audit: audit rows are write-once; %s of "%s" is not allowed (id=%s). Expired rows are removed only by worm_audit_purge().', + TG_OP, TG_TABLE_NAME, row_identity), + HINT = 'see qbit-worm-audit docs/DESIGN.md section 4'; +END; +$$; + +-- changeset worm-audit:3 splitStatements:false +CREATE OR REPLACE FUNCTION public.worm_audit_lookup_reject() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$ +BEGIN + IF TG_OP = 'UPDATE' AND NEW.id IS NOT DISTINCT FROM OLD.id AND NEW.name IS NOT DISTINCT FROM OLD.name THEN + RETURN NEW; + END IF; + RAISE EXCEPTION USING ERRCODE = 'P0001', + MESSAGE = format('worm_audit: audit rows are write-once; %s of "%s" is not allowed (id=%s). Expired rows are removed only by worm_audit_purge().', + TG_OP, TG_TABLE_NAME, OLD.id), + HINT = 'see qbit-worm-audit docs/DESIGN.md section 4'; +END; +$$; + +-- changeset worm-audit:4 splitStatements:false +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'worm_audit_purger') THEN + CREATE ROLE worm_audit_purger NOLOGIN; + END IF; +END; +$$; + +-- changeset worm-audit:5 splitStatements:false +CREATE OR REPLACE FUNCTION public.worm_audit_purge(p_cutoff timestamptz) RETURNS void +LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public, pg_temp SET timezone = 'UTC' AS $$ +DECLARE + boundary bigint; + smaller bigint; + anchor text; + audits_count bigint; + details_count bigint; + links_count bigint; +BEGIN + IF p_cutoff IS NULL OR p_cutoff > now() - interval '${retention}' THEN + INSERT INTO public.worm_audit_purge(requested_at, requested_by, cutoff, retention_interval, outcome, reason) + VALUES(now(), session_user, p_cutoff, '${retention}', 'REFUSED', 'Cutoff is within retention or null'); + RETURN; + END IF; + -- Same lock as the Java chainer; table locks also exclude concurrent audit inserts. + PERFORM pg_advisory_xact_lock(86753090421); + LOCK TABLE ${audit}, ${audit_detail}, public.worm_audit_chain IN SHARE ROW EXCLUSIVE MODE; + SELECT coalesce(min(c.id) - 1, (SELECT max(id) FROM public.worm_audit_chain)) INTO boundary + FROM public.worm_audit_chain c LEFT JOIN ${audit} a ON a.id = c.audit_id + WHERE a.id IS NULL OR a.timestamp IS NULL OR a.timestamp >= p_cutoff; + -- Shrink to a prefix closed over every audit's links AND all its stored details. + LOOP + SELECT min(c.id) - 1 INTO smaller FROM public.worm_audit_chain c + WHERE c.id <= boundary AND ( + EXISTS (SELECT FROM public.worm_audit_chain later WHERE later.audit_id = c.audit_id AND later.id > boundary) + OR EXISTS (SELECT FROM ${audit_detail} d WHERE d.audit_id = c.audit_id AND NOT EXISTS ( + SELECT FROM public.worm_audit_chain covered WHERE covered.audit_id = c.audit_id + AND d.id::text = ANY(string_to_array(covered.detail_ids, ','))))); + EXIT WHEN smaller IS NULL; + boundary := smaller; + END LOOP; + SELECT link_hash INTO anchor FROM public.worm_audit_chain WHERE id = boundary; + IF anchor IS NULL THEN + INSERT INTO public.worm_audit_purge(requested_at, requested_by, cutoff, retention_interval, outcome, reason) + VALUES(now(), session_user, p_cutoff, '${retention}', 'REFUSED', 'No complete expired chain prefix'); + RETURN; + END IF; + PERFORM set_config('worm_audit.purging', 'on', true); + DELETE FROM ${audit_detail} d WHERE EXISTS + (SELECT FROM public.worm_audit_chain c WHERE c.audit_id = d.audit_id AND c.id <= boundary); + GET DIAGNOSTICS details_count = ROW_COUNT; + -- Keep ids locally because links must be deleted before their audit headers. + WITH removed AS (DELETE FROM public.worm_audit_chain WHERE id <= boundary RETURNING audit_id), + removed_audits AS (DELETE FROM ${audit} WHERE id IN (SELECT audit_id FROM removed) RETURNING id) + SELECT (SELECT count(*) FROM removed), (SELECT count(*) FROM removed_audits) INTO links_count, audits_count; + PERFORM set_config('worm_audit.purging', 'off', true); + INSERT INTO public.worm_audit_purge(requested_at, requested_by, cutoff, retention_interval, outcome, + audits_deleted, details_deleted, links_deleted, anchor_link_id, anchor_hash) + VALUES(now(), session_user, p_cutoff, '${retention}', 'PURGED', audits_count, details_count, links_count, boundary, anchor); +END; +$$; + +-- changeset worm-audit:6 +DROP TRIGGER IF EXISTS worm_audit_row_guard ON ${audit}; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON ${audit} + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON ${audit}; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON ${audit} + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON ${audit} FROM ${app_role}; +GRANT SELECT, INSERT ON ${audit} TO ${app_role}; + +DROP TRIGGER IF EXISTS worm_audit_row_guard ON ${audit_detail}; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON ${audit_detail} + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON ${audit_detail}; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON ${audit_detail} + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON ${audit_detail} FROM ${app_role}; +GRANT SELECT, INSERT ON ${audit_detail} TO ${app_role}; + +DROP TRIGGER IF EXISTS worm_audit_row_guard ON public.worm_audit_chain; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON public.worm_audit_chain + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON public.worm_audit_chain; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON public.worm_audit_chain + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON public.worm_audit_chain FROM ${app_role}; +GRANT SELECT, INSERT ON public.worm_audit_chain TO ${app_role}; + +DROP TRIGGER IF EXISTS worm_audit_row_guard ON public.worm_audit_checkpoint; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON public.worm_audit_checkpoint + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON public.worm_audit_checkpoint; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON public.worm_audit_checkpoint + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON public.worm_audit_checkpoint FROM ${app_role}; +GRANT SELECT, INSERT ON public.worm_audit_checkpoint TO ${app_role}; + +DROP TRIGGER IF EXISTS worm_audit_row_guard ON public.worm_audit_export; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON public.worm_audit_export + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON public.worm_audit_export; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON public.worm_audit_export + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON public.worm_audit_export FROM ${app_role}; +GRANT SELECT, INSERT ON public.worm_audit_export TO ${app_role}; + +DROP TRIGGER IF EXISTS worm_audit_row_guard ON public.worm_audit_verification; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON public.worm_audit_verification + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON public.worm_audit_verification; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON public.worm_audit_verification + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON public.worm_audit_verification FROM ${app_role}; +GRANT SELECT, INSERT ON public.worm_audit_verification TO ${app_role}; + +DROP TRIGGER IF EXISTS worm_audit_row_guard ON public.worm_audit_purge; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON public.worm_audit_purge + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON public.worm_audit_purge; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON public.worm_audit_purge + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON public.worm_audit_purge FROM ${app_role}; +GRANT SELECT, INSERT ON public.worm_audit_purge TO ${app_role}; + +DROP TRIGGER IF EXISTS worm_audit_row_guard ON ${audit_table}; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON ${audit_table} + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_lookup_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON ${audit_table}; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON ${audit_table} + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON ${audit_table} FROM ${app_role}; +GRANT SELECT, INSERT ON ${audit_table} TO ${app_role}; + +DROP TRIGGER IF EXISTS worm_audit_row_guard ON ${audit_user}; +CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON ${audit_user} + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_lookup_reject(); +DROP TRIGGER IF EXISTS worm_audit_truncate_guard ON ${audit_user}; +CREATE TRIGGER worm_audit_truncate_guard BEFORE TRUNCATE ON ${audit_user} + FOR EACH STATEMENT EXECUTE FUNCTION public.worm_audit_reject(); +REVOKE UPDATE, DELETE, TRUNCATE ON ${audit_user} FROM ${app_role}; +GRANT SELECT, INSERT ON ${audit_user} TO ${app_role}; + +-- Lookups may refresh display metadata, but never identities or names. +GRANT UPDATE (label, create_date, modify_date) ON ${audit_table} TO ${app_role}; +GRANT UPDATE (create_date, modify_date) ON ${audit_user} TO ${app_role}; + +REVOKE ALL ON FUNCTION public.worm_audit_purge(timestamptz) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.worm_audit_purge(timestamptz) FROM ${app_role}; +GRANT EXECUTE ON FUNCTION public.worm_audit_purge(timestamptz) TO worm_audit_purger; + +-- changeset worm-audit:7 splitStatements:false +DO $$ +DECLARE + table_name text; + sequence_name text; +BEGIN + FOREACH table_name IN ARRAY ARRAY['${audit}', '${audit_detail}', '${audit_table}', '${audit_user}', + 'public.worm_audit_checkpoint', 'public.worm_audit_export', 'public.worm_audit_verification', 'public.worm_audit_purge'] LOOP + sequence_name := pg_get_serial_sequence(table_name, 'id'); + IF sequence_name IS NOT NULL THEN + EXECUTE format('GRANT USAGE ON SEQUENCE %s TO ${app_role}', sequence_name); + END IF; + END LOOP; +END; +$$; + +-- changeset worm-audit:8 splitStatements:false +CREATE OR REPLACE FUNCTION public.worm_audit_detail_coverage() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$ +BEGIN + -- Serialize even direct SQL appends; the GIN index makes overlap checks indexed. + PERFORM pg_advisory_xact_lock(86753090421); + IF NEW.detail_ids <> '' AND ( + EXISTS (SELECT FROM unnest(string_to_array(NEW.detail_ids, ',')) AS d(id) GROUP BY d.id HAVING count(*) > 1) + OR EXISTS (SELECT FROM public.worm_audit_chain c + WHERE string_to_array(c.detail_ids, ',') && string_to_array(NEW.detail_ids, ','))) THEN + RAISE EXCEPTION USING ERRCODE = '23505', MESSAGE = 'worm_audit: audit detail is already covered by the chain'; + END IF; + RETURN NEW; +END; +$$; + +-- changeset worm-audit:9 +CREATE INDEX IF NOT EXISTS worm_audit_chain_detail_coverage ON public.worm_audit_chain USING gin (string_to_array(detail_ids, ',')); +DROP TRIGGER IF EXISTS worm_audit_detail_coverage_guard ON public.worm_audit_chain; +CREATE TRIGGER worm_audit_detail_coverage_guard BEFORE INSERT ON public.worm_audit_chain + FOR EACH ROW EXECUTE FUNCTION public.worm_audit_detail_coverage(); diff --git a/src/test/java/com/kingsrook/qbits/wormaudit/CanonicalAndCryptoTest.java b/src/test/java/com/kingsrook/qbits/wormaudit/CanonicalAndCryptoTest.java new file mode 100644 index 0000000..27f9553 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/wormaudit/CanonicalAndCryptoTest.java @@ -0,0 +1,204 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPairGenerator; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.wormaudit.canonical.AuditCanonical; +import com.kingsrook.qbits.wormaudit.canonical.Jcs; +import com.kingsrook.qbits.wormaudit.chain.Hashes; +import com.kingsrook.qbits.wormaudit.checkpoint.Checkpoints; +import com.kingsrook.qbits.wormaudit.ddl.WormAuditDdl; +import com.kingsrook.qbits.wormaudit.guard.AppendOnlyGuard; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** Tests and fixtures for canonical and crypto test. + *******************************************************************************/ +class CanonicalAndCryptoTest +{ + /******************************************************************************* + ** Verify jcs ordering escaping and invalid types. + *******************************************************************************/ + @Test + void jcsOrderingEscapingAndInvalidTypes() + { + var value = new LinkedHashMap(); + value.put("\u20ac", "Euro"); + value.put("\ud83d\ude00", "Emoji"); + value.put("\ufb33", "Hebrew"); + value.put("a", null); + String result = Jcs.string(value); + assertThat(result) + .isEqualTo( + "{\"a\":null,\"\u20ac\":\"Euro\",\"\ud83d\ude00\":\"Emoji\",\"\ufb33\":\"Hebrew\"}"); + assertThat(Jcs.string("\"\\\b\f\n\r\t\u0001/é")) + .isEqualTo("\"\\\"\\\\\\b\\f\\n\\r\\t\\u0001/é\""); + assertThat(Jcs.string(List.of(Long.MIN_VALUE, Long.MAX_VALUE))) + .isEqualTo("[-9223372036854775808,9223372036854775807]"); + assertThatThrownBy(() -> Jcs.string(1.5)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Jcs.string("\ud800")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Jcs.string("\udc00")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Jcs.string("\ud800x")) + .isInstanceOf(IllegalArgumentException.class); + } + + /******************************************************************************* + ** Verify exact canonical document. + *******************************************************************************/ + @Test + void exactCanonicalDocument() + { + var audit = + new QRecord() + .withValue("id", 4211L) + .withValue("recordId", 17) + .withValue("message", "Record was Edited") + .withValue("timestamp", Instant.parse("2026-10-13T21:04:05.123456Z")); + var detail = + new QRecord() + .withValue("id", 9001L) + .withValue("fieldName", "title") + .withValue("message", "Changed Title") + .withValue("oldValue", "hmac:v1:test:old") + .withValue("newValue", "hmac:v1:test:new"); + assertThat( + new String( + AuditCanonical.bytes(audit, "cms_page", "james", List.of(detail)), + StandardCharsets.UTF_8)) + .isEqualTo( + "{\"audit\":{\"clientChannel\":null,\"id\":4211,\"message\":\"Record was" + + " Edited\",\"recordId\":\"17\",\"table\":\"cms_page\",\"timestamp\":\"2026-10-13T21:04:05.123456Z\",\"user\":\"james\"},\"details\":[{\"fieldName\":\"title\",\"id\":9001,\"message\":\"Changed" + + " Title\",\"newValue\":\"hmac:v1:test:new\",\"oldValue\":\"hmac:v1:test:old\"}],\"v\":1}"); + assertThat( + new String( + AuditCanonical.bytes( + audit, + null, + null, + List.of( + new QRecord().withValue("id", 2L), + new QRecord().withValue("id", 1L))), + StandardCharsets.UTF_8)) + .containsSubsequence("\"id\":1,", "\"id\":2,"); + } + + /******************************************************************************* + ** Verify independent hash and merkle vectors. + *******************************************************************************/ + @Test + void independentHashAndMerkleVectors() throws Exception + { + assertThat(Hashes.link(Hashes.GENESIS, "{}".getBytes(StandardCharsets.UTF_8))) + .isEqualTo("72f1d7c888d85ce3159bd8bbf38f3046725d3d4d6cef2b9449d9fb4b58cf7a9c"); + String a = "11".repeat(32), b = "22".repeat(32), c = "33".repeat(32); + assertThat(Hashes.merkle(List.of(a))).isEqualTo(a); + assertThat(Hashes.merkle(List.of(a, b, c))) + .isEqualTo("2470b93c7954a79f41c0106caeeeae350559c2af06bd9a9ab203b163951f64f7"); + assertThatThrownBy(() -> Hashes.merkle(List.of())) + .isInstanceOf(IllegalArgumentException.class); + } + + /******************************************************************************* + ** Verify checkpoint pinning and signing. + *******************************************************************************/ + @Test + void checkpointPinningAndSigning() throws Exception + { + var keys = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + var other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + var config = + new WormAuditQBitConfig() + .withSigningKey( + () -> new WormAuditQBitConfig.SigningKey("a", keys.getPrivate())) + .withTrustedPublicKeys(Map.of("a", keys.getPublic())); + var links = + List.of(new QRecord().withValue("id", 1L).withValue("link_hash", "11".repeat(32))); + var checkpoint = Checkpoints.sign(links, config, Instant.now()); + assertThat(Checkpoints.verify(checkpoint, keys.getPublic())).isTrue(); + assertThat(Checkpoints.verify(checkpoint, other.getPublic())).isFalse(); + assertThat(Checkpoints.verify(checkpoint, null)).isFalse(); + checkpoint.setValue("signature", "bad"); + assertThat(Checkpoints.verify(checkpoint, keys.getPublic())).isFalse(); + checkpoint.setValue("algorithm", "RSA"); + assertThat(Checkpoints.verify(checkpoint, keys.getPublic())).isFalse(); + config.withTrustedPublicKeys(Map.of()); + assertThatThrownBy(() -> Checkpoints.sign(links, config, Instant.now())) + .hasMessageContaining("not pinned"); + config.withTrustedPublicKeys(Map.of("a", other.getPublic())); + assertThatThrownBy(() -> Checkpoints.sign(links, config, Instant.now())) + .hasMessageContaining("does not match"); + } + + /******************************************************************************* + ** Verify ddl is reviewed rendering and rejects injection. + *******************************************************************************/ + @Test + void ddlIsReviewedRenderingAndRejectsInjection() throws Exception + { + String sql = WormAuditDdl.postgres(new WormAuditQBitConfig()); + assertThat(sql) + .isEqualTo( + java.nio.file.Files.readString( + java.nio.file.Path.of("docs/ddl/worm-audit-postgres.sql"))); + assertThat(sql).doesNotContain("${"); + assertThat(sql).contains("GET DIAGNOSTICS stack = PG_CONTEXT", "function worm_audit_purge(timestamp with time zone)", "SET timezone = 'UTC'"); + // The documented migration contract deliberately uses physical snake_case columns. + assertThat(sql).contains("OLD.audit_id", "OLD.timestamp", "NEW.name", "GRANT UPDATE (label, create_date, modify_date)"); + assertThat(java.nio.file.Files.readString(java.nio.file.Path.of("README.md"))) + .contains("renders table names only", "audit_detail.audit_id", "audit_user.id/name/create_date/modify_date"); + assertThat( + WormAuditDdl.postgres( + new WormAuditQBitConfig() + .withRetentionYears(7) + .withAppRole("runtime") + .withCoreAuditTableNames(Map.of("audit", "history.audit")))) + .contains("\"history\".\"audit\"", "7 years", "TO \"runtime\""); + assertThatThrownBy(() -> WormAuditDdl.identifier("a; DROP TABLE audit")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + WormAuditDdl.postgres( + new WormAuditQBitConfig().withRetentionYears(0))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> WormAuditDdl.postgres(new WormAuditQBitConfig().withAppRole("a.b"))) + .isInstanceOf(IllegalArgumentException.class); + var sqlError = new java.sql.SQLException("worm_audit: rejected", "P0001"); + assertThat(AppendOnlyGuard.map(new Exception(sqlError))) + .hasMessage(AppendOnlyGuard.MESSAGE); + assertThat( + AppendOnlyGuard.map( + new java.sql.SQLException("ERROR: worm_audit: rejected", "P0001"))) + .hasMessage(AppendOnlyGuard.MESSAGE); + assertThat(AppendOnlyGuard.map(new java.sql.SQLException("other", "P0001"))) + .hasMessage("WORM audit operation failed"); + var original = new com.kingsrook.qqq.backend.core.exceptions.QException("original"); + assertThat(AppendOnlyGuard.map(original)).isSameAs(original); + } +} diff --git a/src/test/java/com/kingsrook/qbits/wormaudit/ChainVerificationTest.java b/src/test/java/com/kingsrook/qbits/wormaudit/ChainVerificationTest.java new file mode 100644 index 0000000..5f34fc9 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/wormaudit/ChainVerificationTest.java @@ -0,0 +1,347 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit; + +import java.security.KeyPairGenerator; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.wormaudit.chain.AuditStore; +import com.kingsrook.qbits.wormaudit.chain.Hashes; +import com.kingsrook.qbits.wormaudit.chain.WormAuditChainer; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qbits.wormaudit.verify.WormAuditVerifier; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.OLD; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.STORE; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.audit; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.core; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.detail; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.init; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.remove; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.tamper; +import static org.assertj.core.api.Assertions.assertThat; + +/******************************************************************************* + ** Tests and fixtures for chain verification test. + *******************************************************************************/ +class ChainVerificationTest +{ + /******************************************************************************* + ** Verify signed. + *******************************************************************************/ + static WormAuditQBitConfig signed() throws Exception + { + var pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + return (new WormAuditQBitConfig() + .withCheckpointEveryLinks(1) + .withSigningKey(() -> new WormAuditQBitConfig.SigningKey("test", pair.getPrivate())) + .withTrustedPublicKeys(Map.of("test", pair.getPublic()))); + } + + /******************************************************************************* + ** Verify late headers details and chunk boundaries. + *******************************************************************************/ + @Test + void lateHeadersDetailsAndChunkBoundaries() throws Exception + { + var config = signed().withBatchSize(1); + init(config); + audit(20); + detail(1, 20); + var writer = new WormAuditChainer(config); + assertThat(writer.chainOnce().audits()).isEqualTo(1); + audit(1); + detail(2, 1); + detail(3, 20); + assertThat(writer.chainOnce().lateDetails()).isEqualTo(1); + for(long i = 4; i <= 260; i++) + { + detail(i, 1); + } + assertThat(writer.chainOnce().lateDetails()).isEqualTo(257); + var links = STORE.all(WormAuditTables.CHAIN); + assertThat(links).extracting(r -> r.getValueLong("id")).containsExactly(1L, 2L, 3L, 4L, 5L); + assertThat(links).allMatch(r -> AuditStore.detailIds(r).size() <= 250); + var result = new WormAuditVerifier(config).verify(); + assertThat(result.getBreaks()).isEmpty(); + assertThat(result.getLinksChecked()).isEqualTo(5); + assertThat(result.getFromLinkId()).isEqualTo(1); + assertThat(result.getToLinkId()).isEqualTo(5); + assertThat(writer.chainOnce().links()).isZero(); + } + + /******************************************************************************* + ** Verify overflow at initial chain and settling and elapsed checkpoint. + *******************************************************************************/ + @Test + void overflowAtInitialChainAndSettlingAndElapsedCheckpoint() throws Exception + { + var config = signed().withCheckpointEveryLinks(10000).withCheckpointEveryMinutes(1); + init(config); + audit(1); + for(long i = 1; i <= 251; i++) + { + detail(i, 1); + } + var writer = new WormAuditChainer(config); + assertThat(writer.chainOnce(OLD.plusSeconds(30)).links()).isZero(); + Instant now = Instant.now(); + assertThat(writer.chainOnce(now).links()).isEqualTo(2); + assertThat(STORE.all(WormAuditTables.CHECKPOINT)).isEmpty(); + assertThat(writer.chainOnce(now.plusSeconds(61)).checkpoints()).isEqualTo(1); + assertThat(new WormAuditVerifier(config).verify().passed()).isTrue(); + config.withSigningKey(null); + assertThat(new WormAuditVerifier(config).verify().getWarnings()) + .contains("no signing key configured"); + } + + /******************************************************************************* + ** Verify detects breaks. + *******************************************************************************/ + @ParameterizedTest + @ValueSource( + strings = + { + "content", + "headerDeleted", + "detailDeleted", + "inserted", + "detailInserted", + "gap", + "previous", + "reorder", + "forged", + "untrusted", + "merkle", + "head", + "duplicate", + "kind", + "lookup", + "range", + "nullCheckpoint", + "nullPrevious", + "nullTimestamp" + }) + void detectsBreaks(String scenario) throws Exception + { + var config = signed(); + init(config); + audit(1); + detail(1, 1); + audit(2); + detail(2, 2); + new WormAuditChainer(config).chainOnce(); + String expected; + switch(scenario) + { + case "content" -> + { + tamper("auditDetail", 1, "newValue", "tampered"); + expected = + "Break at link 1 (audit 1, table cms_page, record 17): content differs from" + + " the chained hash (audit_detail 1)"; + } + case "headerDeleted" -> + { + remove("audit", 1); + expected = "audit header is missing"; + } + case "detailDeleted" -> + { + remove("auditDetail", 1); + expected = "audit_detail 1 is missing"; + } + case "inserted" -> + { + audit(3); + expected = "audit 3 is not covered"; + } + case "detailInserted" -> + { + detail(3, 1); + expected = "audit_detail 3 is not covered"; + } + case "gap" -> + { + remove(WormAuditTables.CHAIN, 1); + expected = "gap"; + } + case "previous" -> + { + tamper(WormAuditTables.CHAIN, 2, "prev_hash", Hashes.GENESIS); + expected = "prev-hash discontinuity"; + } + case "reorder" -> + { + tamper(WormAuditTables.CHAIN, 1, "audit_id", 2L); + expected = "another audit"; + } + case "forged" -> + { + tamper(WormAuditTables.CHECKPOINT, 1, "signature", "AA=="); + expected = "checkpoint 1: signature does not verify"; + } + case "untrusted" -> + { + config.withTrustedPublicKeys(Map.of()); + expected = "untrusted key"; + } + case "merkle" -> + { + tamper(WormAuditTables.CHECKPOINT, 1, "merkle_root", Hashes.GENESIS); + expected = "merkle mismatch"; + } + case "head" -> + { + tamper(WormAuditTables.CHECKPOINT, 1, "head_hash", Hashes.GENESIS); + expected = "head mismatch"; + } + case "duplicate" -> + { + tamper(WormAuditTables.CHAIN, 1, "detail_ids", "1,1"); + expected = "audit_detail 1 is covered twice"; + } + case "kind" -> + { + tamper(WormAuditTables.CHAIN, 1, "kind", "INVALID"); + expected = "invalid kind"; + } + case "lookup" -> + { + tamper("auditTable", 1, "name", "renamed"); + expected = "content differs"; + } + case "nullCheckpoint" -> + { + tamper(WormAuditTables.CHECKPOINT, 1, "signature", null); + expected = "malformed checkpoint"; + } + case "nullPrevious" -> + { + tamper(WormAuditTables.CHAIN, 1, "prev_hash", null); + expected = "malformed link"; + } + case "nullTimestamp" -> + { + tamper("audit", 1, "timestamp", null); + expected = "missing audit timestamp"; + } + default -> + { + tamper(WormAuditTables.CHECKPOINT, 1, "link_count", 22L); + expected = "invalid link range"; + } + } + var result = new WormAuditVerifier(config).verify(); + assertThat(result.passed()).isFalse(); + assertThat(result.getBreaks()).anyMatch(b -> b.reason().contains(expected)); + } + + /******************************************************************************* + ** Verify selection does not run completeness and checks boundary. + *******************************************************************************/ + @Test + void selectionDoesNotRunCompletenessAndChecksBoundary() throws Exception + { + var config = signed(); + init(config); + audit(1); + audit(2); + new WormAuditChainer(config).chainOnce(); + audit(3); + assertThat(new WormAuditVerifier(config).verify(Set.of(2L)).passed()).isTrue(); + assertThat(new WormAuditVerifier(config).verify(Set.of()).getLinksChecked()).isZero(); + assertThat(new WormAuditVerifier(config).verify(Set.of(99L)).getBreaks()) + .anyMatch(b -> b.reason().contains("Missing selected link 99")); + tamper(WormAuditTables.CHAIN, 2, "prev_hash", Hashes.GENESIS); + assertThat(new WormAuditVerifier(config).verify(Set.of(2L)).getBreaks()) + .anyMatch(b -> b.reason().contains("prev-hash")); + } + + /******************************************************************************* + ** Verify continues after purge anchor including empty live chain. + *******************************************************************************/ + @Test + void continuesAfterPurgeAnchorIncludingEmptyLiveChain() throws Exception + { + var config = signed(); + init(config); + audit(1); + detail(1, 1); + audit(2); + new WormAuditChainer(config).chainOnce(); + var first = STORE.get(WormAuditTables.CHAIN, 1); + STORE.insert( + WormAuditTables.PURGE, + List.of( + new com.kingsrook.qqq.backend.core.model.data.QRecord() + .withValue("outcome", "PURGED") + .withValue("anchor_link_id", 1L) + .withValue("anchor_hash", first.getValueString("link_hash")))); + remove("auditDetail", 1); + remove("audit", 1); + remove(WormAuditTables.CHAIN, 1); + assertThat(new WormAuditVerifier(config).verify().passed()).isTrue(); + var last = STORE.get(WormAuditTables.CHAIN, 2); + STORE.insert( + WormAuditTables.PURGE, + List.of( + new com.kingsrook.qqq.backend.core.model.data.QRecord() + .withValue("outcome", "PURGED") + .withValue("anchor_link_id", 2L) + .withValue("anchor_hash", last.getValueString("link_hash")))); + remove("audit", 2); + remove(WormAuditTables.CHAIN, 2); + assertThat(new WormAuditVerifier(config).verify().passed()).isTrue(); + audit(3); + new WormAuditChainer(config).chainOnce(); + assertThat(STORE.all(WormAuditTables.CHAIN)) + .extracting(r -> r.getValueLong("id")) + .containsExactly(3L); + assertThat(new WormAuditVerifier(config).verify().passed()).isTrue(); + } + + /******************************************************************************* + ** Findings retain bounded diagnostics while recording the complete failure count. + *******************************************************************************/ + @Test + void boundedDiagnosticsPreserveTotalCount() throws Exception + { + init(new WormAuditQBitConfig()); + var result = new com.kingsrook.qbits.wormaudit.verify.VerificationResult(); + for(long id = 1; id <= 1200; id++) + { + result.add(id, id, "gap"); + result.warn("warning " + id); + } + result.omitted(20); + assertThat(result.getBreaks()).hasSize(1000); + assertThat(result.getWarnings()).hasSize(100); + assertThat(result.getBreakCount()).isEqualTo(1220); + assertThat(result.passed()).isFalse(); + assertThat(com.kingsrook.qbits.wormaudit.processes.VerificationRuns.save(result, "USER").getValueLong("break_count")).isEqualTo(1220); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/wormaudit/DockerConditionTest.java b/src/test/java/com/kingsrook/qbits/wormaudit/DockerConditionTest.java new file mode 100644 index 0000000..0e0f205 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/wormaudit/DockerConditionTest.java @@ -0,0 +1,43 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** Tests and fixtures for docker condition test. + *******************************************************************************/ +class DockerConditionTest +{ + /******************************************************************************* + ** Verify never skips in ci. + *******************************************************************************/ + @Test + void neverSkipsInCi() + { + assertThat(RequiresDockerCondition.evaluate(false, null).isDisabled()).isTrue(); + assertThat(RequiresDockerCondition.evaluate(true, "true").isDisabled()).isFalse(); + assertThatThrownBy(() -> RequiresDockerCondition.evaluate(false, "false")) + .hasMessageContaining("CI is set"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/wormaudit/MinioExportAcceptanceTest.java b/src/test/java/com/kingsrook/qbits/wormaudit/MinioExportAcceptanceTest.java new file mode 100644 index 0000000..85c4ba0 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/wormaudit/MinioExportAcceptanceTest.java @@ -0,0 +1,183 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit; + + +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig; +import com.kingsrook.qbits.objectstorage.api.Capability; +import com.kingsrook.qbits.objectstorage.api.CapabilityStatus; +import com.kingsrook.qbits.objectstorage.api.ListRequest; +import com.kingsrook.qbits.objectstorage.api.ObjectStorage; +import com.kingsrook.qbits.objectstorage.config.AddressingStyle; +import com.kingsrook.qbits.objectstorage.config.BucketRole; +import com.kingsrook.qbits.objectstorage.config.ChecksumMode; +import com.kingsrook.qbits.objectstorage.config.ProviderType; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig; +import com.kingsrook.qbits.objectstorage.internal.CapabilityProbe; +import com.kingsrook.qbits.objectstorage.internal.CapabilityStore; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.wormaudit.chain.WormAuditChainer; +import com.kingsrook.qbits.wormaudit.export.ExportFiles; +import com.kingsrook.qbits.wormaudit.export.ObjectStorageSealedStore; +import com.kingsrook.qbits.wormaudit.export.SealedExportVerifier; +import com.kingsrook.qbits.wormaudit.export.SealedExporter; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qbits.wormaudit.processes.ExportProcesses; +import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.checksums.RequestChecksumCalculation; +import software.amazon.awssdk.core.checksums.ResponseChecksumValidation; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.S3Exception; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.STORE; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.audit; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.detail; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.init; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.tamper; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Real Object Lock acceptance. Missing Docker skips locally and fails in CI. + *******************************************************************************/ +@ExtendWith(RequiresDockerCondition.class) +class MinioExportAcceptanceTest +{ + /******************************************************************************* + ** Production adapter, COMPLIANCE retention, version preservation and UI findings. + *******************************************************************************/ + @Test + void lockedRoundTripVersionRetentionLiveTamperAndUnsupportedBucket() throws Exception + { + String image = System.getProperty("qqq.test.minioImage", + "cgr.dev/chainguard/minio@sha256:dc455cdd4db6e390b1b26514f15ff37681a777bf8dab41aac445a0259f751e56"); + try(GenericContainer minio = new GenericContainer<>(image) + .withEnv("MINIO_ROOT_USER", "worm-test-access").withEnv("MINIO_ROOT_PASSWORD", "worm-test-secret") + .withCommand("server", "/tmp/minio-data").withExposedPorts(9000) + .waitingFor(Wait.forHttp("/minio/health/live").forPort(9000).withStartupTimeout(Duration.ofMinutes(2)))) + { + minio.start(); + String endpoint = "http://" + minio.getHost() + ":" + minio.getMappedPort(9000); + try(S3Client s3 = S3Client.builder().endpointOverride(URI.create(endpoint)).region(Region.US_EAST_1).forcePathStyle(true) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create("worm-test-access", "worm-test-secret"))) + .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED) + .responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED).build()) + { + s3.createBucket(b -> b.bucket("worm-locked").objectLockEnabledForBucket(true)); + s3.createBucket(b -> b.bucket("worm-plain")); + var config = ChainVerificationTest.signed(); + var instance = init(config); + AlertsInstanceMetaData.ofOrWithNew(instance).withSink(new QCodeReference(ProcessesAndAlertsTest.Sink.class)); + ProcessesAndAlertsTest.Sink.raised.clear(); + System.setProperty("worm.test.access", "worm-test-access"); + System.setProperty("worm.test.secret", "worm-test-secret"); + ObjectStorage.configureStandalone(new ObjectStorageQBitConfig().withBackendName("memory") + .withAllowedSecretRefPatterns(List.of("prop.worm.test.*")) + .withConnection(new StorageConnectionConfig("minio").withProviderType(ProviderType.MINIO).withEndpoint(endpoint) + .withRegion("us-east-1").withAddressingStyle(AddressingStyle.PATH).withChecksumMode(ChecksumMode.WHEN_REQUIRED) + .withAccessKeyRef("${prop.worm.test.access}").withSecretKeyRef("${prop.worm.test.secret}")) + .withBucket(new StorageBucketConfig("worm_audit").withConnection("minio").withBucketName("worm-locked").withRole(BucketRole.BACKUP).withIndexed(false)) + .withBucket(new StorageBucketConfig("plain").withConnection("minio").withBucketName("worm-plain").withRole(BucketRole.BACKUP).withIndexed(false))); + CapabilityProbe.probeConnection("minio", false); + assertThat(ObjectStorage.asSystem("test", "1").bucket("worm_audit").capability(Capability.OBJECT_LOCK)).isEqualTo(CapabilityStatus.SUPPORTED); + assertThat(ObjectStorage.asSystem("test", "1").bucket("plain").capability(Capability.OBJECT_LOCK)).isEqualTo(CapabilityStatus.UNSUPPORTED); + audit(1); + detail(1, 1); + new WormAuditChainer(config).chainOnce(); + Instant now = Instant.now(); + var exported = new SealedExporter(config).run("USER", now); + assertThat(exported.failed()).as(exported.message()).isFalse(); + var row = exported.row(); + var storage = new ObjectStorageSealedStore("worm_audit", "acceptance"); + assertThat(storage.list(ListRequest.prefix(row.getValueString("object_prefix"))).getItems()).hasSize(3); + var verifier = new SealedExportVerifier(config); + assertThat(verifier.verify(row, "USER").result().passed()).isTrue(); + for(String name : ExportFiles.NAMES) + { + String key = row.getValueString("object_prefix") + name; + var head = storage.head(key); + assertThat(head.getLockMode()).isEqualTo("COMPLIANCE"); + assertThat(Duration.between(now.atZone(java.time.ZoneOffset.UTC).plusYears(6).toInstant(), head.getLockRetainUntil()).abs()).isLessThan(Duration.ofSeconds(2)); + String originalVersion = head.getVersionId(); + assertThat(originalVersion).isNotBlank(); + assertThat(ExportFiles.parse(row.getValueString("object_versions").getBytes(java.nio.charset.StandardCharsets.UTF_8))) + .containsEntry(name, originalVersion); + assertThatThrownBy(() -> s3.deleteObject(b -> b.bucket("worm-locked").key(key).versionId(originalVersion))) + .isInstanceOf(S3Exception.class); + byte[] original = s3.getObjectAsBytes(b -> b.bucket("worm-locked").key(key).versionId(originalVersion)).asByteArray(); + // S3 overwrites create new versions; they cannot replace the locked version. + var replacement = s3.putObject(b -> b.bucket("worm-locked").key(key).objectLockMode("COMPLIANCE") + .objectLockRetainUntilDate(row.getValueInstant("retain_until")), RequestBody.fromString("different replacement bytes")); + assertThat(replacement.versionId()).isNotEqualTo(originalVersion); + assertThat(s3.getObjectAsBytes(b -> b.bucket("worm-locked").key(key).versionId(originalVersion)).asByteArray()).isEqualTo(original); + try(var sealed = storage.getVersion(key, originalVersion)) + { + assertThat(sealed.readAllBytes()).isEqualTo(original); + } + var report = verifier.verify(row, "USER"); + assertThat(report.result().passed()).isFalse(); + assertThat(report.result().getLinksChecked()).isEqualTo(1); + assertThat(report.result().getBreaks()).allSatisfy(b -> assertThat(b.reason()).contains("was overwritten after sealing")); + assertThat(report.result().getBreaks().toString()).contains("object " + name + " was overwritten after sealing (current version " + + replacement.versionId() + ", sealed version " + originalVersion + " is still retained under Object Lock)"); + } + tamper("auditDetail", 1, "newValue", "tampered live value"); + var load = new ExportProcesses.VerifyLoad(); + load.runOnePage(new RunBackendStepInput().withRecords(List.of(row)), new RunBackendStepOutput()); + assertThat(load.getProcessSummary(new RunBackendStepOutput(), true).toString()) + .contains("ERROR", "link 1 (audit 1): live content differs from the sealed copy"); + tamper("auditDetail", 1, "newValue", "new"); + audit(2); + new WormAuditChainer(config).chainOnce(); + config.withExportBucketAlias("plain"); + var failed = new SealedExporter(config).run("SCHEDULE", Instant.now()); + assertThat(failed.failed()).isTrue(); + assertThat(failed.message()).contains("does not support Object Lock"); + assertThat(STORE.all(WormAuditTables.EXPORT)).hasSize(1); + assertThat(ProcessesAndAlertsTest.Sink.raised).hasSize(1); + } + finally + { + ObjectStorageRuntime.reset(); + CapabilityStore.clearCache(); + System.clearProperty("worm.test.access"); + System.clearProperty("worm.test.secret"); + } + } + } +} + diff --git a/src/test/java/com/kingsrook/qbits/wormaudit/PhiCanaryTest.java b/src/test/java/com/kingsrook/qbits/wormaudit/PhiCanaryTest.java new file mode 100644 index 0000000..c17be66 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/wormaudit/PhiCanaryTest.java @@ -0,0 +1,206 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import com.kingsrook.qbits.wormaudit.chain.AuditStore; +import com.kingsrook.qbits.wormaudit.chain.WormAuditChainer; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qbits.wormaudit.verify.WormAuditVerifier; +import com.kingsrook.qqq.backend.core.actions.audits.AuditAction; +import com.kingsrook.qqq.backend.core.actions.audits.AuditActionCustomizerInterface; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.audits.AuditInput; +import com.kingsrook.qqq.backend.core.model.actions.audits.AuditSingleInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QSupplementalFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import org.junit.jupiter.api.Test; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.OLD; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.STORE; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.audit; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.core; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.detail; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.init; +import static org.assertj.core.api.Assertions.assertThat; + +/******************************************************************************* + ** Tests and fixtures for phi canary test. + *******************************************************************************/ +class PhiCanaryTest +{ + // RC.5 has AiFieldMetaData but not DataClassification; use a test-local marker. + /******************************************************************************* + ** Tests and fixtures for phi. + *******************************************************************************/ + public static class Phi extends QSupplementalFieldMetaData + { + /******************************************************************************* + ** Verify get type. + *******************************************************************************/ + public String getType() + { + return ("test-phi"); + } + } + + // Real RC.5 AuditAction customization point: rewrite details BEFORE insertion. + /******************************************************************************* + ** Tests and fixtures for token hook. + *******************************************************************************/ + public static class TokenHook implements AuditActionCustomizerInterface + { + /******************************************************************************* + ** Verify customize input. + *******************************************************************************/ + public void customizeInput(AuditSingleInput input) + { + for(QRecord detail : input.getDetails()) + { + var field = + QContext.getQInstance() + .getTable(input.getAuditTableName()) + .getField(detail.getValueString("fieldName")); + if(field.getSupplementalMetaData("test-phi") != null) + { + for(String column : List.of("oldValue", "newValue")) + { + try + { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init( + new SecretKeySpec( + "fixture-key-not-production" + .getBytes(StandardCharsets.UTF_8), + "HmacSHA256")); + detail.setValue( + column, + "hmac:v1:test:" + + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString( + mac.doFinal( + detail.getValueString(column) + .getBytes( + StandardCharsets + .UTF_8)))); + } + catch(Exception e) + { + throw new IllegalStateException(e); + } + } + detail.setValue("message", "Changed Diagnosis"); + } + } + } + } + + /******************************************************************************* + ** Verify token hook keeps every audit and chain value free of canary. + *******************************************************************************/ + @Test + void tokenHookKeepsEveryAuditAndChainValueFreeOfCanary() throws Exception + { + var config = ChainVerificationTest.signed(); + var instance = init(config); + instance.addTable( + new QTableMetaData() + .withName("patient") + .withBackendName("memory") + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField( + new QFieldMetaData("diagnosis", QFieldType.STRING) + .withSupplementalMetaData(new Phi()))); + instance.addSupplementalCustomizer( + AuditActionCustomizerInterface.CUSTOMIZER_TYPE, + new QCodeReference(TokenHook.class)); + String canary = "CANARY-PHI-" + UUID.randomUUID(); + var detail = + new QRecord() + .withValue("fieldName", "diagnosis") + .withValue("oldValue", canary) + .withValue("newValue", canary) + .withValue("message", "Changed Diagnosis from " + canary + " to " + canary); + new AuditAction() + .execute( + new AuditInput() + .withAuditSingleInput( + new AuditSingleInput() + .withAuditTableName("patient") + .withAuditUserName("james") + .withRecordId(17) + .withTimestamp(OLD) + .withMessage("Record was Edited") + .withDetails(List.of(detail)))); + assertThat(STORE.all("audit")).hasSize(1); + assertThat(STORE.all("auditDetail")).hasSize(1); + assertThat(STORE.all("auditDetail").getFirst().getValueString("newValue")) + .startsWith("hmac:v1:test:"); + new WormAuditChainer(config).chainOnce(); + List values = new ArrayList<>(); + for(String table : + List.of( + "audit", + "auditDetail", + WormAuditTables.CHAIN, + WormAuditTables.CHECKPOINT)) + { + STORE.all(table) + .forEach( + r -> + r.getValues().values().stream() + .filter(Objects::nonNull) + .map(Object::toString) + .forEach(values::add)); + } + for(var link : STORE.all(WormAuditTables.CHAIN)) + { + values.add( + new String( + STORE.canonical( + STORE.get("audit", link.getValueLong("audit_id")), + AuditStore.detailIds(link)), + StandardCharsets.UTF_8)); + } + for(String value : values) + { + assertThat(value).doesNotContain(canary); + // Every longer substring contains a 5-character substring. + for(int start = 0; start <= canary.length() - 5; start++) + { + assertThat(value).doesNotContain(canary.substring(start, start + 5)); + } + } + assertThat(new WormAuditVerifier(config).verify().passed()).isTrue(); + } +} diff --git a/src/test/java/com/kingsrook/qbits/wormaudit/PostgresAcceptanceTest.java b/src/test/java/com/kingsrook/qbits/wormaudit/PostgresAcceptanceTest.java new file mode 100644 index 0000000..18ee3a9 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/wormaudit/PostgresAcceptanceTest.java @@ -0,0 +1,552 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import com.kingsrook.qbits.wormaudit.chain.AuditStore; +import com.kingsrook.qbits.wormaudit.chain.WormAuditChainer; +import com.kingsrook.qbits.wormaudit.ddl.WormAuditDdl; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qbits.wormaudit.verify.WormAuditVerifier; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.STORE; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.audit; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.core; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.detail; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.init; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.lookups; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** Tests and fixtures for postgres acceptance test. + *******************************************************************************/ +@ExtendWith(RequiresDockerCondition.class) +@Testcontainers +class PostgresAcceptanceTest +{ + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + + WormAuditQBitConfig config; + QInstance instance; + + /******************************************************************************* + ** Verify owner. + *******************************************************************************/ + Connection owner() throws SQLException + { + return (DriverManager.getConnection( + POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())); + } + + /******************************************************************************* + ** Verify app. + *******************************************************************************/ + Connection app() throws SQLException + { + return (DriverManager.getConnection(POSTGRES.getJdbcUrl(), "app", "fixture")); + } + + /******************************************************************************* + ** Verify sql. + *******************************************************************************/ + void sql(Connection c, String sql) throws SQLException + { + try(var s = c.createStatement()) + { + s.execute(sql); + } + } + + /******************************************************************************* + ** Verify count. + *******************************************************************************/ + long count(Connection c, String sql) throws SQLException + { + try(var s = c.createStatement(); + var r = s.executeQuery(sql)) + { + r.next(); + return (r.getLong(1)); + } + } + + /******************************************************************************* + ** Verify database. + *******************************************************************************/ + @BeforeEach + void database() throws Exception + { + config = ChainVerificationTest.signed().withSettleHorizonSeconds(0); + instance = RdbmsTestSupport.instance(POSTGRES.getJdbcUrl(), "app", "fixture", config, true); + try(var c = owner()) + { + sql( + c, + "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT USAGE ON SCHEMA public" + + " TO PUBLIC"); + sql( + c, + "DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='app') THEN" + + " CREATE ROLE app LOGIN PASSWORD 'fixture'; END IF; END $$"); + RdbmsTestSupport.createTables(c, instance, true); + String ddl = WormAuditDdl.postgres(config); + sql(c, ddl); + sql(c, ddl); + } + QContext.init(instance, new QSession()); + lookups(); + } + + /******************************************************************************* + ** Verify grants triggers lookup rules and spoofed flag. + *******************************************************************************/ + @Test + void grantsTriggersLookupRulesAndSpoofedFlag() throws Exception + { + audit(1); + detail(1, 1); + new WormAuditChainer(config).chainOnce(); + try(var c = owner(); + var runtime = app()) + { + assertThatThrownBy(() -> sql(runtime, + "INSERT INTO worm_audit_chain(id,kind,audit_id,detail_ids,prev_hash,link_hash,chained_at) " + + "SELECT 99,'LATE_DETAIL',audit_id,detail_ids,prev_hash,link_hash,chained_at FROM worm_audit_chain WHERE id=1")) + .isInstanceOf(SQLException.class).satisfies(e -> assertThat(((SQLException) e).getSQLState()).isEqualTo("23505")); + + for(String table : + List.of("worm_audit_export", "worm_audit_verification", "worm_audit_purge")) + { + sql(c, "INSERT INTO " + table + " DEFAULT VALUES"); + } + List tables = + List.of( + "audit", + "audit_detail", + "worm_audit_chain", + "worm_audit_checkpoint", + "worm_audit_export", + "worm_audit_verification", + "worm_audit_purge"); + for(String table : tables) + { + assertThat( + count( + c, + "SELECT has_table_privilege('app','" + + table + + "','UPDATE')::int")) + .isZero(); + assertThat( + count( + c, + "SELECT has_table_privilege('app','" + + table + + "','DELETE')::int")) + .isZero(); + assertThat( + count( + c, + "SELECT has_table_privilege('app','" + + table + + "','TRUNCATE')::int")) + .isZero(); + // PostgreSQL checks grants before firing triggers: an unprivileged app + // receives 42501. Temporarily grant DML to prove the trigger defense too. + assertThatThrownBy(() -> sql(runtime, "UPDATE " + table + " SET id=id")) + .isInstanceOf(SQLException.class) + .satisfies( + e -> + assertThat(((SQLException) e).getSQLState()) + .isEqualTo("42501")); + sql(c, "GRANT UPDATE, DELETE, TRUNCATE ON " + table + " TO app"); + for(String operation : + List.of( + "UPDATE " + table + " SET id=id", + "DELETE FROM " + table, + "TRUNCATE " + table)) + { + assertThatThrownBy(() -> sql(runtime, operation)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("worm_audit:") + .satisfies( + e -> + assertThat(((SQLException) e).getSQLState()) + .isEqualTo("P0001")); + } + } + sql(runtime, "SELECT set_config('worm_audit.purging','on',false)"); + assertThatThrownBy(() -> sql(runtime, "DELETE FROM audit")) + .hasMessageContaining("worm_audit:"); + sql(runtime, "UPDATE audit_table SET label='Allowed label'"); + sql(runtime, "UPDATE audit_user SET modify_date=now()"); + for(String table : List.of("audit_table", "audit_user")) + { + sql(c, "GRANT UPDATE, DELETE ON " + table + " TO app"); + assertThatThrownBy(() -> sql(runtime, "UPDATE " + table + " SET name='renamed'")) + .hasMessageContaining("worm_audit:"); + assertThatThrownBy(() -> sql(runtime, "UPDATE " + table + " SET id=id+1")) + .hasMessageContaining("worm_audit:"); + assertThatThrownBy(() -> sql(runtime, "DELETE FROM " + table)) + .hasMessageContaining("worm_audit:"); + assertThatThrownBy(() -> sql(c, "TRUNCATE " + table)) + .hasMessageContaining("worm_audit:"); + sql(runtime, "UPDATE " + table + " SET modify_date=now()"); + } + sql(runtime, "UPDATE audit_table SET label='New label'"); + sql(c, WormAuditDdl.postgres(config)); + assertThat( + count( + c, + "SELECT" + + " has_function_privilege('app','worm_audit_purge(timestamptz)','EXECUTE')::int")) + .isZero(); + } + } + + /******************************************************************************* + ** Verify retention refusal purge anchor and real backend tamper. + *******************************************************************************/ + @Test + void retentionRefusalPurgeAnchorAndRealBackendTamper() throws Exception + { + audit(1); + detail(1, 1); + audit(2); + detail(2, 2); + // A live suffix remains after the expired prefix is removed. + try(var c = owner()) + { + sql( + c, + "INSERT INTO audit(id,audit_table_id,audit_user_id,record_id,message,timestamp)" + + " VALUES(3,1,1,17,'Record was Edited',now()-interval '2 minutes')"); + } + new WormAuditChainer(config).chainOnce(); + assertThat(new WormAuditVerifier(config).verify().getBreaks()).isEmpty(); + String anchor = STORE.get(WormAuditTables.CHAIN, 2).getValueString("link_hash"); + try(var c = owner()) + { + sql( + c, + "DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='purge_login')" + + " THEN CREATE ROLE purge_login LOGIN PASSWORD 'purger-fixture'; END IF;" + + " END $$; GRANT worm_audit_purger TO purge_login"); + } + try(var purger = + DriverManager.getConnection( + POSTGRES.getJdbcUrl(), "purge_login", "purger-fixture")) + { + sql(purger, "SELECT worm_audit_purge(now())"); + assertThat(STORE.all(WormAuditTables.PURGE).getFirst().getValueString("outcome")) + .isEqualTo("REFUSED"); + sql(purger, "SELECT worm_audit_purge(now()-interval '6 years')"); + } + var purge = STORE.all(WormAuditTables.PURGE).getLast(); + assertThat(purge.getValueString("outcome")).isEqualTo("PURGED"); + assertThat(purge.getValueString("requested_by")).isEqualTo("purge_login"); + assertThat(purge.getValueLong("audits_deleted")).isEqualTo(2); + assertThat(purge.getValueLong("details_deleted")).isEqualTo(2); + assertThat(purge.getValueLong("links_deleted")).isEqualTo(2); + assertThat(purge.getValueString("anchor_hash")).isEqualTo(anchor); + assertThat(new WormAuditVerifier(config).verify().getBreaks()).isEmpty(); + detail(3, 3); + new WormAuditChainer(config).chainOnce(); + try(var c = owner()) + { + sql( + c, + "ALTER TABLE audit_detail DISABLE TRIGGER ALL; UPDATE audit_detail SET" + + " new_value='tampered' WHERE id=3; ALTER TABLE audit_detail ENABLE" + + " TRIGGER ALL"); + } + assertThat(new WormAuditVerifier(config).verify().getBreaks()) + .anyMatch( + b -> + b.reason() + .contains( + "Break at link 4 (audit 3, table cms_page," + + " record 17)") + && b.reason().contains("audit_detail 3")); + } + + /******************************************************************************* + ** Verify prefix cannot split an audit and advisory lock skips tick. + *******************************************************************************/ + @Test + void prefixCannotSplitAnAuditAndAdvisoryLockSkipsTick() throws Exception + { + // This retention fixture deliberately polls a detail for a six-year-old header. + config.withLateDetailLookbackHours(24 * 365 * 10); + audit(1); + new WormAuditChainer(config).chainOnce(); + try(var c = owner()) + { + sql( + c, + "INSERT INTO audit(id,audit_table_id,audit_user_id,record_id,message,timestamp)" + + " VALUES(2,1,1,17,'Record was Edited',now()-interval '2 minutes')"); + } + new WormAuditChainer(config).chainOnce(); + detail(1, 1); + new WormAuditChainer(config).chainOnce(); + try(var c = owner()) + { + sql(c, "SELECT worm_audit_purge(now()-interval '6 years')"); + assertThat(STORE.all(WormAuditTables.PURGE).getLast().getValueString("outcome")) + .isEqualTo("REFUSED"); + assertThat(STORE.all(WormAuditTables.CHAIN)).hasSize(3); + c.setAutoCommit(false); + sql(c, "SELECT pg_advisory_xact_lock(" + WormAuditChainer.ADVISORY_LOCK + ")"); + assertThat(new WormAuditChainer(config).chainOnce().skipped()).isTrue(); + c.rollback(); + } + } + + /******************************************************************************* + ** Verify eight writers and three chainers with late commits. + *******************************************************************************/ + @Test + void eightWritersAndThreeChainersWithLateCommits() throws Exception + { + var done = new AtomicBoolean(false); + var executor = Executors.newFixedThreadPool(11); + List> writers = new ArrayList<>(); + List> chainers = new ArrayList<>(); + try + { + for(int thread = 0; thread < 8; thread++) + { + final int writer = thread; + writers.add( + executor.submit( + () -> + { + try(var c = app()) + { + for(int i = 0; i < 8; i++) + { + long id = 1000L + writer * 100 + i; + c.setAutoCommit(false); + sql( + c, + "INSERT INTO" + + " audit(id,audit_table_id,audit_user_id,record_id,message,timestamp)" + + " VALUES(" + + id + + ",1,1,17,'Record was" + + " Edited',now()-interval '2" + + " minutes')"); + if(i % 2 == 0) + { + c.commit(); + } + Thread.sleep(5L * (writer + 1)); + sql( + c, + "INSERT INTO" + + " audit_detail(id,audit_id,field_name,message,old_value,new_value)" + + " VALUES(" + + id + + "," + + id + + ",'title','Changed" + + " Title','old','new')"); + c.commit(); + } + } + catch(Exception e) + { + throw new RuntimeException(e); + } + })); + } + for(int i = 0; i < 3; i++) + { + chainers.add( + executor.submit( + () -> + { + QContext.init(instance, new QSession()); + try + { + do + { + new WormAuditChainer(config).chainOnce(); + Thread.sleep(10); + } while(!done.get()); + } + catch(Exception e) + { + throw new RuntimeException(e); + } + })); + } + for(var writer : writers) + { + writer.get(90, TimeUnit.SECONDS); + } + done.set(true); + for(var chainer : chainers) + { + chainer.get(90, TimeUnit.SECONDS); + } + } + finally + { + done.set(true); + executor.shutdownNow(); + } + new WormAuditChainer(config).chainOnce(); + var links = STORE.all(WormAuditTables.CHAIN); + for(int i = 0; i < links.size(); i++) + { + assertThat(links.get(i).getValueLong("id")).isEqualTo(i + 1L); + } + assertThat(links.stream().filter(r -> "AUDIT".equals(r.getValueString("kind"))).count()) + .isEqualTo(64); + assertThat(links.stream().flatMap(r -> AuditStore.detailIds(r).stream()).toList()) + .hasSize(64) + .doesNotHaveDuplicates(); + assertThat(new WormAuditVerifier(config).verify().getBreaks()).isEmpty(); + } + + /******************************************************************************* + ** Even the function owner cannot authorize direct deletes by setting a GUC. + *******************************************************************************/ + @Test + void ownerCannotSpoofPurgeStack() throws Exception + { + audit(1); + detail(1, 1); + try(var connection = owner()) + { + sql(connection, "ALTER TABLE audit ALTER COLUMN timestamp TYPE timestamp without time zone USING timestamp AT TIME ZONE 'UTC'"); + sql(connection, "INSERT INTO audit(id,audit_table_id,audit_user_id,timestamp) VALUES" + + "(2,1,1,(now() AT TIME ZONE 'UTC')-interval '6 years'-interval '1 hour')"); + } + new WormAuditChainer(config).chainOnce(); + try(var connection = owner()) + { + sql(connection, "SELECT set_config('worm_audit.purging','on',false)"); + for(String table : List.of("audit", "audit_detail", "worm_audit_chain")) + { + assertThatThrownBy(() -> sql(connection, "DELETE FROM " + table + " WHERE id=1")) + .isInstanceOf(SQLException.class).hasMessageContaining("worm_audit:"); + } + assertThat(count(connection, "SELECT count(*) FROM worm_audit_purge")).isZero(); + // This succeeds only if the PG16 context text matches the stack guard. + sql(connection, "SET timezone='America/Chicago'"); + sql(connection, "SELECT worm_audit_purge(now()-interval '6 years')"); + assertThat(count(connection, "SELECT count(*) FROM audit")).isZero(); + assertThat(count(connection, "SELECT count(*) FROM worm_audit_purge WHERE outcome='PURGED'")).isEqualTo(1); + } + } + + + /******************************************************************************* + ** A UTC without-tz audit column settles identically in a Chicago JVM/session. + *******************************************************************************/ + @Test + void chainerUsesUtcCutoffInChicago() throws Exception + { + var original = java.util.TimeZone.getDefault(); + config.withSettleHorizonSeconds(60); + try(var connection = owner()) + { + sql(connection, "ALTER TABLE audit ALTER COLUMN timestamp TYPE timestamp without time zone USING timestamp AT TIME ZONE 'UTC'"); + sql(connection, "INSERT INTO audit(id,audit_table_id,audit_user_id,timestamp) VALUES" + + "(1,1,1,TIMESTAMP '2026-10-13 12:00:00'),(2,1,1,TIMESTAMP '2026-10-13 12:01:30')"); + // New runtime connections inherit this non-UTC session setting. + sql(connection, "ALTER ROLE app SET timezone='America/Chicago'"); + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("America/Chicago")); + assertThat(new WormAuditChainer(config).chainOnce(java.time.Instant.parse("2026-10-13T12:02:00Z")).audits()).isEqualTo(1); + assertThat(STORE.all(WormAuditTables.CHAIN)).extracting(r -> r.getValueLong("audit_id")).containsExactly(1L); + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("UTC")); + assertThat(new WormAuditVerifier(config).verify(java.util.Set.of(1L)).getBreaks()).isEmpty(); + } + finally + { + java.util.TimeZone.setDefault(original); + try(var connection = owner()) + { + sql(connection, "ALTER ROLE app RESET timezone"); + } + } + } + + + /******************************************************************************* + ** PostgreSQL containment polling includes the lower window boundary only. + *******************************************************************************/ + @Test + void postgresLateDetailWindowAndCompleteness() throws Exception + { + config.withSettleHorizonSeconds(60); + var now = java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS); + var cutoff = now.minusSeconds(60); + try(var connection = owner(); var statement = connection.prepareStatement( + "INSERT INTO audit(id,audit_table_id,audit_user_id,timestamp) VALUES(?,1,1,?)")) + { + for(int id = 1; id <= 4; id++) + { + long seconds = switch(id) + { + case 1 -> 86401; + case 2 -> 86400; + case 3 -> 1; + default -> 0; + }; + statement.setInt(1, id); + statement.setObject(2, cutoff.minusSeconds(seconds).atOffset(java.time.ZoneOffset.UTC)); + statement.executeUpdate(); + } + } + var writer = new WormAuditChainer(config); + assertThat(writer.chainOnce(now).audits()).isEqualTo(3); + for(long id = 1; id <= 4; id++) + { + detail(id, id); + } + assertThat(writer.chainOnce(now).lateDetails()).isEqualTo(2); + assertThat(STORE.all(WormAuditTables.CHAIN).stream().flatMap(r -> AuditStore.detailIds(r).stream()).toList()).containsExactly(2L, 3L); + assertThat(new WormAuditVerifier(config).verify().getBreaks()).anyMatch(b -> b.reason().contains("audit_detail 1 is not covered")); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/wormaudit/ProcessesAndAlertsTest.java b/src/test/java/com/kingsrook/qbits/wormaudit/ProcessesAndAlertsTest.java new file mode 100644 index 0000000..a310f4b --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/wormaudit/ProcessesAndAlertsTest.java @@ -0,0 +1,234 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.wormaudit.chain.WormAuditChainer; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qbits.wormaudit.processes.VerificationRuns; +import com.kingsrook.qbits.wormaudit.processes.WormAuditProcesses; +import com.kingsrook.qqq.backend.core.alerts.AlertResolution; +import com.kingsrook.qqq.backend.core.alerts.AlertSignal; +import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface; +import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.BackendStepPostRunInput; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.BackendStepPostRunOutput; +import org.junit.jupiter.api.Test; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.STORE; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.audit; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.core; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.detail; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.init; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.tamper; +import static org.assertj.core.api.Assertions.assertThat; + +/******************************************************************************* + ** Tests and fixtures for processes and alerts test. + *******************************************************************************/ +class ProcessesAndAlertsTest +{ + /******************************************************************************* + ** Tests and fixtures for sink. + *******************************************************************************/ + public static class Sink implements AlertSinkInterface + { + static final List raised = new ArrayList<>(); + static final List resolved = new ArrayList<>(); + static final List beats = new ArrayList<>(); + + /******************************************************************************* + ** Verify raise. + *******************************************************************************/ + public void raise(AlertSignal signal) + { + raised.add(signal); + } + + /******************************************************************************* + ** Verify resolve. + *******************************************************************************/ + public void resolve(AlertResolution resolution) + { + resolved.add(resolution); + } + + /******************************************************************************* + ** Verify heartbeat. + *******************************************************************************/ + public void heartbeat(String key, Instant at) + { + beats.add(key); + } + } + + /******************************************************************************* + ** Verify daily raise resolve heartbeat and user result. + *******************************************************************************/ + @Test + void dailyRaiseResolveHeartbeatAndUserResult() throws Exception + { + var config = ChainVerificationTest.signed(); + var instance = init(config); + Sink.raised.clear(); + Sink.resolved.clear(); + Sink.beats.clear(); + AlertsInstanceMetaData.ofOrWithNew(instance).withSink(new QCodeReference(Sink.class)); + audit(1); + detail(1, 1); + new WormAuditProcesses.ChainTick() + .run(new RunBackendStepInput(), new RunBackendStepOutput()); + tamper("auditDetail", 1, "newValue", "broken"); + var daily = new WormAuditProcesses.Daily(); + var output = new RunBackendStepOutput(); + daily.run(new RunBackendStepInput(), output); + assertThat(output.getValueString("outcome")).isEqualTo("FAIL"); + assertThat(Sink.raised).hasSize(1); + assertThat(Sink.raised.getFirst().getAlertTypeName()).isEqualTo("worm_audit.chain.break"); + assertThat(Sink.raised.getFirst().getDedupKey()).isEqualTo("chain"); + assertThat(Sink.raised.getFirst().getRecordRefs().getFirst().getTableName()) + .isEqualTo(WormAuditTables.VERIFICATION); + assertThat(Sink.raised.getFirst().getTitle()).contains("Break at link 1"); + var first = STORE.all(WormAuditTables.VERIFICATION).getFirst(); + assertThat(first.getValueLong("first_break_link_id")).isEqualTo(1); + assertThat(first.getValueLong("first_break_audit_id")).isEqualTo(1); + assertThat(first.getValueString("trigger")).isEqualTo("SCHEDULE"); + tamper("auditDetail", 1, "newValue", "new"); + daily.run(new RunBackendStepInput(), new RunBackendStepOutput()); + assertThat(Sink.resolved).hasSize(1); + assertThat(Sink.beats) + .containsExactly("worm_audit.verify.daily", "worm_audit.verify.daily"); + assertThat(STORE.all(WormAuditTables.VERIFICATION)).hasSize(2); + assertThat(VerificationRuns.run(null, "USER").passed()).isTrue(); + assertThat(Sink.beats).hasSize(2); + } + + /******************************************************************************* + ** Verify review and load across pages including failures and empty scope. + *******************************************************************************/ + @Test + void reviewAndLoadAcrossPagesIncludingFailuresAndEmptyScope() throws Exception + { + var config = new WormAuditQBitConfig(); + init(config); + audit(1); + audit(2); + new WormAuditChainer(config).chainOnce(); + var links = STORE.all(WormAuditTables.CHAIN); + var review = new WormAuditProcesses.Review(); + var load = new WormAuditProcesses.Load(); + var output = new RunBackendStepOutput(); + assertThat(load.getProcessSummary(output, true)).isEmpty(); + for(var link : links) + { + var input = new RunBackendStepInput().withRecords(List.of(link)); + review.runOnePage(input, output); + load.runOnePage(input, output); + } + assertThat(review.getProcessSummary(output, false).toString()) + .contains("2 chain links will be verified", "0 checkpoints"); + load.postRun( + new BackendStepPostRunInput(new RunBackendStepInput()), + new BackendStepPostRunOutput(output)); + assertThat(STORE.all(WormAuditTables.VERIFICATION)).hasSize(1); + assertThat(load.getProcessSummary(output, true).toString()) + .contains("2 links verified", "no signing key configured"); + audit(3); + var fullWithEmptyFilter = new WormAuditProcesses.Load(); + fullWithEmptyFilter.runOnePage(new RunBackendStepInput().withRecords(links), output); + fullWithEmptyFilter.postRun(new BackendStepPostRunInput(new RunBackendStepInput().addValue("queryFilterJson", "{}")), + new BackendStepPostRunOutput(output)); + assertThat(fullWithEmptyFilter.getProcessSummary(output, true).toString()).contains("audit 3 is not covered by the chain"); + tamper("audit", 1, "message", "changed"); + var selected = new WormAuditProcesses.Load(); + selected.runOnePage( + new RunBackendStepInput().withRecords(List.of(links.getFirst())), output); + selected.postRun( + new BackendStepPostRunInput(new RunBackendStepInput().addValue("recordIds", "1")), + new BackendStepPostRunOutput(output)); + assertThat(selected.getProcessSummary(output, true).toString()) + .contains("ERROR", "content differs"); + init(config); + var empty = new WormAuditProcesses.Load(); + empty.postRun( + new BackendStepPostRunInput(new RunBackendStepInput()), + new BackendStepPostRunOutput(output)); + assertThat(empty.getProcessSummary(output, true).toString()).contains("0 links verified"); + assertThat(new WormAuditProcesses.Review().getProcessSummary(output, false).toString()) + .contains("links none"); + } + + /******************************************************************************* + ** Full loads retain no id collection; review summarizes disjoint streamed pages once. + *******************************************************************************/ + @Test + void reviewRangesAndSelectionOnlyIds() throws Exception + { + var config = ChainVerificationTest.signed().withCheckpointEveryLinks(2); + init(config); + for(long id = 1; id <= 4; id++) + { + audit(id); + } + new WormAuditChainer(config).chainOnce(); + var links = STORE.all(WormAuditTables.CHAIN); + var review = new WormAuditProcesses.Review(); + var full = new WormAuditProcesses.Load(); + var selected = new WormAuditProcesses.Load(); + var output = new RunBackendStepOutput(); + var ids = WormAuditProcesses.Load.class.getDeclaredField("ids"); + ids.setAccessible(true); + for(var link : links) + { + var input = new RunBackendStepInput().withRecords(List.of(link)); + review.runOnePage(input, output); + full.runOnePage(input, output); + assertThat(ids.get(full)).isNull(); + } + String summary = review.getProcessSummary(output, false).toString(); + assertThat(summary).contains("4 chain links", "links 1–4", "2 checkpoints"); + // A second call reuses the count; later inserts cannot silently change Review. + STORE.insert(WormAuditTables.CHECKPOINT, List.of(new com.kingsrook.qqq.backend.core.model.data.QRecord() + .withValue("from_link_id", 1L).withValue("to_link_id", 4L))); + assertThat(review.getProcessSummary(output, false).toString()).isEqualTo(summary); + selected.runOnePage(new RunBackendStepInput().addValue("recordIds", "2").withRecords(List.of(links.get(1))), output); + assertThat(ids.get(selected)).isEqualTo(java.util.Set.of(2L)); + } + + + /******************************************************************************* + ** Docker dependencies must not enable debug wire logs during tests. + *******************************************************************************/ + @Test + void dockerLoggersAreQuiet() + { + for(String name : List.of("org.testcontainers", "com.github.dockerjava", "org.apache.hc", "tc")) + { + assertThat(org.apache.logging.log4j.LogManager.getLogger(name).isDebugEnabled()).isFalse(); + assertThat(org.apache.logging.log4j.LogManager.getLogger(name).isWarnEnabled()).isTrue(); + } + } + +} diff --git a/src/test/java/com/kingsrook/qbits/wormaudit/RdbmsChainTest.java b/src/test/java/com/kingsrook/qbits/wormaudit/RdbmsChainTest.java new file mode 100644 index 0000000..5cae5ce --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/wormaudit/RdbmsChainTest.java @@ -0,0 +1,212 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit; + +import java.sql.DriverManager; +import java.util.Map; +import java.util.UUID; +import com.kingsrook.qbits.wormaudit.chain.WormAuditChainer; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qbits.wormaudit.verify.WormAuditVerifier; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import org.junit.jupiter.api.Test; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.STORE; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.audit; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.core; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.detail; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.init; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.lookups; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** Tests and fixtures for rdbms chain test. + *******************************************************************************/ +class RdbmsChainTest +{ + /******************************************************************************* + ** Verify real qqq rdbms actions anti join rollback and tamper. + *******************************************************************************/ + @Test + void realQqqRdbmsActionsAntiJoinRollbackAndTamper() throws Exception + { + String url = + "jdbc:h2:mem:worm_" + + UUID.randomUUID() + + ";DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE"; + var config = ChainVerificationTest.signed(); + var instance = RdbmsTestSupport.instance(url, "sa", "", config, false); + try(var connection = DriverManager.getConnection(url, "sa", "")) + { + RdbmsTestSupport.createTables(connection, instance, false); + QContext.init(instance, new QSession()); + lookups(); + audit(1); + detail(1, 1); + var writer = new WormAuditChainer(config); + assertThat(writer.chainOnce().links()).isEqualTo(1); + assertThat(writer.chainOnce().links()).isZero(); + assertThat(new WormAuditVerifier(config).verify().getBreaks()).isEmpty(); + // Fail signing after append, verifying the real RDBMS transaction rolls back. + var pins = config.getTrustedPublicKeys(); + config.withTrustedPublicKeys(Map.of()); + audit(2); + assertThatThrownBy(writer::chainOnce) + .hasMessageContaining("WORM audit operation failed"); + assertThat(STORE.all(WormAuditTables.CHAIN)).hasSize(1); + config.withTrustedPublicKeys(pins); + assertThat(writer.chainOnce().links()).isEqualTo(1); + assertThat(STORE.all(WormAuditTables.CHAIN)) + .extracting(r -> r.getValueLong("id")) + .containsExactly(1L, 2L); + connection + .createStatement() + .execute("UPDATE audit_detail SET new_value='changed' WHERE id=1"); + assertThat(new WormAuditVerifier(config).verify().getBreaks()) + .anyMatch(b -> b.reason().contains("audit_detail 1")); + } + } + + /******************************************************************************* + ** Exercise multiple keyset pages, capped checkpoints and bounded SQL query counts. + *******************************************************************************/ + @Test + void batchesPagesAndCheckpointRanges() throws Exception + { + String url = "jdbc:h2:mem:pages_" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE"; + var config = ChainVerificationTest.signed().withBatchSize(1500).withCheckpointEveryLinks(400).withCheckpointEveryMinutes(1); + var instance = RdbmsTestSupport.instance(url, "sa", "", config, false); + try(var connection = DriverManager.getConnection(url, "sa", ""); var statement = connection.createStatement()) + { + RdbmsTestSupport.createTables(connection, instance, false); + QContext.init(instance, new QSession()); + lookups(); + statement.execute("INSERT INTO audit(id,audit_table_id,audit_user_id,record_id,timestamp) SELECT x,1,1,17,CURRENT_TIMESTAMP - INTERVAL '2' MINUTE FROM SYSTEM_RANGE(1,1005) series(x)"); + statement.execute("INSERT INTO audit_detail(id,audit_id,field_name,new_value) SELECT x,x,'title','new' FROM SYSTEM_RANGE(1,1005) series(x)"); + statement.execute("SET QUERY_STATISTICS TRUE"); + var writer = new WormAuditChainer(config); + var now = java.time.Instant.now(); + var counts = writer.chainOnce(now); + assertThat(counts.links()).isEqualTo(1005); + assertThat(counts.checkpoints()).isEqualTo(2); + assertSelectCount(connection, 100); + assertThat(STORE.last(WormAuditTables.CHECKPOINT).getValueInstant("created_at")).isCloseTo(now, org.assertj.core.api.Assertions.within(1, java.time.temporal.ChronoUnit.SECONDS)); + statement.execute("SET QUERY_STATISTICS FALSE"); + statement.execute("SET QUERY_STATISTICS TRUE"); + assertThat(writer.chainOnce(now.plusSeconds(61)).checkpoints()).isEqualTo(1); + assertThat(new WormAuditVerifier(config).verify().getBreaks()).isEmpty(); + assertSelectCount(connection, 100); + assertThat(STORE.all(WormAuditTables.CHECKPOINT)).extracting(r -> r.getValueLong("link_count")).containsExactly(400L, 400L, 205L); + statement.execute("UPDATE audit_detail SET new_value='changed' WHERE id=1001"); + assertThat(new WormAuditVerifier(config).verify().getBreaks()).anyMatch(b -> b.reason().contains("audit_detail 1,001")); + statement.execute("DELETE FROM worm_audit_chain WHERE id=1000"); + assertThat(new WormAuditVerifier(config).verify().getBreaks()).anyMatch(b -> b.reason().contains("gap")); + } + } + + + /******************************************************************************* + ** Assert actual query executions stay proportional to batches, not row count. + *******************************************************************************/ + private void assertSelectCount(java.sql.Connection connection, int limit) throws Exception + { + try(var statement = connection.createStatement(); var rows = statement.executeQuery( + "SELECT COALESCE(SUM(EXECUTION_COUNT),0) FROM INFORMATION_SCHEMA.QUERY_STATISTICS WHERE UPPER(SQL_STATEMENT) LIKE 'SELECT%'")) + { + rows.next(); + assertThat(rows.getLong(1)).isLessThan(limit); + } + } + + + /******************************************************************************* + ** Poll only the lookback window and report older omissions with exact bounded ids. + *******************************************************************************/ + @Test + void lateWindowAndCompletenessOffenderLimit() throws Exception + { + String url = "jdbc:h2:mem:window_" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE"; + var config = new WormAuditQBitConfig().withBatchSize(500); + var instance = RdbmsTestSupport.instance(url, "sa", "", config, false); + try(var connection = DriverManager.getConnection(url, "sa", ""); var statement = connection.createStatement()) + { + RdbmsTestSupport.createTables(connection, instance, false); + QContext.init(instance, new QSession()); + lookups(); + audit(1); + statement.execute("INSERT INTO audit(id,audit_table_id,audit_user_id,timestamp) VALUES(2,1,1,CURRENT_TIMESTAMP - INTERVAL '2' MINUTE)"); + var writer = new WormAuditChainer(config); + writer.chainOnce(); + detail(1, 1); + detail(2, 2); + assertThat(writer.chainOnce().lateDetails()).isEqualTo(1); + assertThat(new WormAuditVerifier(config).verify().getBreaks()).anyMatch(b -> b.reason().contains("audit_detail 1 is not covered")); + statement.execute("INSERT INTO worm_audit_chain SELECT 99,kind,audit_id,detail_ids,prev_hash,link_hash,chained_at FROM worm_audit_chain WHERE id=3"); + assertThat(new WormAuditVerifier(config).verify().getBreaks()).anyMatch(b -> b.reason().contains("audit_detail 2 is covered twice")); + statement.execute("DELETE FROM worm_audit_chain WHERE id=99"); + statement.execute("INSERT INTO audit_detail(id,audit_id) SELECT x,1 FROM SYSTEM_RANGE(3,107) series(x)"); + var result = new WormAuditVerifier(config).verify(); + assertThat(result.getBreakCount()).isEqualTo(106); + assertThat(result.getBreaks()).hasSize(100); + assertThat(result.getBreaks().getFirst().reason()).contains("audit_detail 1 is not covered"); + assertThat(result.getBreaks().getLast().reason()).contains("audit_detail 101 is not covered"); + assertThat(result.getWarnings()).anyMatch(w -> w.contains("106 coverage offenders")); + statement.execute("INSERT INTO audit(id,timestamp) SELECT x,CURRENT_TIMESTAMP - INTERVAL '2' MINUTE FROM SYSTEM_RANGE(2000,2104) series(x)"); + result = new WormAuditVerifier(config).verify(); + assertThat(result.getBreakCount()).isEqualTo(211); + assertThat(result.getBreaks()).hasSize(200); + assertThat(result.getBreaks().getFirst().reason()).contains("audit 2,000 is not covered"); + assertThat(result.getBreaks().get(99).reason()).contains("audit 2,099 is not covered"); + } + } + + + /******************************************************************************* + ** Cutoffs are UTC local values for timestamp columns, independent of JVM zone. + *******************************************************************************/ + @Test + void withoutTimezoneCutoffInChicago() throws Exception + { + var original = java.util.TimeZone.getDefault(); + String url = "jdbc:h2:mem:timezone_" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE"; + var config = new WormAuditQBitConfig(); + var instance = RdbmsTestSupport.instance(url, "sa", "", config, false); + try(var connection = DriverManager.getConnection(url, "sa", ""); var statement = connection.createStatement()) + { + RdbmsTestSupport.createTables(connection, instance, false); + statement.execute("ALTER TABLE audit ALTER COLUMN timestamp timestamp without time zone"); + QContext.init(instance, new QSession()); + lookups(); + statement.execute("INSERT INTO audit(id,audit_table_id,audit_user_id,timestamp) VALUES(1,1,1,TIMESTAMP '2026-10-13 12:00:00'),(2,1,1,TIMESTAMP '2026-10-13 12:01:30')"); + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("America/Chicago")); + assertThat(new WormAuditChainer(config).chainOnce(java.time.Instant.parse("2026-10-13T12:02:00Z")).audits()).isEqualTo(1); + assertThat(STORE.all(WormAuditTables.CHAIN)).extracting(r -> r.getValueLong("audit_id")).containsExactly(1L); + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("UTC")); + assertThat(new WormAuditVerifier(config).verify(java.util.Set.of(1L)).getBreaks()).isEmpty(); + } + finally + { + java.util.TimeZone.setDefault(original); + } + } + +} diff --git a/src/test/java/com/kingsrook/qbits/wormaudit/RdbmsTestSupport.java b/src/test/java/com/kingsrook/qbits/wormaudit/RdbmsTestSupport.java new file mode 100644 index 0000000..14e1825 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/wormaudit/RdbmsTestSupport.java @@ -0,0 +1,157 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit; + +import java.sql.Connection; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import com.kingsrook.qbits.wormaudit.ddl.WormAuditDdl; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.SimpleConnectionProvider; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails; +import com.kingsrook.qqq.backend.module.rdbms.strategy.BaseRDBMSActionStrategy; + +/******************************************************************************* + ** Tests and fixtures for rdbms test support. + *******************************************************************************/ +class RdbmsTestSupport +{ + // RC.5 supplies the RDBMS actions and a pluggable strategy. Hosts supply their + // PostgreSQL quoting strategy; this fixture uses the core actions unchanged. + /******************************************************************************* + ** Tests and fixtures for postgres strategy. + *******************************************************************************/ + public static class PostgresStrategy extends BaseRDBMSActionStrategy + { + /******************************************************************************* + ** Verify get identifier quote string. + *******************************************************************************/ + @Override + public String getIdentifierQuoteString() + { + return ("\""); + } + + /******************************************************************************* + ** Verify get insert default values clause. + *******************************************************************************/ + @Override + public String getInsertDefaultValuesClause() + { + return ("DEFAULT VALUES"); + } + } + + /******************************************************************************* + ** Verify instance. + *******************************************************************************/ + static QInstance instance( + String url, String user, String password, WormAuditQBitConfig config, boolean postgres) + throws Exception + { + com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders(); + var backend = + new RDBMSBackendMetaData() + .withName("db") + .withVendor(postgres ? "postgresql" : "h2") + .withJdbcUrl(url) + .withJdbcDriverClassName( + postgres ? "org.postgresql.Driver" : "org.h2.Driver") + .withUsername(user) + .withPassword(password) + .withConnectionProvider(new QCodeReference(SimpleConnectionProvider.class)); + if(postgres) + { + backend.withActionStrategyCodeReference(new QCodeReference(PostgresStrategy.class)); + } + QInstance instance = WormAuditTestSupport.core(backend); + for(var table : instance.getTables().values()) + { + table.withBackendDetails( + new RDBMSTableBackendDetails().withTableName(snake(table.getName()))); + table.getFields().values().forEach(f -> f.withBackendName(snake(f.getName()))); + } + new WormAuditQBitProducer().withQBitConfig(config.withBackendName("db")).produce(instance); + return (instance); + } + + /******************************************************************************* + ** Verify snake. + *******************************************************************************/ + static String snake(String value) + { + return (value.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT)); + } + + /******************************************************************************* + ** Verify create tables. + *******************************************************************************/ + static void createTables(Connection connection, QInstance instance, boolean coreOnly) + throws Exception + { + for(var table : instance.getTables().values()) + { + if(coreOnly && table.getName().startsWith("worm_audit_")) + { + continue; + } + List columns = new ArrayList<>(); + for(var field : table.getFields().values()) + { + String type = + switch(field.getType()) + { + case INTEGER -> "integer"; + case LONG -> "bigint"; + case DATE_TIME -> "timestamp(6) with time zone"; + default -> "text"; + }; + if(field.getName().equals("id")) + { + type += + table.getName().equals("worm_audit_chain") + ? " PRIMARY KEY" + : " GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY"; + } + columns.add( + WormAuditDdl.identifier( + field.getBackendName() == null + ? field.getName() + : field.getBackendName()) + + " " + + type); + } + String name = ((RDBMSTableBackendDetails) table.getBackendDetails()).getTableName(); + try(var statement = connection.createStatement()) + { + statement.execute( + "CREATE TABLE " + + WormAuditDdl.identifier(name) + + " (" + + String.join(",", columns) + + ")"); + } + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/wormaudit/RecordIntegrationTest.java b/src/test/java/com/kingsrook/qbits/wormaudit/RecordIntegrationTest.java new file mode 100644 index 0000000..ed6ffb9 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/wormaudit/RecordIntegrationTest.java @@ -0,0 +1,125 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.wormaudit.chain.WormAuditChainer; +import com.kingsrook.qbits.wormaudit.model.LinkRangeWidget; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qbits.wormaudit.processes.VerificationRuns; +import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeEvent; +import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeListenerInterface; +import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeType; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChildRecordListData; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import org.junit.jupiter.api.Test; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.audit; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.init; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** Tests and fixtures for record integration test. + *******************************************************************************/ +class RecordIntegrationTest +{ + /******************************************************************************* + ** Tests and fixtures for event sink. + *******************************************************************************/ + public static class EventSink implements RecordChangeListenerInterface + { + static List tables = new ArrayList<>(); + + /******************************************************************************* + ** Verify applies to. + *******************************************************************************/ + public boolean appliesTo(String table, RecordChangeType type) + { + return (List.of(WormAuditTables.VERIFICATION, WormAuditTables.EXPORT).contains(table) + && type == RecordChangeType.INSERT); + } + + /******************************************************************************* + ** Verify on records changed. + *******************************************************************************/ + public void onRecordsChanged(RecordChangeEvent event) + { + tables.add(event.getTableName()); + } + } + + /******************************************************************************* + ** Verify checkpoint child list uses range and read permissions. + *******************************************************************************/ + @Test + void checkpointChildListUsesRangeAndReadPermissions() throws Exception + { + var config = ChainVerificationTest.signed().withCheckpointEveryLinks(2); + var instance = init(config); + audit(1); + audit(2); + new WormAuditChainer(config).chainOnce(); + var input = + new RenderWidgetInput() + .withWidgetMetaData( + instance.getWidget(WormAuditTables.CHECKPOINT + "_links")); + input.setQueryParams(Map.of("id", "1")); + assertThatThrownBy(() -> new LinkRangeWidget().render(input)).isInstanceOf(Exception.class); + QContext.getQSession() + .withPermissions("worm_audit_checkpoint.read", "worm_audit_chain.read"); + var data = (ChildRecordListData) new LinkRangeWidget().render(input).getWidgetData(); + assertThat(data.getTotalRows()).isEqualTo(2); + assertThat(data.getQueryOutput().getRecords()).hasSize(2); + assertThat(data.getViewAllLink()).contains("filter="); + input.setQueryParams(Map.of("id", "999")); + assertThatThrownBy(() -> new LinkRangeWidget().render(input)) + .hasMessageContaining("was not found"); + assertThat( + instance.getTable(WormAuditTables.EXPORT) + .getField("from_checkpoint_id") + .getPossibleValueSourceName()) + .isEqualTo(WormAuditTables.CHECKPOINT); + assertThat( + instance.getTable(WormAuditTables.VERIFICATION) + .getField("export_id") + .getPossibleValueSourceName()) + .isEqualTo(WormAuditTables.EXPORT); + } + + /******************************************************************************* + ** Verify verification inserts reach core event spi for host esb publications. + *******************************************************************************/ + @Test + void verificationInsertsReachCoreEventSpiForHostEsbPublications() throws Exception + { + var instance = init(new WormAuditQBitConfig()); + EventSink.tables.clear(); + instance.withRecordChangeListener(new QCodeReference(EventSink.class)); + audit(1); + new WormAuditChainer(VerificationRuns.config()).chainOnce(); + VerificationRuns.run(null, "USER"); + assertThat(EventSink.tables).containsExactly(WormAuditTables.VERIFICATION); + } +} diff --git a/src/test/java/com/kingsrook/qbits/wormaudit/RequiresDockerCondition.java b/src/test/java/com/kingsrook/qbits/wormaudit/RequiresDockerCondition.java new file mode 100644 index 0000000..5684be6 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/wormaudit/RequiresDockerCondition.java @@ -0,0 +1,67 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.testcontainers.DockerClientFactory; + +/******************************************************************************* + ** Tests and fixtures for requires docker condition. + *******************************************************************************/ +public class RequiresDockerCondition implements ExecutionCondition +{ + /******************************************************************************* + ** Verify evaluate execution condition. + *******************************************************************************/ + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) + { + boolean available; + try + { + available = DockerClientFactory.instance().isDockerAvailable(); + } + catch(Exception | LinkageError e) + { + available = false; + } + return (evaluate(available, System.getenv("CI"))); + } + + /******************************************************************************* + ** Verify evaluate. + *******************************************************************************/ + static ConditionEvaluationResult evaluate(boolean available, String ci) + { + if(available) + { + return (ConditionEvaluationResult.enabled("Docker is available")); + } + if(ci != null) + { + throw new IllegalStateException("Docker is required when CI is set"); + } + return (ConditionEvaluationResult.disabled( + "Docker unavailable; coordinator must run PostgreSQL acceptance tests")); + } +} diff --git a/src/test/java/com/kingsrook/qbits/wormaudit/SealedExportTest.java b/src/test/java/com/kingsrook/qbits/wormaudit/SealedExportTest.java new file mode 100644 index 0000000..2f4996e --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/wormaudit/SealedExportTest.java @@ -0,0 +1,710 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit; + + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.objectstorage.api.ListRequest; +import com.kingsrook.qbits.objectstorage.api.ObjectSummary; +import com.kingsrook.qbits.objectstorage.api.Page; +import com.kingsrook.qbits.objectstorage.api.StoredObject; +import com.kingsrook.qbits.wormaudit.canonical.Jcs; +import com.kingsrook.qbits.wormaudit.chain.Hashes; +import com.kingsrook.qbits.wormaudit.chain.WormAuditChainer; +import com.kingsrook.qbits.wormaudit.export.ExportFiles; +import com.kingsrook.qbits.wormaudit.export.SealedExportVerifier; +import com.kingsrook.qbits.wormaudit.export.SealedExporter; +import com.kingsrook.qbits.wormaudit.export.SealedStore; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qbits.wormaudit.processes.ExportProcesses; +import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.BackendStepPostRunInput; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.BackendStepPostRunOutput; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.STORE; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.audit; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.detail; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.init; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.remove; +import static com.kingsrook.qbits.wormaudit.WormAuditTestSupport.tamper; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Offline sealed-export contract, corruption, retention, batching and UI tests. + *******************************************************************************/ +class SealedExportTest +{ + static final Instant NOW = Instant.parse("2026-10-14T02:00:00Z"); + WormAuditQBitConfig config; + MemorySealedStore storage; + + /******************************************************************************* + ** Tiny object boundary; bytes exist only in tests. + *******************************************************************************/ + static class MemorySealedStore implements SealedStore + { + final Map objects = new LinkedHashMap<>(); + final Map heads = new LinkedHashMap<>(); + final Map> history = new LinkedHashMap<>(); + int nextVersion; + String missingVersion; + boolean omitVersion; + boolean racePut; + boolean supported = true; + String echoMode = "COMPLIANCE"; + long shortenSeconds; + int failPut; + + @Override + public boolean supportsLock() + { + return (supported); + } + + @Override + public String put(String key, Path file, Instant until) throws Exception + { + if(failPut > 0 && objects.size() + 1 == failPut) + { + throw (new IllegalStateException("CANARY-PRIVATE-PROVIDER-TEXT")); + } + if(objects.containsKey(key)) + { + history.computeIfAbsent(key, k -> new LinkedHashMap<>()).put(heads.get(key).getVersionId(), objects.get(key)); + } + String version = omitVersion ? missingVersion : "v" + ++nextVersion; + objects.put(key, Files.readAllBytes(file)); + heads.put(key, new StoredObject().withKey(key).withVersionId(racePut ? "replacement" : version) + .withLockMode(echoMode).withLockRetainUntil(until.minusSeconds(shortenSeconds))); + return (version); + } + + @Override + public StoredObject head(String key) + { + return (heads.get(key)); + } + + @Override + public InputStream getVersion(String key, String versionId) + { + byte[] bytes = versionId.equals(heads.get(key).getVersionId()) ? objects.get(key) : history.get(key).get(versionId); + return (new ByteArrayInputStream(bytes)); + } + + @Override + public Page list(ListRequest request) + { + return (new Page().withItems(objects.keySet().stream().filter(k -> k.startsWith(request.getPrefix())) + .map(k -> new ObjectSummary().withKey(k)).toList())); + } + } + + + /******************************************************************************* + ** Exercise setUp. + *******************************************************************************/ + @BeforeEach + void setUp() throws Exception + { + config = ChainVerificationTest.signed(); + var instance = init(config); + storage = new MemorySealedStore(); + ProcessesAndAlertsTest.Sink.raised.clear(); + ProcessesAndAlertsTest.Sink.resolved.clear(); + ProcessesAndAlertsTest.Sink.beats.clear(); + AlertsInstanceMetaData.ofOrWithNew(instance).withSink(new QCodeReference(ProcessesAndAlertsTest.Sink.class)); + } + + + QRecord seal() throws Exception + { + new WormAuditChainer(config).chainOnce(NOW); + var result = new SealedExporter(config, storage).run("USER", NOW); + assertThat(result.failed()).as(result.message()).isFalse(); + assertThat(result.row()).isNotNull(); + return (result.row()); + } + + + SealedExportVerifier.Report verify(QRecord row) throws Exception + { + return (new SealedExportVerifier(config, storage).verify(row, "USER")); + } + + + String findings(QRecord row) throws Exception + { + return (verify(row).result().getBreaks().toString()); + } + + + /******************************************************************************* + ** Exercise canonicalGoldenFilesAndRoundTrip. + *******************************************************************************/ + @Test + void canonicalGoldenFilesAndRoundTrip() throws Exception + { + audit(1); + detail(1, 1); + QRecord row = seal(); + String prefix = "worm-audit/2026/10/14/1-1/"; + assertThat(row.getValueString("object_prefix")).isEqualTo(prefix); + String content = "{\"audit\":{\"clientChannel\":null,\"id\":1,\"message\":\"Record was Edited\",\"recordId\":\"17\",\"table\":\"cms_page\",\"timestamp\":\"2020-01-02T03:04:06.123456Z\",\"user\":\"james\"},\"details\":[{\"fieldName\":\"title\",\"id\":1,\"message\":\"Changed Title\",\"newValue\":\"new\",\"oldValue\":\"old\"}],\"v\":1}"; + String hash = Hashes.link(Hashes.GENESIS, content.getBytes(StandardCharsets.UTF_8)); + String golden = "{\"content\":" + content + ",\"link\":{\"auditId\":1,\"detailIds\":[1],\"id\":1,\"kind\":\"AUDIT\",\"linkHash\":\"" + hash + + "\",\"prevHash\":\"" + Hashes.GENESIS + "\"}}\n"; + assertThat(new String(storage.objects.get(prefix + "links.jsonl"), StandardCharsets.UTF_8)).isEqualTo(golden); + var manifest = ExportFiles.parse(storage.objects.get(prefix + "manifest.json")); + assertThat(manifest.keySet()).containsExactly("checkpointCount", "createdAt", "files", "format", "fromLinkId", "headHash", "linkCount", + "prevManifestSha256", "qbitVersion", "toLinkId"); + assertThat(manifest).containsEntry("format", "worm-audit-export/1").containsEntry("linkCount", 1L).containsEntry("checkpointCount", 1L) + .containsEntry("prevManifestSha256", null).containsEntry("headHash", hash).containsEntry("createdAt", NOW.toString()); + assertThat(storage.objects.get(prefix + "manifest.json")).isEqualTo(Jcs.bytes(manifest)); + assertThat(row.getValues()).containsKeys("from_checkpoint_id", "to_checkpoint_id", "bucket_alias", "manifest_key", "manifest_sha256", + "prev_manifest_sha256", "object_count", "byte_count", "lock_mode", "retain_until", "sealed_at"); + assertThat(row.getValueLong("byte_count")).isEqualTo(storage.objects.values().stream().mapToLong(b -> b.length).sum()); + assertThat(row.getValueInstant("retain_until")).isEqualTo(Instant.parse("2032-10-14T02:00:00Z")); + assertThat(verify(row).result().passed()).isTrue(); + QRecord verification = STORE.last(WormAuditTables.VERIFICATION); + assertThat(verification.getValueString("kind")).isEqualTo("SEALED_EXPORT"); + assertThat(verification.getValueLong("export_id")).isEqualTo(row.getValueLong("id")); + assertThat(verification.getValueString("outcome")).isEqualTo("PASS"); + assertThat(ProcessesAndAlertsTest.Sink.resolved).hasSize(1); + } + + + /******************************************************************************* + ** Every overwrite is a break while verification still uses the sealed bytes. + *******************************************************************************/ + @Test + void detectsOverwritesAndReadsSealedVersions() throws Exception + { + audit(1); + QRecord row = seal(); + Map versions = ExportFiles.parse(row.getValueString("object_versions").getBytes(StandardCharsets.UTF_8)); + assertThat(versions.keySet()).containsExactly("checkpoints.jsonl", "links.jsonl", "manifest.json"); + assertThat(row.getValueString("object_versions")).isEqualTo(Jcs.string(versions)); + assertThat(STORE.get(WormAuditTables.EXPORT, row.getValueLong("id")).getValueString("object_versions")) + .isEqualTo(row.getValueString("object_versions")); + try(var files = new ExportFiles()) + { + String linksKey = row.getValueString("object_prefix") + "links.jsonl"; + Files.write(files.file("links.jsonl"), storage.objects.get(linksKey)); + storage.put(linksKey, files.file("links.jsonl"), row.getValueInstant("retain_until")); + var identical = verify(row); + assertThat(identical.result().passed()).isFalse(); + assertThat(identical.result().getLinksChecked()).isEqualTo(1); + assertThat(identical.result().getBreaks()).singleElement().satisfies(b -> + assertThat(b.reason()).contains("object links.jsonl was overwritten after sealing")); + for(String name : ExportFiles.NAMES) + { + String key = row.getValueString("object_prefix") + name; + Files.writeString(files.file(name), "different bytes"); + String current = storage.put(key, files.file(name), row.getValueInstant("retain_until")); + var report = verify(row); + assertThat(report.result().passed()).isFalse(); + assertThat(report.result().getLinksChecked()).isEqualTo(1); + assertThat(report.result().getBreaks()).allSatisfy(b -> assertThat(b.reason()).contains("was overwritten after sealing")); + assertThat(report.result().getBreaks().toString()).contains("object " + name + " was overwritten after sealing (current version " + + current + ", sealed version " + versions.get(name) + " is still retained under Object Lock)"); + } + } + var load = new ExportProcesses.VerifyLoad(storage); + load.runOnePage(new RunBackendStepInput().withRecords(List.of(row)), new RunBackendStepOutput()); + assertThat(load.getProcessSummary(new RunBackendStepOutput(), true).toString()).contains("ERROR", "was overwritten after sealing"); + } + + + /******************************************************************************* + ** Unversioned uploads and upload/HEAD races cannot create a sealed catalog row. + *******************************************************************************/ + @Test + void refusesMissingVersionsAndUploadRaces() throws Exception + { + audit(1); + new WormAuditChainer(config).chainOnce(NOW); + storage.omitVersion = true; + for(String version : java.util.Arrays.asList(null, "", " ", "null")) + { + storage.missingVersion = version; + var result = new SealedExporter(config, storage).run("USER", NOW); + assertThat(result.failed()).isTrue(); + assertThat(result.message()).contains("returned no version id"); + assertThat(STORE.all(WormAuditTables.EXPORT)).isEmpty(); + } + storage.omitVersion = false; + storage.racePut = true; + assertThat(new SealedExporter(config, storage).run("USER", NOW).message()).contains("version changed after upload"); + assertThat(STORE.all(WormAuditTables.EXPORT)).isEmpty(); + assertThat(ProcessesAndAlertsTest.Sink.raised).hasSize(5); + } + + + /******************************************************************************* + ** Missing catalog versions must never silently fall back to current content. + *******************************************************************************/ + @Test + void refusesMissingRecordedVersion() throws Exception + { + audit(1); + QRecord row = seal(); + row.setValue("object_versions", "{}"); + assertThat(findings(row)).contains("object links.jsonl has no recorded sealed version id"); + row.setValue("object_versions", null); + assertThat(verify(row).result().passed()).isFalse(); + } + + + /******************************************************************************* + ** Exercise rangeSelectionNoCheckpointContiguousAndPurgeAnchor. + *******************************************************************************/ + @Test + void rangeSelectionNoCheckpointContiguousAndPurgeAnchor() throws Exception + { + assertThat(new SealedExporter(config, storage).run("SCHEDULE", NOW).message()).isEqualTo("Nothing to seal: no new checkpoint"); + assertThat(ProcessesAndAlertsTest.Sink.beats).containsExactly("worm_audit.export.daily"); + audit(1); + QRecord first = seal(); + assertThat(new SealedExporter(config, storage).run("USER", NOW).row()).isNull(); + audit(2); + QRecord second = seal(); + assertThat(second.getValueLong("from_link_id")).isEqualTo(2L); + assertThat(second.getValueString("prev_manifest_sha256")).isEqualTo(first.getValueString("manifest_sha256")); + assertThat(verify(second).result().passed()).isTrue(); + + init(config); + STORE.insert(WormAuditTables.PURGE, List.of(new QRecord().withValue("outcome", "PURGED").withValue("anchor_link_id", 10L).withValue("anchor_hash", Hashes.GENESIS))); + audit(11); + QRecord anchored = seal(); + assertThat(anchored.getValueLong("from_link_id")).isEqualTo(11L); + assertThat(anchored.getValueString("prev_manifest_sha256")).isNull(); + assertThat(verify(anchored).result().passed()).isTrue(); + STORE.insert(WormAuditTables.PURGE, List.of(new QRecord().withValue("outcome", "PURGED").withValue("anchor_link_id", 20L).withValue("anchor_hash", Hashes.GENESIS))); + assertThatThrownBy(() -> SealedExporter.scope(STORE)).hasMessage("Unexported links were purged"); + } + + + /******************************************************************************* + ** Exercise refusesUnsupportedAndIncorrectLockEchoAndPartialUpload. + *******************************************************************************/ + @Test + void refusesUnsupportedAndIncorrectLockEchoAndPartialUpload() throws Exception + { + audit(1); + new WormAuditChainer(config).chainOnce(NOW); + storage.supported = false; + assertThat(new SealedExporter(config, storage).run("SCHEDULE", NOW).message()).contains("does not support Object Lock"); + assertThat(storage.objects).isEmpty(); + storage.supported = true; + storage.echoMode = "GOVERNANCE"; + assertThat(new SealedExporter(config, storage).run("USER", NOW).message()).contains("did not echo COMPLIANCE"); + storage.echoMode = "COMPLIANCE"; + storage.shortenSeconds = 1; + assertThat(new SealedExporter(config, storage).run("USER", NOW).failed()).isTrue(); + storage.shortenSeconds = 0; + storage.objects.clear(); + storage.failPut = 2; + assertThat(new SealedExporter(config, storage).run("SCHEDULE", NOW).message()).doesNotContain("CANARY").contains("storage or database"); + assertThat(STORE.all(WormAuditTables.EXPORT)).isEmpty(); + assertThat(ProcessesAndAlertsTest.Sink.raised).hasSize(4); + assertThat(ProcessesAndAlertsTest.Sink.beats).hasSize(2); + } + + + /******************************************************************************* + ** Exercise tamperedObjectsBrokenManifestChainAndLockRemoval. + *******************************************************************************/ + @Test + void tamperedObjectsBrokenManifestChainAndLockRemoval() throws Exception + { + audit(1); + QRecord first = seal(); + String key = first.getValueString("object_prefix") + "links.jsonl"; + byte[] original = storage.objects.get(key); + storage.objects.put(key, "tampered".getBytes(StandardCharsets.UTF_8)); + assertThat(findings(first)).contains("object links.jsonl hash does not match the manifest"); + storage.objects.put(key, original); + storage.heads.get(key).withLockMode("GOVERNANCE"); + assertThat(findings(first)).contains("lacks the recorded COMPLIANCE retention"); + storage.heads.get(key).withLockMode("COMPLIANCE"); + audit(2); + QRecord second = seal(); + tamper(WormAuditTables.EXPORT, first.getValueLong("id"), "manifest_sha256", "f".repeat(64)); + assertThat(findings(second)).contains("prevManifestSha256 does not match"); + assertThat(findings(STORE.get(WormAuditTables.EXPORT, first.getValueLong("id")))).contains("manifest.json hash does not match"); + } + + + /******************************************************************************* + ** Exercise exactLiveDifferencesMissingRowsAndPurgedCopies. + *******************************************************************************/ + @Test + void exactLiveDifferencesMissingRowsAndPurgedCopies() throws Exception + { + audit(1); + detail(1, 1); + audit(2); + QRecord row = seal(); + tamper("auditDetail", 1, "newValue", "CANARY-PRIVATE"); + assertThat(findings(row)).contains("link 1 (audit 1): live content differs from the sealed copy (sealed 2026-10-14)").doesNotContain("CANARY"); + remove(WormAuditTables.CHAIN, 2); + assertThat(findings(row)).contains("link 2 is missing from the live chain"); + tamper(WormAuditTables.CHAIN, 1, "kind", "LATE_DETAIL"); + assertThat(findings(row)).contains("live chain kind differs"); + remove("audit", 1); + assertThat(findings(row)).contains("live audit header is missing"); + String head = (String) ExportFiles.parse(storage.objects.get(row.getValueString("manifest_key"))).get("headHash"); + STORE.insert(WormAuditTables.PURGE, List.of(new QRecord().withValue("outcome", "PURGED").withValue("anchor_link_id", 2L).withValue("anchor_hash", head))); + remove(WormAuditTables.CHAIN, 1); + remove("audit", 2); + var report = verify(row); + assertThat(report.result().passed()).isTrue(); + assertThat(report.purged()).isEqualTo(2); + var load = new ExportProcesses.VerifyLoad(storage); + load.runOnePage(new RunBackendStepInput().withRecords(List.of(row)), new RunBackendStepOutput()); + assertThat(load.getProcessSummary(new RunBackendStepOutput(), true).toString()).contains("INFO", "2 links verified from the sealed copy only"); + } + + + /******************************************************************************* + ** Exercise refusesBadSourceAndDetectsMissingLiveDetails. + *******************************************************************************/ + @Test + void refusesBadSourceAndDetectsMissingLiveDetails() throws Exception + { + audit(1); + detail(1, 1); + new WormAuditChainer(config).chainOnce(NOW); + tamper("auditDetail", 1, "newValue", "changed"); + assertThat(new SealedExporter(config, storage).run("USER", NOW).message()).contains("content differs at link 1"); + assertThat(storage.objects).isEmpty(); + tamper("auditDetail", 1, "newValue", "new"); + QRecord row = seal(); + remove("auditDetail", 1); + assertThat(findings(row)).contains("link 1 (audit 1): live content is missing or invalid"); + } + + + /******************************************************************************* + ** Exercise spansPagesLateDetailsAndCheckpointBoundaries. + *******************************************************************************/ + @Test + void spansPagesLateDetailsAndCheckpointBoundaries() throws Exception + { + config.withBatchSize(1100).withCheckpointEveryLinks(500); + for(long id = 1; id <= 1100; id++) + { + audit(id); + } + new WormAuditChainer(config).chainOnce(NOW); + // The final 100 links need the hourly checkpoint. + new WormAuditChainer(config).chainOnce(NOW.plusSeconds(3601)); + QRecord row = seal(); + assertThat(row.getValueLong("to_link_id")).isEqualTo(1100); + assertThat(verify(row).result().getLinksChecked()).isEqualTo(1100); + detail(1, 1); + new WormAuditChainer(config).chainOnce(NOW.plusSeconds(7202)); + new WormAuditChainer(config).chainOnce(NOW.plusSeconds(10803)); + QRecord late = seal(); + assertThat(late.getValueLong("from_link_id")).isEqualTo(1101); + assertThat(verify(late).result().passed()).isTrue(); + } + + + /******************************************************************************* + ** Exercise reviewLoadSelectionAndSchedulesUseStandardEtl. + *******************************************************************************/ + @Test + void reviewLoadSelectionAndSchedulesUseStandardEtl() throws Exception + { + var instance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance(); + assertThat(config.getExportBucketAlias()).isEqualTo("worm_audit"); + assertThat(config.getExportPrefix()).isEqualTo("worm-audit"); + assertThat(instance.getProcess(ExportProcesses.EXPORT).getTableName()).isEqualTo(WormAuditTables.EXPORT); + assertThat(instance.getProcess(ExportProcesses.VERIFY).getMinInputRecords()).isEqualTo(1); + assertThat(instance.getProcess(ExportProcesses.DAILY).getSchedule().getCronTimeZoneId()).isEqualTo("UTC"); + assertThat(instance.getProcess(ExportProcesses.DAILY).getSchedule().getCronExpression()).isEqualTo("0 0 2 * * ?"); + var output = new RunBackendStepOutput(); + var input = new RunBackendStepInput().withRecords(List.of(new QRecord().withValue("id", 0L))); + var extract = new ExportProcesses.ExportExtract(); + assertThat(extract.doCount(input)).isEqualTo(1); + extract.setRecordPipe(new com.kingsrook.qqq.backend.core.actions.reporting.RecordPipe()); + extract.run(input, output); + var review = new ExportProcesses.ExportReview(); + review.runOnePage(input, output); + assertThat(review.getProcessSummary(output, false).toString()).contains("Nothing to seal"); + audit(1); + new WormAuditChainer(config).chainOnce(NOW); + review.runOnePage(input, output); + assertThat(review.getProcessSummary(output, false).toString()).contains("1 chain links will be sealed", "1 checkpoints"); + assertThat(STORE.all(WormAuditTables.EXPORT)).isEmpty(); + var load = new ExportProcesses.ExportLoad(storage); + assertThat(load.getProcessSummary(output, true)).isEmpty(); + load.runOnePage(input, output); + load.postRun(new BackendStepPostRunInput(input), new BackendStepPostRunOutput(output)); + assertThat(load.getProcessSummary(output, true).toString()).contains("1 links sealed in export"); + var rows = STORE.all(WormAuditTables.EXPORT); + var verifyInput = new RunBackendStepInput().withRecords(rows).addValue("recordIds", rows.getFirst().getValueString("id")); + assertThat(new ExportProcesses.VerifyExtract().getQueryFilter(verifyInput).getCriteria()).isNotEmpty(); + var verifyReview = new ExportProcesses.VerifyReview(); + verifyReview.runOnePage(verifyInput, output); + assertThat(verifyReview.getProcessSummary(output, false).toString()).contains("1 sealed exports"); + var verifyLoad = new ExportProcesses.VerifyLoad(storage); + verifyLoad.runOnePage(verifyInput, output); + assertThat(verifyLoad.getProcessSummary(output, true).toString()).contains("1 links verified"); + new ExportProcesses.Daily().run(input, output); + assertThat(output.getValueString("outcome")).isEqualTo("PASS"); + audit(2); + new WormAuditChainer(config).chainOnce(NOW); + new ExportProcesses.Daily().run(input, output); + assertThat(output.getValueString("outcome")).isEqualTo("FAIL"); + assertThat(ProcessesAndAlertsTest.Sink.beats).contains("worm_audit.export.daily"); + new ExportProcesses.ExportLoad(); + new ExportProcesses.VerifyLoad(); + } + + + /******************************************************************************* + ** Exercise malformedJsonIsValueFreeAndConfigRejectsUnsafePrefixes. + *******************************************************************************/ + @Test + void malformedJsonIsValueFreeAndConfigRejectsUnsafePrefixes() throws Exception + { + assertThatThrownBy(() -> ExportFiles.parse("{\"x\":\"CANARY\",\"x\":2}".getBytes(StandardCharsets.UTF_8))) + .hasMessage("Invalid sealed JSON").hasNoCause(); + assertThatThrownBy(() -> config.withExportPrefix("../escape").validate()).isInstanceOf(IllegalArgumentException.class); + config.withExportPrefix("evidence/audit").withExportBucketAlias("backup").withExportCron("0 5 2 * * ?").validate(); + assertThat(config.getExportBucketAlias()).isEqualTo("backup"); + try(var files = new ExportFiles()) + { + assertThatThrownBy(() -> files.file("../escape")).isInstanceOf(IllegalArgumentException.class); + } + } + + + /******************************************************************************* + ** Re-hash a tampered object and catalog to reach the independent signature checks. + *******************************************************************************/ + QRecord rewrite(QRecord row, String name, byte[] bytes) throws Exception + { + String prefix = row.getValueString("object_prefix"); + storage.objects.put(prefix + name, bytes); + Map manifest = ExportFiles.parse(storage.objects.get(prefix + "manifest.json")); + Map hashes = new LinkedHashMap<>((Map) manifest.get("files")); + hashes.put(name, java.util.HexFormat.of().formatHex(Hashes.digest(bytes))); + manifest.put("files", hashes); + byte[] encoded = Jcs.bytes(manifest); + storage.objects.put(prefix + "manifest.json", encoded); + tamper(WormAuditTables.EXPORT, row.getValueLong("id"), "manifest_sha256", java.util.HexFormat.of().formatHex(Hashes.digest(encoded))); + return (STORE.get(WormAuditTables.EXPORT, row.getValueLong("id"))); + } + + + /******************************************************************************* + ** Exercise independentSignaturesMerkleAndSealedContentChecks. + *******************************************************************************/ + @Test + void independentSignaturesMerkleAndSealedContentChecks() throws Exception + { + audit(1); + QRecord row = seal(); + String prefix = row.getValueString("object_prefix"); + byte[] originalCheckpoint = storage.objects.get(prefix + "checkpoints.jsonl"); + Map checkpoint = ExportFiles.parse(originalCheckpoint); + checkpoint.put("signature", java.util.Base64.getEncoder().encodeToString(new byte[64])); + row = rewrite(row, "checkpoints.jsonl", (Jcs.string(checkpoint) + "\n").getBytes(StandardCharsets.UTF_8)); + assertThat(findings(row)).contains("checkpoint 1: signature does not verify"); + checkpoint = ExportFiles.parse(originalCheckpoint); + checkpoint.put("merkle_root", "a".repeat(64)); + row = rewrite(row, "checkpoints.jsonl", (Jcs.string(checkpoint) + "\n").getBytes(StandardCharsets.UTF_8)); + assertThat(findings(row)).contains("Merkle root or head hash differs"); + row = rewrite(row, "checkpoints.jsonl", originalCheckpoint); + Map document = ExportFiles.parse(storage.objects.get(prefix + "links.jsonl")); + Map content = (Map) document.get("content"); + ((Map) content.get("audit")).put("message", "CANARY-SEALED"); + row = rewrite(row, "links.jsonl", (Jcs.string(document) + "\n").getBytes(StandardCharsets.UTF_8)); + assertThat(findings(row)).contains("sealed content differs from the chained hash").doesNotContain("CANARY"); + storage.objects.remove(prefix + "links.jsonl"); + assertThat(findings(row)).contains("cannot be read or has invalid structure").doesNotContain("CANARY"); + } + + + /******************************************************************************* + ** Exercise concurrentExportersDoNotOverlapAndMultipleSelectionsPersistSeparately. + *******************************************************************************/ + @Test + void concurrentExportersDoNotOverlapAndMultipleSelectionsPersistSeparately() throws Exception + { + audit(1); + new WormAuditChainer(config).chainOnce(NOW); + var instance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance(); + var workers = java.util.concurrent.Executors.newFixedThreadPool(2); + try + { + var jobs = new java.util.ArrayList>(); + for(int i = 0; i < 2; i++) + { + jobs.add(workers.submit(() -> + { + com.kingsrook.qqq.backend.core.context.QContext.init(instance, new com.kingsrook.qqq.backend.core.model.session.QSession()); + try + { + return (new SealedExporter(config, storage).run("USER", NOW)); + } + finally + { + com.kingsrook.qqq.backend.core.context.QContext.clear(); + } + })); + } + int sealed = 0; + for(var job : jobs) + { + var result = job.get(10, java.util.concurrent.TimeUnit.SECONDS); + assertThat(result.failed()).as(result.message()).isFalse(); + sealed += result.row() == null ? 0 : 1; + } + assertThat(sealed).isEqualTo(1); + } + finally + { + workers.shutdownNow(); + } + audit(2); + seal(); + var load = new ExportProcesses.VerifyLoad(storage); + load.runOnePage(new RunBackendStepInput().withRecords(STORE.all(WormAuditTables.EXPORT)), new RunBackendStepOutput()); + assertThat(STORE.all(WormAuditTables.VERIFICATION)).hasSize(2) + .allMatch(r -> "PASS".equals(r.getValueString("outcome"))); + assertThat(STORE.all(WormAuditTables.VERIFICATION)).extracting(r -> r.getValueLong("export_id")).doesNotHaveDuplicates(); + } + + + /******************************************************************************* + ** Exercise h2TransactionRoundTripAndFailedPutHasNoCatalogRow. + *******************************************************************************/ + @Test + void h2TransactionRoundTripAndFailedPutHasNoCatalogRow() throws Exception + { + String url = "jdbc:h2:mem:exports_" + java.util.UUID.randomUUID() + ";DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE"; + var instance = RdbmsTestSupport.instance(url, "sa", "", config, false); + try(var connection = java.sql.DriverManager.getConnection(url, "sa", "")) + { + RdbmsTestSupport.createTables(connection, instance, false); + com.kingsrook.qqq.backend.core.context.QContext.init(instance, new com.kingsrook.qqq.backend.core.model.session.QSession()); + WormAuditTestSupport.lookups(); + audit(1); + detail(1, 1); + QRecord row = seal(); + assertThat(verify(row).result().passed()).isTrue(); + assertThat(verify(STORE.get(WormAuditTables.EXPORT, row.getValueLong("id"))).result().passed()).isTrue(); + connection.createStatement().execute("UPDATE audit_detail SET new_value='changed' WHERE id=1"); + assertThat(findings(row)).contains("link 1 (audit 1): live content differs"); + audit(2); + new WormAuditChainer(config).chainOnce(NOW); + storage.failPut = storage.objects.size() + 1; + assertThat(new SealedExporter(config, storage).run("USER", NOW).failed()).isTrue(); + assertThat(STORE.all(WormAuditTables.EXPORT)).hasSize(1); + } + } + + + /******************************************************************************* + ** Exercise realAdapterRequiresBackupRoleAndPositiveCapability. + *******************************************************************************/ + @Test + void realAdapterRequiresBackupRoleAndPositiveCapability() throws Exception + { + var bucket = new com.kingsrook.qbits.objectstorage.config.StorageBucketConfig("worm_audit") + .withConnection("test").withBucketName("test-bucket").withIndexed(false) + .withRole(com.kingsrook.qbits.objectstorage.config.BucketRole.BACKUP); + System.setProperty("worm.unit.access", "unit-access"); + System.setProperty("worm.unit.secret", "unit-secret"); + var server = com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1", 0), 0); + var responseVersion = new java.util.concurrent.atomic.AtomicReference<>("sealed-version"); + var requested = new java.util.concurrent.CopyOnWriteArrayList(); + server.createContext("/test-bucket/links.jsonl", exchange -> + { + requested.add(exchange.getRequestURI().getRawQuery()); + exchange.getResponseHeaders().add("x-amz-version-id", responseVersion.get()); + byte[] bytes = "sealed fixture".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); + try(var output = exchange.getResponseBody()) + { + output.write(bytes); + } + }); + server.start(); + try + { + com.kingsrook.qbits.objectstorage.api.ObjectStorage.configureStandalone( + new com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig().withBackendName("memory") + .withAllowedSecretRefPatterns(List.of("prop.worm.unit.*")) + .withConnection(new com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig("test") + .withEndpoint("http://127.0.0.1:" + server.getAddress().getPort()).withRegion("us-east-1") + .withAddressingStyle(com.kingsrook.qbits.objectstorage.config.AddressingStyle.PATH) + .withProviderType(com.kingsrook.qbits.objectstorage.config.ProviderType.MINIO) + .withAccessKeyRef("${prop.worm.unit.access}").withSecretKeyRef("${prop.worm.unit.secret}")) + .withBucket(bucket)); + var adapter = new com.kingsrook.qbits.wormaudit.export.ObjectStorageSealedStore("worm_audit", "unit"); + assertThat(adapter.supportsLock()).isFalse(); + com.kingsrook.qbits.objectstorage.internal.CapabilityStore.record(bucket, + com.kingsrook.qbits.objectstorage.api.Capability.OBJECT_LOCK, + com.kingsrook.qbits.objectstorage.api.CapabilityStatus.SUPPORTED, "unit fixture"); + assertThat(adapter.supportsLock()).isTrue(); + try(var input = adapter.getVersion("links.jsonl", "sealed-version")) + { + assertThat(new String(input.readAllBytes(), StandardCharsets.UTF_8)).isEqualTo("sealed fixture"); + } + responseVersion.set("different-version"); + assertThatThrownBy(() -> adapter.getVersion("links.jsonl", "sealed-version")) + .hasMessage("Storage returned a different object version"); + assertThatThrownBy(() -> adapter.getVersion("links.jsonl", null)).hasMessage("Missing sealed object version"); + assertThat(requested).containsExactly("versionId=sealed-version", "versionId=sealed-version"); + bucket.withRole(com.kingsrook.qbits.objectstorage.config.BucketRole.PRIVATE); + assertThat(adapter.supportsLock()).isFalse(); + } + finally + { + server.stop(0); + com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime.reset(); + System.clearProperty("worm.unit.access"); + System.clearProperty("worm.unit.secret"); + } + } +} + diff --git a/src/test/java/com/kingsrook/qbits/wormaudit/WormAuditQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/wormaudit/WormAuditQBitProducerTest.java index 08b6bd1..575242f 100644 --- a/src/test/java/com/kingsrook/qbits/wormaudit/WormAuditQBitProducerTest.java +++ b/src/test/java/com/kingsrook/qbits/wormaudit/WormAuditQBitProducerTest.java @@ -20,71 +20,144 @@ package com.kingsrook.qbits.wormaudit; - -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; +import java.util.List; +import com.kingsrook.qbits.wormaudit.ddl.WormAuditDdl; +import com.kingsrook.qbits.wormaudit.model.WormAuditTables; +import com.kingsrook.qbits.wormaudit.processes.WormAuditProcesses; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface; -import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; - +import static org.assertj.core.api.Assertions.assertThatThrownBy; /******************************************************************************* - ** Verify the scaffold's registration and configuration contract. + ** Tests and fixtures for worm audit qbit producer test. *******************************************************************************/ class WormAuditQBitProducerTest { /******************************************************************************* - ** Produce the identity and exact config into an otherwise empty instance. + ** Verify metadata and defaults. *******************************************************************************/ @Test - void producesQBitWithoutTables() + void metadataAndDefaults() throws Exception { - QInstance instance = new QInstance(); - WormAuditQBitConfig config = new WormAuditQBitConfig(); - WormAuditQBitProducer producer = new WormAuditQBitProducer().withQBitConfig(config); - MetaDataProducerMultiOutput output = producer.produce(instance); - QBitMetaData qBit = instance.getQBits().get("com.kingsrook.qbits:qbit-worm-audit"); - - assertThat(qBit).isNotNull(); - assertThat(qBit.getGroupId()).isEqualTo("com.kingsrook.qbits"); - assertThat(qBit.getArtifactId()).isEqualTo("qbit-worm-audit"); - assertThat(qBit.getVersion()).isEqualTo("0.1.0"); - assertThat(qBit.getConfig()).isSameAs(config); - assertThat(producer.getQBitConfig()).isSameAs(config); - assertThat(producer.isEnabled()).isTrue(); - assertThat(producer.getNamespace()).isNull(); - assertThat(output.getEach(QBitMetaData.class)).containsExactly(qBit); - assertThat(output.getEach(QTableMetaData.class)).isEmpty(); - assertThat(instance.getTables()).isEmpty(); + var config = new WormAuditQBitConfig(); + QInstance instance = WormAuditTestSupport.init(config); assertThat(config.getRetentionYears()).isEqualTo(6); assertThat(config.getTableMetaDataCustomizer()).isNull(); + assertThat(config.getSigningKey()).isNull(); + assertThat(config.getTrustedPublicKeys()).isEmpty(); + assertThat(config.getChainIntervalSeconds()).isEqualTo(30); + assertThat(config.getSettleHorizonSeconds()).isEqualTo(60); + assertThat(config.getCheckpointEveryLinks()).isEqualTo(10000); + assertThat(config.getCheckpointEveryMinutes()).isEqualTo(60); + assertThat(config.getBatchSize()).isEqualTo(1000); + assertThat(config.getLateDetailLookbackHours()).isEqualTo(24); + assertThat(config.getVerifyCron()).isEqualTo("0 0 3 * * ?"); + assertThat(config.getAppRole()).isEqualTo("app"); + assertThat(instance.getQBits().get("com.kingsrook.qbits:qbit-worm-audit").getConfig()) + .isSameAs(config); + assertThat(instance.getApp("worm_audit").getSections()) + .extracting(s -> s.getName()) + .containsExactlyElementsOf(WormAuditTables.NAV); + assertThat(instance.getApp("worm_audit").getChildren()) + .allMatch( + c -> + c.getName().startsWith("worm_audit_") + && !c.getName().contains("verify")); + assertThat(instance.getProcess(WormAuditProcesses.VERIFY).getTableName()) + .isEqualTo(WormAuditTables.CHAIN); + assertThat(instance.getProcess(WormAuditProcesses.CHAIN).getSchedule().getRepeatSeconds()) + .isEqualTo(30); + assertThat(instance.getProcess(WormAuditProcesses.DAILY).getSchedule().getCronExpression()) + .isEqualTo(config.getVerifyCron()); + for(String name : + List.of( + "audit", + "auditDetail", + "auditUser", + "auditTable", + WormAuditTables.CHAIN, + WormAuditTables.CHECKPOINT, + WormAuditTables.EXPORT, + WormAuditTables.VERIFICATION, + WormAuditTables.PURGE)) + { + var table = instance.getTable(name); + assertThat( + table.isCapabilityEnabled( + instance.getBackendForTable(name), Capability.TABLE_UPDATE)) + .isFalse(); + assertThat( + table.isCapabilityEnabled( + instance.getBackendForTable(name), Capability.TABLE_DELETE)) + .isFalse(); + if(name.startsWith("worm_audit_")) + { + assertThat(table.getPermissionRules().getPermissionBaseName()).isEqualTo(name); + assertThat( + table.isCapabilityEnabled( + instance.getBackendForTable(name), Capability.TABLE_INSERT)) + .isFalse(); + assertThat(table.getSections()).isNotEmpty(); + } + } } - - /******************************************************************************* - ** Configured instances may be namespaced; unconfigured scanning is disabled. + ** Verify customizations and validation. *******************************************************************************/ @Test - void supportsNamespacedConfiguration() + void customizationsAndValidation() throws Exception { assertThat(new WormAuditQBitProducer().isEnabled()).isFalse(); - MetaDataCustomizerInterface customizer = (instance, table) -> table; - WormAuditQBitConfig config = new WormAuditQBitConfig() - .withTableMetaDataCustomizer(customizer) - .withRetentionYears(7); - WormAuditQBitProducer producer = new WormAuditQBitProducer() - .withQBitConfig(config) - .withNamespace("test"); - QInstance instance = new QInstance(); + var config = + new WormAuditQBitConfig() + .withRetentionYears(7) + .withChainIntervalSeconds(45) + .withSettleHorizonSeconds(0) + .withBatchSize(13) + .withCheckpointEveryLinks(5) + .withCheckpointEveryMinutes(2) + .withVerifyCron("0 1 3 * * ?") + .withAppRole("runtime") + .withSchedulerName("jobs") + .withCoreAuditTableNames(java.util.Map.of("audit", "custom_audit")) + .withTableMetaDataCustomizer( + (instance, table) -> + table.withLabel("custom") + .withCapabilities(Capability.TABLE_UPDATE)); + var instance = + WormAuditTestSupport.core( + new com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData() + .withName("memory") + .withBackendType( + com.kingsrook.qqq.backend.core.modules.backend + .implementations.memory.MemoryBackendModule.class)); + var producer = + new WormAuditQBitProducer() + .withNamespace("test") + .withQBitConfig(config.withBackendName("memory")); producer.produce(instance); - assertThat(producer.getNamespace()).isEqualTo("test"); - assertThat(instance.getQBits().get("com.kingsrook.qbits:qbit-worm-audit:test").getConfig()).isSameAs(config); - assertThat(config.getTableMetaDataCustomizer()).isSameAs(customizer); - assertThat(config.getRetentionYears()).isEqualTo(7); - assertThat(instance.getTables()).isEmpty(); + assertThat(producer.getQBitConfig()).isSameAs(config); + assertThat(producer.isEnabled()).isTrue(); + assertThat(config.getCoreAuditTableNames()).containsEntry("audit", "custom_audit"); + assertThat(instance.getTable(WormAuditTables.CHAIN).getLabel()).isEqualTo("custom"); + assertThat( + instance.getTable(WormAuditTables.CHAIN) + .isCapabilityEnabled( + instance.getBackend("memory"), Capability.TABLE_UPDATE)) + .isFalse(); + assertThatThrownBy(() -> new WormAuditQBitConfig().validate()) + .isInstanceOf(IllegalArgumentException.class); + assertThat(config.withLateDetailLookbackHours(48).getLateDetailLookbackHours()).isEqualTo(48); + assertThatThrownBy(() -> config.withLateDetailLookbackHours(0).validate()).isInstanceOf(IllegalArgumentException.class); + config.withLateDetailLookbackHours(24); + assertThatThrownBy(() -> config.withRetentionYears(0).validate()) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> WormAuditDdl.resolveNames(new WormAuditQBitConfig(), new QInstance())) + .isInstanceOf(IllegalArgumentException.class); } } diff --git a/src/test/java/com/kingsrook/qbits/wormaudit/WormAuditTestSupport.java b/src/test/java/com/kingsrook/qbits/wormaudit/WormAuditTestSupport.java new file mode 100644 index 0000000..7350e96 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/wormaudit/WormAuditTestSupport.java @@ -0,0 +1,136 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.wormaudit; + +import com.kingsrook.qbits.wormaudit.chain.AuditStore; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; + +import java.time.Instant; +import java.util.List; + +class WormAuditTestSupport { + static final Instant OLD = Instant.parse("2020-01-02T03:04:05.123456Z"); + static final AuditStore STORE = new AuditStore(null); + + static QInstance init(WormAuditQBitConfig config) throws Exception { + MemoryRecordStore.fullReset(); + QInstance instance = + core( + new QBackendMetaData() + .withName("memory") + .withBackendType(MemoryBackendModule.class)); + new WormAuditQBitProducer() + .withQBitConfig(config.withBackendName("memory")) + .produce(instance); + QContext.init(instance, new QSession()); + lookups(); + return (instance); + } + + static QInstance core(QBackendMetaData backend) throws Exception { + QInstance instance = new QInstance(); + instance.withInstanceDefaultAuthentication( + new com.kingsrook.qqq.backend.core.model.metadata.authentication + .QAuthenticationMetaData() + .withType( + com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType + .FULLY_ANONYMOUS) + .withName("anon")); + instance.addBackend(backend); + new AuditsMetaDataProvider().defineAll(instance, backend.getName(), null); + instance.getTable("audit") + .withField(new QFieldMetaData("clientChannel", QFieldType.STRING)); + return (instance); + } + + static void lookups() throws Exception { + STORE.insert( + "auditTable", + List.of( + new QRecord() + .withValue("id", 1) + .withValue("name", "cms_page") + .withValue("label", "Page"))); + STORE.insert( + "auditUser", List.of(new QRecord().withValue("id", 1).withValue("name", "james"))); + } + + static QRecord audit(long id) throws Exception { + QRecord row = + new QRecord() + .withValue("id", id) + .withValue("auditTableId", 1) + .withValue("auditUserId", 1) + .withValue("recordId", 17) + .withValue("message", "Record was Edited") + .withValue("timestamp", OLD.plusSeconds(id)); + return (STORE.insert("audit", List.of(row)).getFirst()); + } + + static QRecord detail(long id, long auditId) throws Exception { + return (STORE.insert( + "auditDetail", + List.of( + new QRecord() + .withValue("id", id) + .withValue("auditId", auditId) + .withValue("fieldName", "title") + .withValue("message", "Changed Title") + .withValue("oldValue", "old") + .withValue("newValue", "new"))) + .getFirst()); + } + + static void tamper(String table, long id, String field, java.io.Serializable value) + throws Exception { + MemoryRecordStore.getInstance() + .update( + new UpdateInput(table) + .withRecords( + List.of( + new QRecord() + .withValue( + "id", + table.equals("auditTable") + || table.equals( + "auditUser") + ? (java.io.Serializable) + (int) id + : id) + .withValue(field, value))), + false); + } + + static void remove(String table, long id) throws Exception { + MemoryRecordStore.getInstance().delete(new DeleteInput(table).withPrimaryKeys(List.of(id))); + } +} diff --git a/src/test/resources/log4j2-test.xml b/src/test/resources/log4j2-test.xml new file mode 100644 index 0000000..7499aa9 --- /dev/null +++ b/src/test/resources/log4j2-test.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + +