Compare commits

...
Author SHA1 Message Date
CI 82d780c05d Bump version to 0.1.0-RC.7 [skip ci] 2026-11-14 08:25:01 +00:00
james.maes 1c5304992d Merge pull request 'fix: a rule read that asks for its own rows no longer recurses (release/0.1 port of #43)' (#44) from fix/reentrant-rule-reload-0.1 into release/0.1
build / maven (push) Successful in 1m54s
maven / build (push) Successful in 1m54s
2026-11-14 08:23:08 +00:00
james.maesandClaude Fable 5.2 db26a1dc2e fix: a rule read that asks for its own rows no longer recurses
build / maven (push) Successful in 1m22s
maven / build (push) Successful in 1m22s
Reading the rule rows a node keeps (classifications, active retention rules,
policies) is a query on a secured-data table, and other code runs inside that
query: a virtual field value provider or a post-query customizer that another
QBit or the host put on the table. When that code runs a query with filter
criteria, core validates the operands of classified fields and asks this QBit
for the classification of the field, which needs the rows that are being
read. The version of the rows was recorded only after the read returned, so
the nested question started the read again, and that read asked again:
StackOverflowError, and the same on every later request, since nothing was
ever recorded. One classification row was enough (core runs no value provider
for an empty result).

- System reads of the QBit's own tables (SystemWrites.query, the audit key
  lookup) name the stored fields of the table. Core fills a virtual field only
  for a query that selects it, so no value provider runs inside a system read.
- RuleRows holds one kind of rule rows and is used for all three kinds, which
  had the same shape. A question asked from inside a read is answered from the
  rows held when the read began (none before the first read) and never starts
  another read. The owner's monitor makes that answer private to the reading
  thread: every other thread waits and gets the rows the read returns.
- A read that fails records nothing and clears the reading mark, so the next
  question reads again. The rows held before are not given to a request in
  place of rows that could not be read.
- RetentionRules.active: a question asked from inside a transaction's read of
  its own rule edits is answered from the rules every thread shares instead
  of reading the edits again.

Who can be given the nested answer: only code on the reading thread, inside
SystemWrites.call, where the session is the system session and the resolver
answers FULL without looking at a classification. No field access decision
for a person is made from it; the request that started the read, and every
request on another thread, gets the rows the read returns.

Tests (SecuredDataRuleReloadTest): 9 of the 11 end in StackOverflowError
without the fix. They cover a value provider and a post-query customizer on
the classification, retention rule and policy tables, the answer a nested
question gets in both directions of a change, the session it is asked in, a
second thread waiting for the read, and failed reads (an exception and an
Error) being read again.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 01:56:49 -06:00
CI f7c7ada8ca Bump version to 0.1.0-RC.6 [skip ci] 2026-11-10 21:39:44 +00:00
james.maes 726a84889d Merge pull request 'Merge develop into release/0.1 for 0.1.0-RC.6 (#41 performance fix)' (#42) from merge/develop-into-release-0.1-rc6 into release/0.1
build / maven (push) Successful in 1m37s
maven / build (push) Successful in 1m37s
2026-11-10 21:38:14 +00:00
james.maesandClaude Fable 5.2 41b39d535b Merge develop into release/0.1 for 0.1.0-RC.6 (#41 request-scoped fingerprint, cached rule rows)
build / maven (push) Successful in 1m27s
maven / build (push) Successful in 1m27s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 15:37:45 -06:00
james.maes 4cd0fd1f54 Merge pull request 'perf: keep the access fingerprint for the request and read rule rows once per change' (#41) from perf/request-scoped-resolver-and-cached-rules into develop
build / maven (push) Successful in 1m55s
maven / build (push) Successful in 1m56s
2026-11-10 21:37:13 +00:00
james.maesandClaude Fable 5.2 ab283c79b9 perf: keep the access fingerprint for the request and read rule rows once per change
build / maven (push) Successful in 1m32s
maven / build (push) Successful in 1m32s
Core asks the field access resolver for its fingerprint before every field
decision (FieldAccessHelper.getDecision), that is once per field of every
record of a result and of every table in the metaData document. Each call
recomputed it: a JSON key, a permission lookup for every table, another JSON
document and a digest. Core also asks for the retention rules of a field on
every audited field and every filter criterion, and each question was a
query.

- getAccessFingerprint keeps its value in the request context (QContext) and
  returns it while the session, its permission set, the channel, the policy
  and classification versions are unchanged and no active break-glass grant
  has expired. Nothing outlives the request.
- RetentionRules.active answers from the active rules the runtime read once
  (SecuredDataRuntime.activeRetentionRules). A rule write clears them after
  commit; a thread that edits rules inside a transaction reads its own edits.
- Policy, classification and retention changes are announced on QQQ's cluster
  bus (ClusterCaches, memo-invalidate), so every node reads again. Before,
  the versions were node-local and another node kept old rules until restart.
- No node uses rule rows older than 30 seconds without reading them again
  (the safety net for a lost event).
- A read that fails throws and replaces nothing: never an empty rule list.

Measured in one JVM with GG Forge's composed instance (222 tables, secured
data and collaboration wired, in-memory backend, platform administrator),
released 0.1.0-RC.4 against this change, same machine, back to back:
metaData request 866.6 ms to 125.3 ms (930 MB to 74 MB allocated, 96 to 34
queries); a 300-row list 1,566 ms to 24 ms; a 27-row list 174 to 230 ms down
to 13 to 17 ms; queries per list 23 to 8.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 15:22:22 -06:00
CI 8ae8326f4e Bump version to 0.1.0-RC.5 [skip ci] 2026-11-07 18:29:11 +00:00
james.maes 92c54535ce Merge pull request 'Merge develop into release/0.1 (Gitea-aligned RC)' (#40) from align/develop-into-release-0.1 into release/0.1
build / maven (push) Successful in 8m29s
maven / build (push) Successful in 3m28s
2026-11-07 18:16:52 +00:00
james.maesandClaude Opus 5.5 65025ad61f Merge develop into release/0.1 (Gitea-aligned RC)
build / maven (push) Canceled after 0s
maven / build (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:16:29 -06:00
james.maes eb98ec5fd0 Merge pull request 'build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)' (#39) from deps/align-gitea into develop
build / maven (push) Successful in 7m39s
maven / build (push) Successful in 4m16s
2026-11-07 18:16:02 +00:00
james.maesandClaude Opus 5.5 f09fe866ad build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)
build / maven (push) Successful in 3m34s
maven / build (push) Successful in 3m35s
pom.xml: parent -> 2.2.1
pom.xml: qqq.snapshot.version: 4.1.0-RC.15 -> 4.1.0-RC.26
pom.xml: qqq-frontend-next: 1.0.0-RC.14 -> 1.0.0-RC.38
pom.xml: added qqq.version=4.1.0-RC.26 (parent BOM import)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:08:54 +00:00
james.maes c4ebc6833c Merge pull request 'ci: pin shared workflow to v1' (#38) from ci/pin-v1-develop into develop
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 13s
QRun-IO/ci-policy: native / native-result (push) Successful in 15s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 11s
build / maven (push) Successful in 5m40s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 5m40s
2026-11-06 16:10:31 +00:00
james.maesandClaude Opus 5.5 2431b4723a ci: pin shared workflow to v1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
build / maven (push) Successful in 3m34s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
maven / build (push) Successful in 3m34s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:13:29 +00:00
james.maes 9fb168b96d Merge pull request 'ci: use shared ci-workflows Maven workflow' (#37) from ci/ci-workflows into develop
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 4s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
build / maven (push) Successful in 2m46s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (push) Successful in 6s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
maven / build (push) Successful in 2m46s
2026-11-05 23:17:59 +00:00
CI e0459a99ff ci: rerun shared workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Failing after 2s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 1s
build / maven (push) Successful in 2m19s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Failing after 1s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Successful in 2m19s
2026-11-05 16:26:50 -06:00
james.maesandClaude Opus 5.5 84e2311d84 ci: use shared ci-workflows Maven workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
build / maven (push) Failing after 2s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Failing after 2s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 2s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
maven / build (push) Failing after 2s
QRun-IO/ci-policy: native / native-result (pull_request) Failing after 1s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Replace the platform-ci release callers with one caller of
KofTwentyTwo/ci-workflows maven.yaml (qqq-orb port).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:08:11 +00:00
forge-recovery 5987cb53ac ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI / secrets (push) Successful in 26s
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
Shared Java Maven CI / verify (push) Successful in 2m17s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 1m55s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
qrun-release / postpublish (push) Successful in 2m4s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 26s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
Shared Java Maven CI / gate (push) Successful in 15s
qrun-release / release (push) Successful in 53m0s
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 12s
QRun-IO/ci-policy: ci / java-maven-6 (push) Successful in 52m55s
Shared QRun source version / version (push) Successful in 2m4s
2026-11-03 18:10:35 +00:00
forge-recovery a087988f11 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 2m5s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 12s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 29s
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 14s
QRun-IO/ci-policy: ci / java-maven-6 (push) Successful in 1h13m45s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
2026-11-02 22:35:22 +00:00
forge-recoveryandCodex 894c3023b4 ci: enroll shared Gitea release-candidate lifecycle
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 25s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 2m28s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 20s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Successful in 12m47s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 20:19:48 +00:00
15c2906fc1 ci: add the isolated postpublish job to the QRun release caller
Shared Java Maven CI / secrets (push) Failing after 16s
Shared Java Maven CI / verify (push) Canceled after 0s
qrun-release / postpublish (push) Canceled after 0s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 20s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m56s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Successful in 23m20s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
Release candidate / release-rc (pull_request) Skipped
Shared Java Maven CI / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 2h30m11s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:52:40 +00:00
45015b6912 ci: move the QRun release caller to the thin v2 contract
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-9 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m1s
Shared Java Maven CI / secrets (push) Failing after 20s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 19s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Canceled after 0s
Shared Java Maven CI / verify (push) Canceled after 0s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
Shared Java Maven CI / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 27m5s
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Canceled after 26m44s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:20:41 +00:00
forge-recoveryandCodex 0f94818084 ci: add the identical QRun release caller
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 8s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-7 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Failing after 1s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 1s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 0s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Failing after 1m40s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 04:14:35 +00:00
james.maesandCodex 7b302ed2b1 ci: adopt managed orb 0.8 patch track (#30)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:29:10 -05:00
james.maesandCodex b917b51656 ci: adopt shared orb 0.7.18 (#29)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:47:03 -05:00
james.maesandCodex dc4734eef1 ci: adopt shared orb 0.7.17 (#28)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:16:47 -05:00
james.maesandCodex cb52c31cd8 ci: centralize release branch verification and workflows (#27)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 12:34:53 -05:00
james.maesandCodex eb912ebf07 ci: use the shared branch-aware CodeQL worker (#26)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:56:24 -05:00
james.maesandCodex 9091f26dd7 ci: adopt central orb 0.7.12 and stable gates (#24)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:53:36 -05:00
james.maesandCodex 4942b7e994 ci: centralize rc release completion in qqq orb (#23)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:25:10 -05:00
james.maesandCodex 0875069488 ci: adopt shared orb 0.7.11 (#22)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:59:13 -05:00
CircleCI feb3082c1b Bump version to 0.1.0-RC.4 [skip ci] 2026-10-16 16:54:47 +00:00
james.maesandClaude Opus 5.5 1343c0925e Merge develop into release/0.1 for 0.1.0-RC.4 (#19 lifecycle leak fix)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:51:58 -05:00
CircleCI c669c44b61 Bump version to 0.1.0-RC.3 [skip ci] 2026-10-16 16:41:56 +00:00
james.maesandClaude Opus 5.5 fdd5fb42e6 Merge develop into release/0.1 for 0.1.0-RC.3 (#18 virtual-field and access-log worker startup fixes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:38:49 -05:00
CircleCI fd60abaffa Bump version to 0.1.0-RC.2 [skip ci] 2026-10-15 21:38:55 +00:00
james.maesandClaude Opus 5.5 f7003e10af Merge develop into release/0.1 for 0.1.0-RC.2 (#16 UI API 1.6.0 extension 1.1.0, QQQ RC.15)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:35:07 -05:00
CircleCI 8417cfe032 Bump version to 0.1.0-RC.1 [skip ci] 2026-10-14 21:47:14 +00:00
16 changed files with 1491 additions and 55 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.7.3
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.3
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
+7
View File
@@ -0,0 +1,7 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+62 -14
View File
@@ -1,26 +1,74 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types: [closed]
branches: [develop]
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
secrets: inherit
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+2 -2
View File
@@ -1,5 +1,5 @@
profile: qbit
orb_version: 0.7.3
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
@@ -13,4 +13,4 @@ publish:
release_candidate: true
release: true
asciidoc: false
release_completion: false
release_completion: true
+6
View File
@@ -4,6 +4,12 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
## [Unreleased]
### Fixed: rule reads that ask for their own rows
- Reading the rule rows a node keeps (classifications, active retention rules, policies) no longer recurses when code that runs inside that read asks for the same rows. Another QBit's virtual field value provider, or a post-query customizer, on a secured-data table that ran a query with filter criteria did exactly that: with one classification row present, every request that needed a classification ended in a `StackOverflowError`.
- The QBit's system reads of its own tables (`SystemWrites.query`, the audit key lookup) name the stored fields of the table, so core runs no virtual field value provider inside them.
- A question asked from inside a rule read is answered from the rows held when that read began (none before the first read) and starts no other read. Only the reading thread, inside the QBit's system read, can be given that answer; every request gets the rows the read returns. The same holds for a transaction's read of its own retention rule edits.
- A rule read that fails is tried again by the next question instead of failing or recursing from then on.
### Fixed: access-log lifecycle
- The access-log worker and JVM shutdown hook hold the logger weakly, so an instance the host drops (tests, reloads) can be collected; its worker stops at the next tick.
- `withRegisterShutdownHook(false)` for hosts that call `SecuredDataRuntime.close()` themselves; `SecuredDataAccessLogger.isWorkerRunning()`.
+5 -4
View File
@@ -22,9 +22,10 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
<!-- QQQ 4.1.0-RC.15 ships UI API 1.6.0, field access and audit value protection -->
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
<revision>0.1.0-SNAPSHOT</revision>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
<revision>0.1.0-RC.7</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
@@ -34,7 +35,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.2.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -92,7 +93,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.14</version>
<version>1.0.0-RC.38</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -0,0 +1,102 @@
/*
* 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.secureddata;
import java.util.List;
import java.util.function.Supplier;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** One kind of rule rows a node keeps in memory (classifications, policies or
** active retention rules): read once per version, never used unread past
** SecuredDataRuntime.MAX_RULE_AGE, and safe against a read that asks for the
** same rows again.
**
** Not synchronized: every call must be made holding the owner's monitor (the
** runtime for classifications and retention rules, the resolver for policies).
** That monitor is what makes the re-entry answer below private to one thread:
** while a read is running, only the thread running it can get in here; every
** other thread waits for the monitor and is given the rows that read returns.
**
** Re-entry: reading rule rows is itself a query, and anything that runs inside
** it (a table customizer, a record-security lookup, a value provider) may ask a
** question that needs the very rows being read. Such a nested question is
** answered with the rows this node held when the read began - the answer every
** request was given until now, and no rows before the first read - and never
** starts another read. The request that started the read gets the new rows.
**
** Failure: a read that throws leaves the previous rows and version in place
** and is not remembered, so the next question reads again. The previous rows
** are never handed to a request in place of rows that could not be read.
*******************************************************************************/
final class RuleRows
{
private List<QRecord> rows = List.of();
private long version;
private long loadedAt;
private boolean loaded;
private boolean reading;
/*******************************************************************************
** The rows of this version, reading them when the rows held are of another
** version, too old, or were never read.
**
** @param currentVersion the owner's invalidation clock, read by the caller
** before this call: a change during the read leaves a
** version that no longer matches, so the next question
** reads again
** @param read the query; whatever it throws is thrown to the caller
*******************************************************************************/
List<QRecord> get(long currentVersion, Supplier<List<QRecord>> read)
{
if(loaded && version == currentVersion && !SecuredDataRuntime.tooOld(loadedAt))
{
return (rows);
}
if(reading)
{
///////////////////////////////////////////////////////////////////////////
// the read that is running on this thread asked for its own rows: give //
// it the rows held when it began, and do not start another read //
///////////////////////////////////////////////////////////////////////////
return (rows);
}
reading = true;
try
{
long startedAt = System.nanoTime();
List<QRecord> fresh = List.copyOf(read.get());
rows = fresh;
version = currentVersion;
loadedAt = startedAt;
loaded = true;
}
finally
{
reading = false;
}
return (rows);
}
}
@@ -124,7 +124,7 @@ public final class SecuredDataKeySource implements ClassifiedValueKeySource, Aut
try
{
List<QRecord> rows = SystemWrites.call(() -> new QueryAction().execute(new QueryInput(SecuredDataTables.AUDIT_KEY)
.withFilter(recordField(table, field, pk)).withShouldOmitHiddenFields(false)
.withFilter(recordField(table, field, pk)).withShouldOmitHiddenFields(false).withFieldNamesToInclude(SystemWrites.storedFields(SecuredDataTables.AUDIT_KEY))
.withTransaction(QContext.getQBackendTransaction())).getRecords());
if(rows.isEmpty())
{
@@ -74,10 +74,14 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
{
}
private long cachedVersion;
/** Used only inside methods synchronized on this resolver. */
private final RuleRows policyRows = new RuleRows();
private List<QRecord> policies = List.of();
private final Map<String, UserState> users = new HashMap<>();
/** Request-scoped (QContext) holder of the access fingerprint. */
private static final String FINGERPRINT_KEY = SecuredDataResolver.class.getName() + ".accessFingerprint";
/*******************************************************************************
@@ -288,16 +292,19 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
/*******************************************************************************
** Refresh policy rows once per version and matched state per relevant permissions.
** A read that fails throws and is tried again by the next call. A question
** asked from inside the read itself (see RuleRows) is answered from the
** policies held when the read began; whatever it matched for its user is
** dropped with every other user's state when the read returns.
*******************************************************************************/
private synchronized UserState state()
{
long version = runtime.policyVersion();
if(cachedVersion != version)
policies = policyRows.get(runtime.policyVersion(), () ->
{
policies = List.copyOf(SystemWrites.query(SecuredDataTables.POLICY, new QQueryFilter()));
List<QRecord> rows = SystemWrites.query(SecuredDataTables.POLICY, new QQueryFilter());
users.clear();
cachedVersion = version;
}
return (rows);
});
QSession session = QContext.getQSession();
TreeSet<String> held = new TreeSet<>();
for(QRecord policy : policies)
@@ -308,7 +315,7 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
held.add(permission);
}
}
String key = JsonUtils.toJson(List.of(userId(), held));
String key = userId() + '\u0000' + String.join("\u0000", held);
UserState state = users.get(key);
if(state == null)
{
@@ -356,11 +363,82 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
** Stable across unrelated permissions/records; includes channel and exact expiry.
*******************************************************************************/
@Override
public synchronized String getAccessFingerprint()
public String getAccessFingerprint()
{
state();
TreeSet<String> relevant = new TreeSet<>();
/////////////////////////////////////////////////////////////////////////////
// core asks for the fingerprint before every field decision (once per //
// field of every record of a result), so the value is kept for the //
// request - and only while everything it is derived from is unchanged //
/////////////////////////////////////////////////////////////////////////////
QSession session = QContext.getQSession();
String channel = Objects.requireNonNullElse(SecuredDataMetadata.channel(session), "");
Instant now = runtime.config().getClock().instant();
RequestFingerprint kept = QContext.getObject(FINGERPRINT_KEY, RequestFingerprint.class).orElse(null);
if(kept != null && kept.matches(this, session, channel, runtime.policyVersion(), runtime.classificationVersion(), now))
{
return (kept.value());
}
RequestFingerprint computed = computeAccessFingerprint(session, channel, now);
QContext.setObject(FINGERPRINT_KEY, computed);
return (computed.value());
}
/*******************************************************************************
** The fingerprint and what it was derived from. It stops matching when the
** session, its permissions, the channel, a policy or classification version
** changes, or when the first active break-glass grant expires.
*******************************************************************************/
private record RequestFingerprint(int resolver, QSession session, int permissionsIdentity, int permissionCount, String channel, long policyVersion,
long classificationVersion, Instant validUntil, String value) implements Serializable
{
/*******************************************************************************
** Whether the kept value is still the one a fresh computation would give.
*******************************************************************************/
boolean matches(SecuredDataResolver owner, QSession current, String currentChannel, long currentPolicyVersion, long currentClassificationVersion, Instant now)
{
return (resolver == System.identityHashCode(owner) && session == current && permissionsIdentity == identityOfPermissions(current) && permissionCount == countOfPermissions(current)
&& channel.equals(currentChannel) && policyVersion == currentPolicyVersion && classificationVersion == currentClassificationVersion && now.isBefore(validUntil));
}
}
/*******************************************************************************
** The permission set object of a session (it is replaced, not edited, when a
** session is given new permissions).
*******************************************************************************/
private static int identityOfPermissions(QSession session)
{
return (session == null || session.getPermissions() == null ? 0 : System.identityHashCode(session.getPermissions()));
}
/*******************************************************************************
** How many permissions a session holds (an edit in place changes it).
*******************************************************************************/
private static int countOfPermissions(QSession session)
{
return (session == null || session.getPermissions() == null ? -1 : session.getPermissions().size());
}
/*******************************************************************************
** Stable across unrelated permissions/records; includes channel and exact expiry.
*******************************************************************************/
private synchronized RequestFingerprint computeAccessFingerprint(QSession session, String channel, Instant now)
{
/////////////////////////////////////////////////////////////////////////////
// versions are read before the rows they describe, so a change during the //
// computation leaves a kept value that no longer matches //
/////////////////////////////////////////////////////////////////////////////
long policyVersion = runtime.policyVersion();
long classificationVersion = runtime.classificationVersion();
TreeSet<String> relevant = new TreeSet<>();
List<QRecord> breakGlass = state().breakGlass();
for(QRecord row : policies)
{
String permission = row.getValueString("permission_name");
@@ -380,12 +458,15 @@ public class SecuredDataResolver implements FieldAccessResolverInterface
}
}
}
List<String> breaks = activeBreakGlass().stream().map(row -> row.getValueString("id")).sorted().toList();
String payload = JsonUtils.toJson(List.of(userId(), relevant, runtime.policyVersion(), runtime.classificationVersion(), breaks,
Objects.requireNonNullElse(SecuredDataMetadata.channel(session), "")));
List<QRecord> activeBreaks = breakGlass.stream().filter(row -> "ACTIVE".equals(row.getValueString("status")))
.filter(row -> row.getValueInstant("expires_at") != null && row.getValueInstant("expires_at").isAfter(now)).toList();
List<String> breaks = activeBreaks.stream().map(row -> row.getValueString("id")).sorted().toList();
Instant validUntil = activeBreaks.stream().map(row -> row.getValueInstant("expires_at")).min(Instant::compareTo).orElse(Instant.MAX);
String payload = JsonUtils.toJson(List.of(userId(), relevant, policyVersion, classificationVersion, breaks, channel));
try
{
return (Base64.getUrlEncoder().withoutPadding().encodeToString(MessageDigest.getInstance("SHA-256").digest(payload.getBytes(StandardCharsets.UTF_8))));
String value = Base64.getUrlEncoder().withoutPadding().encodeToString(MessageDigest.getInstance("SHA-256").digest(payload.getBytes(StandardCharsets.UTF_8)));
return (new RequestFingerprint(System.identityHashCode(this), session, identityOfPermissions(session), countOfPermissions(session), channel, policyVersion, classificationVersion, validUntil, value));
}
catch(java.security.NoSuchAlgorithmException e)
{
@@ -21,9 +21,21 @@
package com.kingsrook.qbits.secureddata;
import java.lang.ref.WeakReference;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
import com.kingsrook.qbits.secureddata.support.SystemWrites;
import com.kingsrook.qqq.backend.core.cluster.ClusterBus;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import com.kingsrook.qqq.backend.core.cluster.ClusterEvent;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
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.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
@@ -34,16 +46,44 @@ import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaDa
public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCloseable
{
public static final String NAME = "securedDataRuntime";
private static final QLogger LOG = QLogger.getLogger(SecuredDataRuntime.class);
/////////////////////////////////////////////////////////////////////////////////
// the three sets of rule rows a node keeps in memory. A change clears them on //
// this node at once and, through QQQ's cluster bus (ClusterCaches), on every //
// other node. MAX_RULE_AGE is the safety net for a lost event: no node uses //
// rule rows older than that without reading them again. //
/////////////////////////////////////////////////////////////////////////////////
public static final String POLICY_CACHE = "securedData.policies";
public static final String CLASSIFICATION_CACHE = "securedData.classifications";
public static final String RETENTION_CACHE = "securedData.retentionRules";
public static final Duration MAX_RULE_AGE = Duration.ofSeconds(30);
static
{
ClusterCaches.shared(POLICY_CACHE, POLICY_CACHE, () -> clearOnThisNode(POLICY_CACHE), null);
ClusterCaches.shared(CLASSIFICATION_CACHE, CLASSIFICATION_CACHE, () -> clearOnThisNode(CLASSIFICATION_CACHE), null);
ClusterCaches.shared(RETENTION_CACHE, RETENTION_CACHE, () -> clearOnThisNode(RETENTION_CACHE), null);
}
private static final AtomicLong STARTUP_VERSION = new AtomicLong(System.currentTimeMillis());
private final AtomicLong classificationVersion = new AtomicLong(STARTUP_VERSION.incrementAndGet());
private final AtomicLong policyVersion = new AtomicLong(STARTUP_VERSION.incrementAndGet());
private final AtomicLong retentionVersion = new AtomicLong(STARTUP_VERSION.incrementAndGet());
private final transient WeakReference<QInstance> owner;
private final transient SecuredDataQBitConfig config;
private final transient SecuredDataResolver resolver;
private final transient SecuredDataKeySource keys = new SecuredDataKeySource();
private final transient SecuredDataAccessLogger logger;
private final transient SecuredDataAnomalies anomalies;
private transient long cachedClassificationVersion;
private transient List<com.kingsrook.qqq.backend.core.model.data.QRecord> classificationRows = List.of();
/////////////////////////////////////////////////////////////////////////////////
// both are used only inside methods synchronized on this runtime: one monitor //
// for the two, because reading either may ask for the other //
/////////////////////////////////////////////////////////////////////////////////
private final transient RuleRows classificationRows = new RuleRows();
private final transient RuleRows activeRetentionRules = new RuleRows();
@@ -53,6 +93,7 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
public SecuredDataRuntime(QInstance instance, SecuredDataQBitConfig config)
{
this.config = config;
this.owner = new WeakReference<>(instance);
this.resolver = new SecuredDataResolver(this);
this.anomalies = new SecuredDataAnomalies(config);
this.logger = new SecuredDataAccessLogger(instance, this);
@@ -137,6 +178,7 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
public void invalidateClassifications()
{
classificationVersion.incrementAndGet();
announce(CLASSIFICATION_CACHE);
}
@@ -147,23 +189,109 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
public void invalidatePolicies()
{
policyVersion.incrementAndGet();
announce(POLICY_CACHE);
}
/*******************************************************************************
** Read classification rows once per version, always as the system.
** A retention rule changed: forget the active rules here and on every node.
*******************************************************************************/
synchronized List<com.kingsrook.qqq.backend.core.model.data.QRecord> classificationRows()
public void invalidateRetentionRules()
{
long version = classificationVersion();
if(cachedClassificationVersion != version)
retentionVersion.incrementAndGet();
announce(RETENTION_CACHE);
}
/*******************************************************************************
** Version of the retention rules this node holds.
*******************************************************************************/
public long retentionVersion()
{
return (retentionVersion.get());
}
/*******************************************************************************
** Whether rows loaded at this System.nanoTime are too old to use unread.
*******************************************************************************/
static boolean tooOld(long loadedAtNanos)
{
return (System.nanoTime() - loadedAtNanos > MAX_RULE_AGE.toNanos());
}
/*******************************************************************************
** Every active retention rule, read once per version and at most MAX_RULE_AGE
** old. A read that fails throws and leaves nothing in place of the rules:
** callers never see "no rules" because the rules could not be read, and the
** next call reads again. A question asked from inside the read itself (see
** RuleRows) is answered with the rules held when the read began.
*******************************************************************************/
public synchronized List<QRecord> activeRetentionRules()
{
return (activeRetentionRules.get(retentionVersion(), () -> SystemWrites.query(SecuredDataTables.RETENTION_RULE, new QQueryFilter()
.withCriteria(new QFilterCriteria("is_active", QCriteriaOperator.EQUALS, true)))));
}
/*******************************************************************************
** Tell the other nodes (this node has already moved its version). A failure
** to send is logged: the other nodes then read again within MAX_RULE_AGE.
*******************************************************************************/
private void announce(String cache)
{
try
{
classificationRows = List.copyOf(com.kingsrook.qbits.secureddata.support.SystemWrites.query(
com.kingsrook.qbits.secureddata.metadata.SecuredDataTables.CLASSIFICATION, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()));
cachedClassificationVersion = version;
QInstance instance = owner.get();
if(instance != null)
{
ClusterBus.publish(instance, new ClusterEvent(ClusterEvent.TYPE_MEMO_INVALIDATE, cache, null));
}
}
return (classificationRows);
catch(RuntimeException e)
{
LOG.warn("Could not announce a secured-data rule change; other nodes read again within " + MAX_RULE_AGE.toSeconds() + " seconds", e);
}
}
/*******************************************************************************
** A rule change announced on the cluster bus (also by this node): move the
** version of the runtime of the instance the event arrived on.
*******************************************************************************/
private static void clearOnThisNode(String cache)
{
SecuredDataRuntime runtime = current();
if(runtime == null)
{
return;
}
switch(cache)
{
case POLICY_CACHE -> runtime.policyVersion.incrementAndGet();
case CLASSIFICATION_CACHE -> runtime.classificationVersion.incrementAndGet();
default -> runtime.retentionVersion.incrementAndGet();
}
}
/*******************************************************************************
** Read classification rows once per version, always as the system. A read
** that fails throws and is tried again by the next call. A question asked from
** inside the read itself (see RuleRows) is answered with the rows held when
** the read began, and never starts another read.
*******************************************************************************/
synchronized List<QRecord> classificationRows()
{
return (classificationRows.get(classificationVersion(), () -> SystemWrites.query(SecuredDataTables.CLASSIFICATION, new QQueryFilter())));
}
@@ -238,7 +238,8 @@ public class SecuredDataTableCustomizers implements TableCustomizerInterface
}
SecuredDataRuntime runtime = SecuredDataRuntime.current();
Runnable invalidate = SecuredDataTables.CLASSIFICATION.equals(table) ? runtime::invalidateClassifications
: SecuredDataTables.POLICY.equals(table) || SecuredDataTables.BREAK_GLASS.equals(table) ? runtime::invalidatePolicies : null;
: SecuredDataTables.POLICY.equals(table) || SecuredDataTables.BREAK_GLASS.equals(table) ? runtime::invalidatePolicies
: SecuredDataTables.RETENTION_RULE.equals(table) ? runtime::invalidateRetentionRules : null;
if(invalidate != null)
{
QBackendTransaction active = transaction == null ? QContext.getQBackendTransaction() : transaction;
@@ -248,6 +249,10 @@ public class SecuredDataTableCustomizers implements TableCustomizerInterface
}
else
{
if(SecuredDataTables.RETENTION_RULE.equals(table))
{
com.kingsrook.qbits.secureddata.retention.RetentionRules.editedInTransaction();
}
active.addAfterCommitCallback(invalidate);
}
}
@@ -58,15 +58,86 @@ public final class RetentionRules
return (names == null ? List.of() : Arrays.stream(names.split(",", -1)).map(String::trim).toList());
}
///////////////////////////////////////////////////////////////////////////////
// set on a thread that changed a rule inside a transaction: until that //
// transaction ends, the thread reads the rules through it (its own edits), //
// not the rules every other thread shares //
///////////////////////////////////////////////////////////////////////////////
private static final ThreadLocal<Boolean> EDITED_IN_TRANSACTION = ThreadLocal.withInitial(() -> false);
/** Set while this thread reads its own edits, so that a question from inside that read does not read them again. */
private static final ThreadLocal<Boolean> READING_OWN_EDITS = ThreadLocal.withInitial(() -> false);
/*******************************************************************************
** Fresh rules in the current transaction; edits take effect without a stale cache.
** The active rules of a table that name this field. Core asks this for every
** field of every audited write and every filter criterion of every query, so
** the active rules are read once (SecuredDataRuntime.activeRetentionRules: per
** change, and at most MAX_RULE_AGE old) instead of once per call. A committed
** change is seen at once on the node that made it and on every node the
** cluster event reaches; a transaction that edits rules reads its own edits.
** A failed read throws: it is never "no rules". A question asked from inside
** a read of the rules is answered from the rules held before that read (see
** RuleRows) and never starts another.
*******************************************************************************/
public static List<QRecord> active(String table, String field)
{
return (SystemWrites.query(SecuredDataTables.RETENTION_RULE, new QQueryFilter()
.withCriteria(new QFilterCriteria("is_active", QCriteriaOperator.EQUALS, true))
.withCriteria(new QFilterCriteria("table_name", QCriteriaOperator.EQUALS, table)))
.stream().filter(rule -> fields(rule).contains(field)).toList());
SecuredDataRuntime runtime = SecuredDataRuntime.current();
List<QRecord> rules;
if(runtime != null && (!readsOwnEdits() || READING_OWN_EDITS.get()))
{
//////////////////////////////////////////////////////////////////////////
// also the answer to a question asked from inside this thread's read //
// of its own edits (code that runs inside that query): the rules //
// every other thread shares, and never another read of the edits //
//////////////////////////////////////////////////////////////////////////
rules = runtime.activeRetentionRules();
}
else
{
READING_OWN_EDITS.set(true);
try
{
rules = SystemWrites.query(SecuredDataTables.RETENTION_RULE, new QQueryFilter()
.withCriteria(new QFilterCriteria("is_active", QCriteriaOperator.EQUALS, true))
.withCriteria(new QFilterCriteria("table_name", QCriteriaOperator.EQUALS, table)));
}
finally
{
READING_OWN_EDITS.remove();
}
}
return (rules.stream().filter(rule -> table != null && table.equals(rule.getValueString("table_name"))).filter(rule -> fields(rule).contains(field)).toList());
}
/*******************************************************************************
** A rule was written on this thread inside a transaction that is still open.
*******************************************************************************/
public static void editedInTransaction()
{
EDITED_IN_TRANSACTION.set(true);
}
/*******************************************************************************
** Whether this thread must read the rules through its open transaction.
*******************************************************************************/
private static boolean readsOwnEdits()
{
if(!EDITED_IN_TRANSACTION.get())
{
return (false);
}
if(QContext.getQBackendTransaction() == null)
{
EDITED_IN_TRANSACTION.remove();
return (false);
}
return (true);
}
/*******************************************************************************
@@ -21,7 +21,9 @@
package com.kingsrook.qbits.secureddata.support;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
@@ -32,6 +34,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
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.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
@@ -100,13 +103,15 @@ public final class SystemWrites
/*******************************************************************************
** Query without user locks or field access recursion.
** Query without user locks or field access recursion, for the stored fields of
** the table only (see storedFields).
*******************************************************************************/
public static List<QRecord> query(String table, QQueryFilter filter)
{
try
{
return (call(() -> new QueryAction().execute(new QueryInput(table).withFilter(filter).withTransaction(QContext.getQBackendTransaction())).getRecords()));
return (call(() -> new QueryAction().execute(new QueryInput(table).withFilter(filter).withFieldNamesToInclude(storedFields(table))
.withTransaction(QContext.getQBackendTransaction())).getRecords()));
}
catch(QException e)
{
@@ -116,6 +121,22 @@ public final class SystemWrites
/*******************************************************************************
** The names of the stored fields of a table, as the projection of a system
** read. A query that names its fields is not given virtual fields, so core
** runs none of the value providers another QBit or the host may have added to
** a secured-data table: this QBit reads its own columns and nothing else, and
** a provider cannot be made to run inside a rule read. Null (every field, and
** the error core reports) for a table the instance does not have.
*******************************************************************************/
public static Set<String> storedFields(String tableName)
{
QTableMetaData table = QContext.getQInstance().getTable(tableName);
return (table == null ? null : new HashSet<>(table.getFields().keySet()));
}
/*******************************************************************************
** Insert and reject backend record errors.
*******************************************************************************/
@@ -0,0 +1,261 @@
/*
* 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.secureddata;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
import com.kingsrook.qbits.secureddata.permissions.SecuredDataPermissions;
import com.kingsrook.qbits.secureddata.retention.RetentionRules;
import com.kingsrook.qbits.secureddata.support.SecuredDataMetadata;
import com.kingsrook.qqq.backend.core.cluster.ClusterBus;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import com.kingsrook.qqq.backend.core.cluster.ClusterEvent;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
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.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.close;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.delete;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.insert;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.setup;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.update;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The rule rows a node keeps (retention rules, policies, classifications) and
** the access fingerprint of a request: read once, cleared by every change on
** every node, never older than the maximum age, and never an empty answer when
** the rules cannot be read.
*******************************************************************************/
class SecuredDataRuleCachesTest
{
private static final List<String> ANNOUNCED = new ArrayList<>();
static
{
ClusterBus.on(ClusterEvent.TYPE_MEMO_INVALIDATE, (instance, event) -> ANNOUNCED.add(event.getName()));
}
QInstance instance;
QTableMetaData person;
SecuredDataRuntime runtime;
/*******************************************************************************
** A person table with a date, so a retention rule on it is valid.
*******************************************************************************/
@BeforeEach
void init() throws Exception
{
instance = setup();
person = instance.getTable("person");
person.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withBackendName("createDate"));
runtime = SecuredDataRuntime.current();
MemoryRecordStore.setCollectStatistics(true);
MemoryRecordStore.resetStatistics();
ANNOUNCED.clear();
}
/*******************************************************************************
** Cleanup.
*******************************************************************************/
@AfterEach
void cleanup()
{
MemoryRecordStore.setCollectStatistics(false);
close();
}
/*******************************************************************************
** An active rule on one field of the person table.
*******************************************************************************/
private static QRecord rule(String field) throws Exception
{
return (insert(SecuredDataTables.RETENTION_RULE, new QRecord().withValue("name", "30 days: " + field).withValue("table_name", "person")
.withValue("field_names", field).withValue("age_field_name", "createDate").withValue("age_days", 30).withValue("action", "NULL_WITH_MARKER")));
}
/*******************************************************************************
** Queries the in-memory store has run since the last reset.
*******************************************************************************/
private static int queries()
{
return (MemoryRecordStore.getStatistics().getOrDefault(MemoryRecordStore.STAT_QUERIES_RAN, 0));
}
/*******************************************************************************
** Core asks for the rules of a field on every audited field and filter
** criterion: many questions are one read, and every committed change (insert,
** update, delete) is seen by the next question.
*******************************************************************************/
@Test
void activeRetentionRulesAreReadOncePerChange() throws Exception
{
QRecord notes = rule("notes");
assertThat(notes.getErrors()).isNullOrEmpty();
MemoryRecordStore.resetStatistics();
for(int i = 0; i < 50; i++)
{
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
assertThat(RetentionRules.active("person", "email")).isEmpty();
assertThat(RetentionRules.active("anotherTable", "notes")).isEmpty();
assertThat(RetentionRules.changedOnly("person", "notes")).isFalse();
}
assertThat(queries()).isLessThanOrEqualTo(1);
QRecord ssn = rule("ssn");
assertThat(RetentionRules.active("person", "ssn")).hasSize(1);
update(SecuredDataTables.RETENTION_RULE, new QRecord().withValue("id", notes.getValue("id")).withValue("is_active", false));
assertThat(RetentionRules.active("person", "notes")).isEmpty();
delete(SecuredDataTables.RETENTION_RULE, ssn);
assertThat(RetentionRules.active("person", "ssn")).isEmpty();
assertThat(ANNOUNCED).contains(SecuredDataRuntime.RETENTION_CACHE);
}
/*******************************************************************************
** A rule change made on another node arrives as a cluster cache event: each
** of the three kinds of rules is read again here. A change made here is
** announced to the other nodes.
*******************************************************************************/
@Test
void aChangeOnAnyNodeClearsTheRulesOnEveryNode() throws Exception
{
rule("notes");
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
long retention = runtime.retentionVersion();
long policies = runtime.policyVersion();
long classifications = runtime.classificationVersion();
ClusterCaches.invalidate(SecuredDataRuntime.RETENTION_CACHE);
ClusterCaches.invalidate(SecuredDataRuntime.POLICY_CACHE);
ClusterCaches.invalidate(SecuredDataRuntime.CLASSIFICATION_CACHE);
assertThat(runtime.retentionVersion()).isGreaterThan(retention);
assertThat(runtime.policyVersion()).isGreaterThan(policies);
assertThat(runtime.classificationVersion()).isGreaterThan(classifications);
MemoryRecordStore.resetStatistics();
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
assertThat(queries()).isGreaterThanOrEqualTo(1);
MemoryRecordStore.resetStatistics();
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
assertThat(queries()).isZero();
ANNOUNCED.clear();
runtime.invalidatePolicies();
runtime.invalidateClassifications();
runtime.invalidateRetentionRules();
assertThat(ANNOUNCED).contains(SecuredDataRuntime.POLICY_CACHE, SecuredDataRuntime.CLASSIFICATION_CACHE, SecuredDataRuntime.RETENTION_CACHE);
}
/*******************************************************************************
** Fail closed: when the rules cannot be read, asking for them throws. The
** rules read before are not used, and the answer is never an empty list.
*******************************************************************************/
@Test
void rulesThatCannotBeReadAreNeverAnEmptyAnswer() throws Exception
{
rule("notes");
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
QTableMetaData ruleTable = instance.getTables().remove(SecuredDataTables.RETENTION_RULE);
try
{
runtime.invalidateRetentionRules();
assertThatThrownBy(() -> RetentionRules.active("person", "notes")).isInstanceOf(RuntimeException.class);
assertThatThrownBy(() -> RetentionRules.active("person", "notes")).isInstanceOf(RuntimeException.class);
}
finally
{
instance.getTables().put(SecuredDataTables.RETENTION_RULE, ruleTable);
}
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
}
/*******************************************************************************
** The safety net for a lost cluster event: rows are read again once they are
** older than the maximum age.
*******************************************************************************/
@Test
void ruleRowsAreNotUsedPastTheirMaximumAge()
{
assertThat(SecuredDataRuntime.tooOld(System.nanoTime())).isFalse();
assertThat(SecuredDataRuntime.tooOld(System.nanoTime() - SecuredDataRuntime.MAX_RULE_AGE.plusSeconds(1).toNanos())).isTrue();
assertThat(SecuredDataRuntime.MAX_RULE_AGE.toSeconds()).isLessThanOrEqualTo(60);
}
/*******************************************************************************
** Core asks for the access fingerprint before every field decision. Within a
** request the same value object is returned; a permission that matters, given
** to the same session object, changes it at once.
*******************************************************************************/
@Test
void theAccessFingerprintIsKeptForTheRequestAndTracksThePermissions()
{
SecuredDataResolver resolver = runtime.resolver();
QSession session = QContext.getQSession();
String first = resolver.getAccessFingerprint();
for(int i = 0; i < 20; i++)
{
assertThat(resolver.getAccessFingerprint()).isSameAs(first);
}
session.getPermissions().add("unrelated.permission");
assertThat(resolver.getAccessFingerprint()).isEqualTo(first);
session.getPermissions().add(SecuredDataMetadata.permissionBase(person) + "." + SecuredDataPermissions.FULL);
String granted = resolver.getAccessFingerprint();
assertThat(granted).isNotEqualTo(first);
session.setPermissions(new HashSet<>(List.of("person.read")));
assertThat(resolver.getAccessFingerprint()).isNotEqualTo(granted);
}
}
@@ -0,0 +1,682 @@
/*
* 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.secureddata;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
import com.kingsrook.qbits.secureddata.retention.RetentionRules;
import com.kingsrook.qbits.secureddata.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessDecision;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.query.VirtualFieldValueProviderInterface;
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.QueryOrGetInputInterface;
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.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
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.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.classification;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.close;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.policyRow;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.setup;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.user;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Reading rule rows (classifications, retention rules, policies) is a query on
** a secured-data table, and other code runs inside that query: a virtual field
** value provider or a post-query customizer that another QBit or the host put
** on the table. When that code runs a query with filter criteria, core asks
** this QBit for a classification - which needs the rows being read.
**
** These tests put such code on the rule tables and prove: the read asks for
** stored fields only, so no value provider runs inside it; a question asked
** inside a read is answered from the rows held before and starts no other
** read; only the reading thread, as the system, can be given that answer; and
** a read that fails is tried again by the next question.
*******************************************************************************/
class SecuredDataRuleReloadTest
{
QInstance instance;
QTableMetaData person;
SecuredDataRuntime runtime;
/*******************************************************************************
** Something to do inside a query on a rule table.
*******************************************************************************/
@FunctionalInterface
interface NestedWork
{
/*******************************************************************************
** Do it.
*******************************************************************************/
void run() throws Exception;
}
/*******************************************************************************
** Stands in for another QBit's virtual field on a secured-data table: filling
** it runs a query that has filter criteria.
*******************************************************************************/
public static class NestedQueryProvider implements VirtualFieldValueProviderInterface
{
static final AtomicInteger RUNS = new AtomicInteger();
/*******************************************************************************
** Fill the field from a query with criteria, as a real provider does.
*******************************************************************************/
@Override
public void fill(QTableMetaData table, String fieldName, List<QRecord> records, QueryInput input) throws QException
{
RUNS.incrementAndGet();
queryWithCriteria();
records.forEach(record -> record.setValue(fieldName, "filled"));
}
}
/*******************************************************************************
** A post-query customizer on a secured-data table. Core runs it for every
** query on the table, whatever fields the query names.
*******************************************************************************/
public static class NestedWorkCustomizer implements TableCustomizerInterface
{
static final AtomicInteger RUNS = new AtomicInteger();
static volatile NestedWork work;
/*******************************************************************************
** Run the work the test asked for, inside the query.
*******************************************************************************/
@Override
public List<QRecord> postQuery(QueryOrGetInputInterface queryInput, List<QRecord> records) throws QException
{
NestedWork toRun = work;
if(toRun != null)
{
RUNS.incrementAndGet();
try
{
toRun.run();
}
catch(QException | RuntimeException e)
{
throw (e);
}
catch(Exception e)
{
throw (new QException("Nested work failed", e));
}
}
return (records);
}
}
/*******************************************************************************
** A person table with a date, so a retention rule on it is valid.
*******************************************************************************/
@BeforeEach
void init() throws Exception
{
instance = setup();
person = instance.getTable("person");
person.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withBackendName("createDate"));
runtime = SecuredDataRuntime.current();
NestedQueryProvider.RUNS.set(0);
NestedWorkCustomizer.RUNS.set(0);
NestedWorkCustomizer.work = null;
}
/*******************************************************************************
** Cleanup.
*******************************************************************************/
@AfterEach
void cleanup()
{
NestedWorkCustomizer.work = null;
close();
}
/*******************************************************************************
** A query with filter criteria on a host table: core validates the operand
** of a classified field, so it asks for the field's classification - and
** this QBit then asks whether a retention rule names the field.
*******************************************************************************/
private static void queryWithCriteria() throws QException
{
new QueryAction().execute(new QueryInput("person").withFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, 1))));
}
/*******************************************************************************
** Another QBit's virtual field on a secured-data table.
*******************************************************************************/
private void addProvider(String table)
{
instance.getTable(table).addVirtualField(new QVirtualFieldMetaData("labels", QFieldType.STRING).withIsQuerySelectable(true)
.withValueProvider(new QCodeReference(NestedQueryProvider.class)));
}
/*******************************************************************************
** A post-query customizer on a secured-data table.
*******************************************************************************/
private void addCustomizer(String table)
{
instance.getTable(table).withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(NestedWorkCustomizer.class));
}
/*******************************************************************************
** A row as another node writes it: straight into the store. None of this
** node's customizers run and nothing here reads the rule rows; this node
** learns of the change when its version moves (the cluster event).
*******************************************************************************/
private static QRecord writtenElsewhere(String table, QRecord row) throws QException
{
return (MemoryRecordStore.getInstance().insert(new InsertInput(table).withRecord(row), true).getFirst());
}
/*******************************************************************************
** A classification of a person field, written by another node.
*******************************************************************************/
private QRecord classifiedElsewhere(String field, String classification) throws QException
{
QRecord row = writtenElsewhere(SecuredDataTables.CLASSIFICATION, new QRecord().withValue("table_name", "person")
.withValue("field_name", field).withValue("classification", classification));
runtime.invalidateClassifications();
return (row);
}
/*******************************************************************************
** A classification row changed by another node.
*******************************************************************************/
private void reclassifiedElsewhere(QRecord row, String classification) throws QException
{
MemoryRecordStore.getInstance().update(new UpdateInput(SecuredDataTables.CLASSIFICATION).withRecord(new QRecord()
.withValue("id", row.getValue("id")).withValue("classification", classification)), false);
runtime.invalidateClassifications();
}
/*******************************************************************************
** An active retention rule on one field of the person table, written by
** another node.
*******************************************************************************/
private void ruleWrittenElsewhere(String field) throws QException
{
writtenElsewhere(SecuredDataTables.RETENTION_RULE, new QRecord().withValue("name", "30 days: " + field).withValue("table_name", "person")
.withValue("field_names", field).withValue("age_field_name", "createDate").withValue("age_days", 30).withValue("action", "NULL_WITH_MARKER")
.withValue("audit_treatment", "SHREDDABLE").withValue("is_active", true));
runtime.invalidateRetentionRules();
}
/*******************************************************************************
** The classification of a person field.
*******************************************************************************/
private DataClassification classificationOf(String field)
{
return (SecuredDataClassifications.effective(person, field));
}
/*******************************************************************************
** The reported failure: a value provider on the classification table whose
** work is a query with criteria, and one classification row. Before the fix
** the next classification question recursed until the stack overflowed, and
** so did every question after it. Now the rule read names its stored fields,
** so the provider does not run inside it at all.
*******************************************************************************/
@Test
void aValueProviderOnTheClassificationTableDoesNotRunInsideTheRuleRead() throws Exception
{
addProvider(SecuredDataTables.CLASSIFICATION);
classification("notes", "PHI");
assertThat(classificationOf("notes")).isEqualTo(DataClassification.PHI);
queryWithCriteria();
assertThat(NestedQueryProvider.RUNS).hasValue(0);
assertThat(SystemWrites.query(SecuredDataTables.CLASSIFICATION, new QQueryFilter())).singleElement()
.satisfies(row -> assertThat(row.getValues()).doesNotContainKey("labels").containsEntry("field_name", "notes"));
}
/*******************************************************************************
** The virtual field is still there for a query that asks for every field (a
** screen listing the classifications). Its provider's query with criteria
** asks for a classification while the rows are out of date: the rows are
** read once, inside the provider, and nothing recurses.
*******************************************************************************/
@Test
void anOrdinaryQueryOnTheClassificationTableStillFillsTheVirtualField() throws Exception
{
addProvider(SecuredDataTables.CLASSIFICATION);
classifiedElsewhere("notes", "PHI");
List<QRecord> rows = SystemWrites.call(() -> new QueryAction().execute(new QueryInput(SecuredDataTables.CLASSIFICATION)).getRecords());
assertThat(rows).singleElement().satisfies(row -> assertThat(row.getValueString("labels")).isEqualTo("filled"));
assertThat(NestedQueryProvider.RUNS).hasValue(1);
assertThat(classificationOf("notes")).isEqualTo(DataClassification.PHI);
}
/*******************************************************************************
** A post-query customizer runs inside the rule read whatever fields the read
** names. Its query with criteria asks for a classification in the middle of
** the read of the classifications: before the fix that started the read again
** and recursed until the stack overflowed. Now the rows are read once.
*******************************************************************************/
@Test
void aQuestionAskedInsideTheClassificationReadDoesNotStartAnotherRead() throws Exception
{
addCustomizer(SecuredDataTables.CLASSIFICATION);
classifiedElsewhere("notes", "PHI");
NestedWorkCustomizer.work = SecuredDataRuleReloadTest::queryWithCriteria;
assertThat(classificationOf("notes")).isEqualTo(DataClassification.PHI);
assertThat(NestedWorkCustomizer.RUNS).hasValue(1);
assertThat(classificationOf("email")).isEqualTo(DataClassification.PII);
assertThat(NestedWorkCustomizer.RUNS).hasValue(1);
}
/*******************************************************************************
** What a question asked inside the read is told, and who can ask it.
**
** The nested question gets the rows this node held when the read began (no
** rows, the first time; then the rows of the previous version), in both
** directions of a change. It can only be asked by the thread doing the read,
** which is inside the QBit's system read: the session is the system session
** and the resolver answers FULL there without looking at any classification,
** so no person's field access is decided from that answer. The question that
** started the read gets the new rows.
*******************************************************************************/
@Test
void aQuestionAskedInsideTheReadIsAnsweredFromTheRowsHeldBefore() throws Exception
{
addCustomizer(SecuredDataTables.CLASSIFICATION);
assertThat(classificationOf("notes")).isNull();
List<DataClassification> nestedAnswers = new ArrayList<>();
List<Boolean> nestedAsSystem = new ArrayList<>();
List<FieldAccessDecision> nestedDecisions = new ArrayList<>();
NestedWork ask = () ->
{
nestedAnswers.add(classificationOf("notes"));
nestedAsSystem.add(QContext.getQSession() instanceof QSystemUserSession && SystemWrites.active());
nestedDecisions.add(runtime.resolver().resolve(person, person.getField("notes")));
};
/////////////////////////////////////////////////////////////////////////
// a row raises the field: inside the read it is still unclassified, //
// and the question that started the read is answered PHI //
/////////////////////////////////////////////////////////////////////////
QRecord row = classifiedElsewhere("notes", "PHI");
NestedWorkCustomizer.work = ask;
assertThat(classificationOf("notes")).isEqualTo(DataClassification.PHI);
NestedWorkCustomizer.work = null;
assertThat(nestedAnswers).containsExactly((DataClassification) null);
/////////////////////////////////////////////////////////////////////////
// the row is changed to lower the field: inside the read it is still //
// PHI, and the question that started the read is answered PUBLIC //
/////////////////////////////////////////////////////////////////////////
reclassifiedElsewhere(row, "PUBLIC");
NestedWorkCustomizer.work = ask;
assertThat(classificationOf("notes")).isEqualTo(DataClassification.PUBLIC);
NestedWorkCustomizer.work = null;
assertThat(nestedAnswers).containsExactly(null, DataClassification.PHI);
assertThat(nestedAsSystem).containsExactly(true, true);
assertThat(nestedDecisions).containsExactly(FieldAccessDecision.FULL, FieldAccessDecision.FULL);
assertThat(NestedWorkCustomizer.RUNS).hasValue(2);
}
/*******************************************************************************
** A request on another thread is never given the rows held before: while the
** read is running it waits, and then it is answered from the rows the read
** returned.
*******************************************************************************/
@Test
void aRequestOnAnotherThreadWaitsForTheReadAndIsGivenItsRows() throws Exception
{
addCustomizer(SecuredDataTables.CLASSIFICATION);
QRecord row = classifiedElsewhere("notes", "PUBLIC");
assertThat(classificationOf("notes")).isEqualTo(DataClassification.PUBLIC);
reclassifiedElsewhere(row, "PHI");
CountDownLatch readIsRunning = new CountDownLatch(1);
CountDownLatch letReadFinish = new CountDownLatch(1);
NestedWorkCustomizer.work = () ->
{
readIsRunning.countDown();
assertThat(letReadFinish.await(30, TimeUnit.SECONDS)).isTrue();
};
ExecutorService pool = Executors.newFixedThreadPool(2);
try
{
Future<DataClassification> reader = pool.submit(asUser("u2", () -> classificationOf("notes")));
assertThat(readIsRunning.await(30, TimeUnit.SECONDS)).isTrue();
Future<DataClassification> other = pool.submit(asUser("u3", () -> classificationOf("notes")));
assertThatThrownBy(() -> other.get(500, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class);
letReadFinish.countDown();
assertThat(reader.get(30, TimeUnit.SECONDS)).isEqualTo(DataClassification.PHI);
assertThat(other.get(30, TimeUnit.SECONDS)).isEqualTo(DataClassification.PHI);
assertThat(NestedWorkCustomizer.RUNS).hasValue(1);
}
finally
{
letReadFinish.countDown();
pool.shutdownNow();
}
}
/*******************************************************************************
** A person's request on its own thread.
*******************************************************************************/
private <T> Callable<T> asUser(String id, Callable<T> work)
{
return (() ->
{
QContext.init(instance, user(id));
try
{
return (work.call());
}
finally
{
QContext.clear();
}
});
}
/*******************************************************************************
** A read that fails (here, in code running inside it, after that code asked a
** nested question) throws to the request that started it and leaves nothing
** behind: the next request reads again and gets the current rows - not the
** rows held before, and not another failure. An Error, such as the stack
** overflow this defect used to end in, is no different.
*******************************************************************************/
@Test
void aClassificationReadThatFailsIsTriedAgainByTheNextQuestion() throws Exception
{
addCustomizer(SecuredDataTables.CLASSIFICATION);
QRecord row = classifiedElsewhere("notes", "PUBLIC");
assertThat(classificationOf("notes")).isEqualTo(DataClassification.PUBLIC);
reclassifiedElsewhere(row, "PHI");
NestedWorkCustomizer.work = () ->
{
classificationOf("notes");
throw (new QException("The store is unavailable"));
};
assertThatThrownBy(() -> classificationOf("notes")).isInstanceOf(IllegalStateException.class);
NestedWorkCustomizer.work = () ->
{
classificationOf("notes");
throw (new StackOverflowError());
};
assertThatThrownBy(() -> classificationOf("notes")).isInstanceOf(StackOverflowError.class);
NestedWorkCustomizer.work = null;
assertThat(classificationOf("notes")).isEqualTo(DataClassification.PHI);
}
/*******************************************************************************
** The active retention rules have the same shape: core's classification
** question for a host field also asks whether a retention rule names the
** field. A value provider on the retention rule table, with one active rule,
** recursed the same way before the fix; it does not run inside the rule read.
*******************************************************************************/
@Test
void aValueProviderOnTheRetentionRuleTableDoesNotRunInsideTheRuleRead() throws Exception
{
addProvider(SecuredDataTables.RETENTION_RULE);
ruleWrittenElsewhere("notes");
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
queryWithCriteria();
assertThat(NestedQueryProvider.RUNS).hasValue(0);
}
/*******************************************************************************
** A question asked inside the read of the retention rules starts no other
** read and is answered from the rules held before; the question that started
** the read gets the new rules. A read that fails is tried again.
*******************************************************************************/
@Test
void aQuestionAskedInsideTheRetentionRuleReadIsAnsweredFromTheRulesHeldBefore() throws Exception
{
addCustomizer(SecuredDataTables.RETENTION_RULE);
assertThat(RetentionRules.active("person", "notes")).isEmpty();
ruleWrittenElsewhere("notes");
List<Integer> nestedAnswers = new ArrayList<>();
NestedWorkCustomizer.work = () ->
{
queryWithCriteria();
nestedAnswers.add(RetentionRules.active("person", "notes").size());
if(nestedAnswers.size() == 1)
{
throw (new QException("The store is unavailable"));
}
};
assertThatThrownBy(() -> RetentionRules.active("person", "notes")).isInstanceOf(IllegalStateException.class);
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
assertThat(nestedAnswers).containsExactly(0, 0);
assertThat(NestedWorkCustomizer.RUNS).hasValue(2);
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
assertThat(NestedWorkCustomizer.RUNS).hasValue(2);
}
/*******************************************************************************
** A transaction that edited a rule reads the rules through itself, not from
** the rules the node keeps. A question asked inside that read is answered
** from the rules every thread shares: before the fix it read the edits again,
** and recursed until the stack overflowed.
*******************************************************************************/
@Test
void aQuestionAskedInsideAReadOfATransactionsOwnEditsDoesNotReadThemAgain() throws Exception
{
addCustomizer(SecuredDataTables.RETENTION_RULE);
ruleWrittenElsewhere("notes");
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
QContext.setTransaction(new QBackendTransaction());
RetentionRules.editedInTransaction();
try
{
NestedWorkCustomizer.work = SecuredDataRuleReloadTest::queryWithCriteria;
assertThat(RetentionRules.active("person", "notes")).hasSize(1);
assertThat(NestedWorkCustomizer.RUNS).hasValue(1);
}
finally
{
////////////////////////////////////////////////////////////////////////
// the next question outside a transaction ends this thread's edits //
////////////////////////////////////////////////////////////////////////
NestedWorkCustomizer.work = null;
QContext.setTransaction(null);
RetentionRules.active("person", "notes");
}
}
/*******************************************************************************
** The policies are read the same way (rows first, version afterwards), so
** they are guarded the same way: code inside the policy read that asks the
** resolver a policy question starts no other read, and a read that fails is
** tried again.
*******************************************************************************/
@Test
void aQuestionAskedInsideThePolicyReadDoesNotStartAnotherRead() throws Exception
{
addCustomizer(SecuredDataTables.POLICY);
addProvider(SecuredDataTables.POLICY);
assertThat(runtime.resolver().revealPolicies(person, "email")).isEmpty();
writtenElsewhere(SecuredDataTables.POLICY, policyRow("cs_admin", "PII", "REVEALABLE"));
runtime.invalidatePolicies();
List<Integer> nestedAnswers = new ArrayList<>();
NestedWorkCustomizer.work = () ->
{
nestedAnswers.add(runtime.resolver().revealPolicies(person, "email").size());
if(nestedAnswers.size() == 1)
{
throw (new QException("The store is unavailable"));
}
};
assertThatThrownBy(() -> runtime.resolver().revealPolicies(person, "email")).isInstanceOf(IllegalStateException.class);
assertThat(runtime.resolver().revealPolicies(person, "email")).hasSize(1);
assertThat(nestedAnswers).containsExactly(0, 0);
assertThat(NestedQueryProvider.RUNS).hasValue(0);
NestedWorkCustomizer.RUNS.set(0);
assertThat(runtime.resolver().revealPolicies(person, "email")).hasSize(1);
assertThat(NestedWorkCustomizer.RUNS).hasValue(0);
}
/*******************************************************************************
** The rows themselves, without a table: nothing is held before the first
** read, a nested question never reads, a failed read is forgotten, and a new
** version is read once.
*******************************************************************************/
@Test
void ruleRowsAreReadOncePerVersionAndNeverFromInsideTheirOwnRead()
{
RuleRows rows = new RuleRows();
AtomicInteger reads = new AtomicInteger();
List<QRecord> first = List.of(new QRecord().withValue("id", 1));
List<QRecord> second = List.of(new QRecord().withValue("id", 2));
assertThatThrownBy(() -> rows.get(1, () ->
{
reads.incrementAndGet();
assertThat(rows.get(1, () -> fail("a nested question must not read"))).isEmpty();
throw (new IllegalStateException("The store is unavailable"));
})).isInstanceOf(IllegalStateException.class);
assertThat(rows.get(1, () ->
{
reads.incrementAndGet();
assertThat(rows.get(1, () -> fail("a nested question must not read"))).isEmpty();
return (first);
})).isEqualTo(first);
assertThat(rows.get(1, () -> fail("rows of this version are held"))).isEqualTo(first);
assertThat(rows.get(2, () ->
{
reads.incrementAndGet();
assertThat(rows.get(2, () -> fail("a nested question must not read"))).isEqualTo(first);
return (second);
})).isEqualTo(second);
assertThat(reads).hasValue(3);
}
/*******************************************************************************
** A read that must not happen.
*******************************************************************************/
private static List<QRecord> fail(String why)
{
throw (new AssertionError(why));
}
}