Compare commits

..
Author SHA1 Message Date
james.maes c27034563c Merge pull request 'fix(permissions): let the service write its own tables when core actions enforce permissions' (#52) from fix/system-writes-under-core-enforcement into develop
build / maven (push) Successful in 2m53s
maven / build (push) Successful in 2m53s
2026-11-12 17:36:14 +00:00
james.maesandClaude Fable 5.2 a8e8750829 fix(permissions): mark the service's own writes with the core bypass; report a failed audit row
build / maven (push) Successful in 2m48s
maven / build (push) Successful in 2m48s
Review of the first fix found the checker's pass too broad: it let any
QSystemUserSession insert, update and delete every table nobody may write
directly, so server code running as the system user could edit or delete
operation log rows.

- ObjectStoragePermissionChecker no longer looks at the session class. A
  write of those tables that reaches it is refused to every session, the
  core system user included.
- Db.insert, Db.update and Db.delete mark their input with core's per-call
  permission bypass (withPermissionBypass, Db.OWN_WRITE_REASON), so core
  does not ask about the service's own writes when a host enforces
  permissions inside the core actions.
- A failed operation log row is logged at ERROR, raises the
  storage.operation_log.failed alert on the StorageAlertSink (resolved by
  the next row written for the bucket) and is counted in
  OperationLog.failedWriteCount(). A row the insert action hands back with
  a record error is now detected too. The operation still succeeds.

Tests: the service's own log, index insert, update and delete under core
enforcement, with the acting person on the row; a system session outside
Db refused insert, update and delete of an operation row; update and
delete of an operation row refused to a person, the administrator and the
system user; the failure signal.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 11:28:59 -06:00
james.maesandClaude Fable 5.2 cfe190634d fix(permissions): let the service write its own tables when core actions enforce permissions
build / maven (push) Successful in 5m33s
maven / build (push) Successful in 5m33s
In a host that sets QInstance.enforcePermissionsInCoreActions, core checks
table permissions inside InsertAction, UpdateAction and DeleteAction for
every outermost call. Db.asSystem starts a new context, so each write it
makes is outermost, and core asks ObjectStoragePermissionChecker about it.
The checker refused every write of a table that is not admin-editable
("This storage table changes only through storage actions.") to every
session, the system user the service itself writes as included.

Seen in a host application: every StorageBucket.put stored the object and
logged "Could not write a storage operation log row" with
QPermissionDeniedException, so object_storage_operation had no row for any
stored file. The same refusal applies to every other Db.asSystem write of
those tables.

The checker now lets a QSystemUserSession through for those tables. Such a
session is built by server code only; no authentication module gives one
to a request. A person is refused as before, the storage administrator
included (DbSystemSessionTest
.testServiceWritesItsOwnTablesWhenCoreActionsEnforcePermissions, which
failed before with no operation row).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 10:55:52 -06:00
CI ab16a1891b Bump version to 0.2.0-SNAPSHOT [skip ci] 2026-11-07 17:23:30 +00:00
james.maes 1c9a5c53b8 Merge pull request 'build(deps): align with Gitea releases (parent 2.2.1, QQQ 4.1.0-RC.25)' (#50) from deps/align-gitea into develop
build / maven (push) Successful in 6m18s
maven / build (push) Successful in 6m18s
2026-11-07 17:18:18 +00:00
james.maesandClaude Opus 5.5 04d61ec5f0 build(deps): align with Gitea releases (parent 2.2.1, QQQ 4.1.0-RC.25)
build / maven (push) Successful in 6m40s
maven / build (push) Successful in 6m40s
pom.xml: parent -> 2.2.1
pom.xml: qqq.snapshot.version: 4.1.0-RC.15 -> 4.1.0-RC.25

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:09:10 +00:00
james.maes 8169d4c3c8 Merge pull request 'ci: pin shared workflow to v1' (#49) 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 12s
QRun-IO/ci-policy: native / native-result (push) Successful in 9s
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-9 (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 13s
build / maven (push) Successful in 7m32s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (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-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 / 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 7m32s
2026-11-06 16:09:08 +00:00
james.maesandClaude Opus 5.5 e451267495 ci: pin shared workflow to v1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 8s
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 / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (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 / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
build / maven (push) Successful in 6m37s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
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 / shared-pipeline (pull_request) Skipped
maven / build (push) Successful in 6m37s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:12:06 +00:00
james.maes adf3b7fe5d Merge pull request 'ci: use shared ci-workflows Maven workflow' (#48) 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: 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: native / native-result (push) Successful in 5s
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-9 (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 6s
build / maven (push) Successful in 5m27s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (push) Successful in 5s
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 / 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 5m27s
2026-11-05 23:17:26 +00:00
CI 55163ca402 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: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 9s
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-mcp (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 / not-enrolled (pull_request) Skipped
build / maven (push) Successful in 5m19s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: native / native-result (pull_request) Failing after 3s
QRun-IO/ci-policy: ci / gradle-1 (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 / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Failing after 1s
maven / build (push) Successful in 5m19s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 4m41s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Failing after 11s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Failing after 17m57s
2026-11-05 16:25:54 -06:00
james.maesandClaude Opus 5.5 9b7c907715 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
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
build / maven (push) Failing after 0s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Failing after 1s
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-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 2s
maven / build (push) Failing after 1s
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 / 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:06:51 +00:00
forge-recovery 2dbcb4f6f0 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / head-profile (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-9 (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-business (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 26s
QRun-IO/ci-policy: native / native-result (push) Successful in 10s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 26s
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 with Docker / verify (push) Successful in 4m32s
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 52m15s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
qrun-release / postpublish (push) Successful in 1m42s
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-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (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
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 with Docker / gate (push) Successful in 11s
qrun-release / release (push) Successful in 52m3s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 4m14s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 12s
Shared QRun source version / version (push) Successful in 1m43s
2026-11-03 18:10:05 +00:00
forge-recovery c00d0e0d35 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / ci-result (push) Successful in 3s
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 1h14m9s
QRun-IO/ci-policy: ci / head-profile (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-9 (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 10s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 31s
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: Shared Java Maven CI with Docker / verify (push) Successful in 4m54s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 10s
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-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (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
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
2026-11-02 22:34:56 +00:00
forge-recoveryandCodex aec65dcdb9 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-6 (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-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
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: 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-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: native / native-result (pull_request) Successful in 9s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 23s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 5m58s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 14m43s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 20:18:04 +00:00
7a949c32ab ci: add the isolated postpublish job to the QRun release caller
Shared Java Maven CI with Docker / secrets (push) Failing after 26s
Shared Java Maven CI with Docker / 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-6 (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: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
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: ci / ci-result (pull_request) Successful in 6s
Release candidate / release-rc (pull_request) Skipped
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 2h33m16s
QRun-IO/ci-policy: ci / gradle-1 (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: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 16s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 3m57s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 24m39s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:51:31 +00:00
95b45414d8 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 / java-frontend-browser-3 (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: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Canceled after 37m17s
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
Shared Java Maven CI with Docker / secrets (push) Failing after 24s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Canceled after 0s
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 37m23s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Failing after 23s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:19:17 +00:00
james.maesandCodex a7b5f19626 ci: adopt managed orb 0.8 patch track (#41)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:35:15 -05:00
james.maesandCodex 79bc8fcf34 ci: adopt shared orb 0.7.18 (#40)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:52:27 -05:00
james.maesandCodex 4eec8b56f3 ci: adopt shared orb 0.7.17 (#39)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:20:13 -05:00
james.maesandCodex 988f4c56b1 ci: centralize QBit source scanning (#36)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:43:43 -05:00
james.maesandCodex 03ed383587 ci: adopt central orb 0.7.12 and stable gates (#35)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:58:38 -05:00
james.maesandCodex 85e9ab1316 ci: centralize rc release completion in qqq orb (#34)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:30:37 -05:00
james.maesandCodex 03215cab62 ci: adopt shared orb 0.7.11 (#33)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:02:00 -05:00
james.maes 6866e6653f Merge pull request #32 from QRun-IO/feature/central-ci
ci: adopt verified central QQQ orb 0.7.1
2026-10-20 18:49:56 -05:00
14 changed files with 523 additions and 29 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.2
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.2
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.2
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
+4
View File
@@ -26,6 +26,10 @@ All notable changes to this project are documented here. The format follows Keep
- Foreign keys on every relation between the qbit tables (`fk_<table>_<column>`), generated by `ObjectStorageSchema`: owned observed state (capabilities, folders, objects, versions, usage) cascades from its bucket or object, `object.folder_id` and `object_reference.object_id` are set null, and a bucket's connection, header and protection rules and operation log restrict. Each key is added by an idempotent block that first records rows whose parent is gone in `qqq_fk_quarantine` and clears (nullable column) or deletes (required column) them, so it also upgrades an existing schema. New indexes `ix_object_storage_bucket_1` (connection_id), `ix_object_storage_header_rule_1` and `ix_object_storage_protection_rule_1` (bucket_id) back the keys. Upgrade: run the generated statements again (or the host's copy of them).
- Conformance suite against MinIO and Garage (Testcontainers), service, index and action tests, and the index and actions on PostgreSQL.
### Fixed
- The service's own writes are no longer refused in a host that enforces permissions inside the core actions (`QInstance.enforcePermissionsInCoreActions`). `Db.asSystem` starts a new context, so each of its writes is an outermost action there and core asks `ObjectStoragePermissionChecker`, which refuses every write of a table nobody may write directly, to every session. The `object_storage_operation` row of every stored object was lost (logged as "Could not write a storage operation log row", the object itself was stored), and so was each other system write of those tables. `Db.insert`, `Db.update` and `Db.delete` now mark their input with core's per-call permission bypass (`withPermissionBypass`, reason `Db.OWN_WRITE_REASON`), so core does not ask about the service's own writes. The checker is unchanged in what it refuses: a write of those tables that does not carry a bypass is refused to a person, to `object_storage.admin` and to the core system user alike, so server code running as the system user cannot edit or delete operation log rows through an enforced action.
- An operation log row that could not be written is no longer only a warning. It is logged at ERROR, raises the `storage.operation_log.failed` alert (`StorageAlertSink`, severity `error`, dedupe key `bucket:<alias>`, resolved by the next row written for that bucket) and is counted in `OperationLog.failedWriteCount()` (per node, since start). A row the insert action hands back with a record error (a refused value, a record lock) is now detected as well; before, it was dropped without any log line from the qbit. The operation the row describes still succeeds.
### Removed
- The QRun-IO/qqq#1165 stand-ins: `SessionCredentialsClient` (session tokens now go through core `S3BackendMetaData.sessionToken` / `S3ClientFactory`, including for the presigner) and the file reading in `SecretRefs` (core `QMetaDataVariableInterpreter` resolves `${file.PATH}`). **Behavior change:** core reads `${file.PATH}` only inside its allowed roots, which are `/var/run/secrets` and `/run/secrets` by default. A host that mounts storage secrets elsewhere must set `qqq.fileReference.roots` or `QQQ_FILE_REFERENCE_ROOTS`. The qbit's default allow-list (`file./var/run/secrets/storage/*`) is already inside the default roots.
- None for the health migration; both Test connection record actions, including on-demand write tests, are retained.
+1
View File
@@ -125,6 +125,7 @@ It is insert-only and inserts a **disabled** generic header rule, `default-mutab
- **Previews** are short-lived (5 minute) presigned GETs, so the Next dashboard's `img-src` must allow the storage host (it allows `https:` by default). Video, audio and PDF previews need same-origin media (the dashboard's `media-src` and `frame-src` are `'self'`); a same-origin download route is the planned follow-up. Until then those files show the file card with download and open links.
- **Scheduler.** Set `withSchedulerName(...)` to run the nightly scan, HEAD backlog, reference scan, usage snapshot and trash purge. Without it, run Rescan bucket by hand.
- **Alerts.** With the Alerts qbit installed, bind a `StorageAlertSink` adapter (`ObjectStorageRuntime.setAlertSink`); without it every condition is still visible on the home and in the tables.
- **A missing audit row.** A storage operation succeeds even when its `object_storage_operation` row cannot be written, and that row is then the one condition no table shows. It is logged at ERROR ("Could not write a storage operation log row"), raised as the `storage.operation_log.failed` alert (severity `error`, dedupe key `bucket:<alias>`, resolved by the next row written for that bucket) and counted in `OperationLog.failedWriteCount()`, per node since it started. A host without an alert sink should export that count (a gauge, or a health check of its own) and alert when it is above zero.
## Conformance
+3 -3
View File
@@ -20,7 +20,7 @@
<properties>
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
<revision>0.1.0-SNAPSHOT</revision>
<revision>0.2.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
@@ -29,7 +29,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.1.0</version>
<version>2.2.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -290,7 +290,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.25</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -48,9 +48,21 @@ import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
** Small helpers for reading and writing the qbit's own tables as the system
** user (the service writes its audit log and index regardless of the calling
** user's per-bucket scope; the calling user's permissions were checked first).
**
** Every write made here carries core's per-call permission bypass
** (AbstractActionInput.withPermissionBypass). That mark, not the session, is
** what lets the service write tables nobody may write directly: a host that
** enforces permissions inside the core actions asks ObjectStoragePermissionChecker
** about every outermost write that does not carry a bypass, and the checker
** refuses those tables to every session, the system user included.
*******************************************************************************/
public final class Db
{
/** The reason recorded on (and logged by core for) each write the service makes of its own tables. */
public static final String OWN_WRITE_REASON = "The object storage service writes its own tables (operation log, index, capabilities, usage); the caller's storage permission was checked before the storage call.";
/*******************************************************************************
** Something that may throw.
*******************************************************************************/
@@ -237,7 +249,9 @@ public final class Db
*******************************************************************************/
public static List<QRecord> insert(String table, List<QRecord> records) throws QException
{
return (new InsertAction().execute(new InsertInput(table).withRecords(records)).getRecords());
InsertInput input = new InsertInput(table).withRecords(records);
input.withPermissionBypass(OWN_WRITE_REASON);
return (new InsertAction().execute(input).getRecords());
}
@@ -259,7 +273,9 @@ public final class Db
*******************************************************************************/
public static List<QRecord> update(String table, List<QRecord> records) throws QException
{
return (new UpdateAction().execute(new UpdateInput(table).withRecords(records)).getRecords());
UpdateInput input = new UpdateInput(table).withRecords(records);
input.withPermissionBypass(OWN_WRITE_REASON);
return (new UpdateAction().execute(input).getRecords());
}
@@ -271,7 +287,9 @@ public final class Db
{
if(!ids.isEmpty())
{
new DeleteAction().execute(new DeleteInput(table).withPrimaryKeys(ids));
DeleteInput input = new DeleteInput(table).withPrimaryKeys(ids);
input.withPermissionBypass(OWN_WRITE_REASON);
new DeleteAction().execute(input);
}
}
@@ -99,6 +99,7 @@ public final class ObjectStorageRuntime
clock = Clock.systemUTC();
indexHook = null;
CapabilityStore.clearCache();
OperationLog.reset();
}
@@ -21,10 +21,17 @@
package com.kingsrook.qbits.objectstorage.internal;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import com.kingsrook.qbits.objectstorage.api.StorageCaller;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -33,12 +40,22 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** every caller (UI action, app code, scheduled job) lands here. Presigned URLs
** are logged without the signature (key, method, expiry and issuer only),
** because the URL itself is a bearer token. A logging failure never fails the
** operation it describes.
** operation it describes, so it is reported three ways instead: an ERROR log
** line, the storage.operation_log.failed alert (StorageAlertSink, resolved by
** the next row written for that bucket), and failedWriteCount().
*******************************************************************************/
public final class OperationLog
{
private static final QLogger LOG = QLogger.getLogger(OperationLog.class);
/** The alert raised (severity error, dedupe key "bucket:" + alias) when a row could not be written. */
public static final String ALERT_TYPE = "storage.operation_log.failed";
private static final AtomicLong FAILED_WRITES = new AtomicLong();
private static final Set<String> UNRESOLVED = ClusterCaches.nodeLocal(ConcurrentHashMap.newKeySet(),
"the buckets whose operation log alert this node raised and has not resolved (alert state, not cached data)");
/*******************************************************************************
@@ -183,6 +200,31 @@ public final class OperationLog
/*******************************************************************************
** How many operation log rows this node could not write since it started (or
** since the runtime was reset). The operations themselves were carried out, so
** anything above zero means storage changes that the audit log does not show.
** A host that has no alert sink reads this (a metrics gauge, a health check of
** its own) to alert on a missing audit row.
*******************************************************************************/
public static long failedWriteCount()
{
return (FAILED_WRITES.get());
}
/*******************************************************************************
** Forget the failures counted and the alerts raised (ObjectStorageRuntime.reset).
*******************************************************************************/
static void reset()
{
FAILED_WRITES.set(0);
UNRESOLVED.clear();
}
/*******************************************************************************
** Insert the row (as the system user), never throwing.
*******************************************************************************/
@@ -196,7 +238,7 @@ public final class OperationLog
try
{
Integer bucketId = ConfigSync.bucketId(e.bucketAlias);
Db.asSystem(() -> Db.insert(ObjectStorageTables.OPERATION, new QRecord()
QRecord written = Db.asSystem(() -> Db.insert(ObjectStorageTables.OPERATION, new QRecord()
.withValue("occurredAt", ObjectStorageRuntime.clock().instant())
.withValue("actor", e.caller.describe())
.withValue("action", e.action)
@@ -210,10 +252,66 @@ public final class OperationLog
.withValue("providerRequestId", e.requestId)
.withValue("processRunId", e.processRunId)
.withValue("detail", e.detail)));
////////////////////////////////////////////////////////////////////////////////
// the insert action does not throw for a record it refused (a missing value, //
// a value too long, a record lock): it hands the record back with its errors //
////////////////////////////////////////////////////////////////////////////////
if(CollectionUtils.nullSafeHasContents(written.getErrors()))
{
throw (new QException("The operation log row was refused: " + written.getErrors()));
}
written(e);
}
catch(Exception ex)
{
LOG.warn("Could not write a storage operation log row", ex, logPair("action", e.action), logPair("key", e.key));
failed(e, ex);
}
}
/*******************************************************************************
** A row was written: resolve the alert of an earlier failure for its bucket.
*******************************************************************************/
private static void written(Entry e)
{
String dedupeKey = "bucket:" + e.bucketAlias;
if(UNRESOLVED.remove(dedupeKey))
{
try
{
ObjectStorageRuntime.alertSink().resolve(ALERT_TYPE, dedupeKey);
}
catch(RuntimeException sinkFailure)
{
LOG.warn("Could not resolve the storage operation log alert", sinkFailure, logPair("bucket", e.bucketAlias));
}
}
}
/*******************************************************************************
** A row could not be written: the operation happened and the audit log does
** not show it. Say so where an operator sees it, without failing the operation.
*******************************************************************************/
private static void failed(Entry e, Exception cause)
{
long count = FAILED_WRITES.incrementAndGet();
LOG.error("Could not write a storage operation log row: the operation is missing from the audit log", cause,
logPair("action", e.action), logPair("bucket", e.bucketAlias), logPair("key", e.key), logPair("result", e.result), logPair("failedWriteCount", count));
String dedupeKey = "bucket:" + e.bucketAlias;
UNRESOLVED.add(dedupeKey);
try
{
ObjectStorageRuntime.alertSink().raise(ALERT_TYPE, dedupeKey, "error", "A storage operation on " + e.bucketAlias + " was not recorded in the operation log",
Map.of("bucket", String.valueOf(e.bucketAlias), "action", String.valueOf(e.action), "count", count));
}
catch(RuntimeException sinkFailure)
{
LOG.warn("Could not raise the storage operation log alert", sinkFailure, logPair("bucket", e.bucketAlias));
}
}
}
@@ -46,7 +46,9 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
** - writes to the editable config tables (connection operational fields, header
** rules, protection rules, bucket thresholds) need object_storage.admin;
** observed and history tables are never written through the UI or API (they
** change only through actions and the service);
** change only through actions and the service, whose own writes carry
** core's per-call permission bypass, see internal.Db; no session is let
** through, the core system user included);
** - each process needs the permission registered for it (default admin).
*******************************************************************************/
public class ObjectStoragePermissionChecker implements CustomPermissionChecker
@@ -65,6 +67,12 @@ public class ObjectStoragePermissionChecker implements CustomPermissionChecker
String needed = required(actionInput, metaData);
if(needed == null)
{
////////////////////////////////////////////////////////////////////////////////////////
// nobody writes these tables directly, whatever the session: a person, the storage //
// administrator and the core system user are all refused. the service's own writes //
// (internal.Db) carry core's per-call permission bypass, so core does not ask about //
// them; a write that reaches this point did not come from the service //
////////////////////////////////////////////////////////////////////////////////////////
throw (new QPermissionDeniedException("This storage table changes only through storage actions."));
}
if(!has(needed))
@@ -21,25 +21,50 @@
package com.kingsrook.qbits.objectstorage.internal;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.api.StorageCaller;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissions;
import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
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.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.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Db.asSystem runs work as the core system user. Token-based authentication
** modules (OAuth2, Auth0) accept a QSystemUserSession without a token and
** reject any other tokenless session, so the class matters, not just the
** permissions.
** permissions. The system session is not what lets the service write its own
** tables, though: each Db write carries core's per-call permission bypass, and
** a write that does not is refused to every session.
*******************************************************************************/
class DbSystemSessionTest
{
private static final String REFUSAL = "This storage table changes only through storage actions.";
/*******************************************************************************
** Reset the runtime and context after each test.
*******************************************************************************/
@@ -69,4 +94,140 @@ class DbSystemSessionTest
assertThat(inside.hasSecurityKeyValue("tenantAllAccess", true)).isTrue();
assertThat(QContext.getQSession()).isSameAs(caller);
}
/*******************************************************************************
** A host that enforces permissions inside the core actions
** (QInstance.enforcePermissionsInCoreActions) still gets its operation log
** row, and the row names the person who acted: the caller is captured before
** the write moves to the system user. The other tables the service keeps
** (here the object index) are written, changed and removed the same way.
*******************************************************************************/
@Test
void testServiceWritesItsOwnTablesWhenCoreActionsEnforcePermissions() throws Exception
{
enforcingInstance();
QContext.setQSession(new QSession().withUser(new QUser().withIdReference("pat@example.com").withFullName("Pat Example")).withPermissions(ObjectStoragePermissions.WRITE));
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("stored/by/the/service").write();
assertThat(operations()).as("the operation log row of a stored object").singleElement().satisfies(row ->
{
assertThat(row.getValueString("objectKey")).isEqualTo("stored/by/the/service");
assertThat(row.getValueString("actor")).as("the person who acted, not the system user that wrote the row").isEqualTo("pat@example.com");
});
assertThat(OperationLog.failedWriteCount()).isZero();
Serializable id = Db.asSystem(() -> Db.insert(ObjectStorageTables.OBJECT, new QRecord().withValue("objectKey", "a/b.txt").withValue("size", 1L))).getValue("id");
Db.asSystem(() -> Db.update(ObjectStorageTables.OBJECT, List.of(new QRecord().withValue("id", id).withValue("size", 2L))));
assertThat(Db.asSystem(() -> Db.first(ObjectStorageTables.OBJECT, "id", id)).getValueLong("size")).isEqualTo(2L);
Db.asSystem(() ->
{
Db.delete(ObjectStorageTables.OBJECT, List.of(id));
return (null);
});
assertThat(Db.asSystem(() -> Db.query(ObjectStorageTables.OBJECT))).isEmpty();
}
/*******************************************************************************
** Being the system user is not enough to write the operation log. Server code
** outside the service's Db (a host, another qbit) that runs as the core system
** user is refused the insert, the update and the delete, also when it borrows
** Db.asSystem for its session.
*******************************************************************************/
@Test
void testSystemSessionOutsideTheServiceCannotWriteTheOperationLog() throws Exception
{
QInstance instance = enforcingInstance();
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("the/audited/key").write();
Serializable id = operations().get(0).getValue("id");
QContext.setQSession(new QSystemUserSession(instance));
assertRefused("insert as the system user", () -> new InsertAction().execute(new InsertInput(ObjectStorageTables.OPERATION).withRecords(List.of(new QRecord().withValue("action", "PUT")))));
assertRefused("update as the system user", () -> new UpdateAction().execute(new UpdateInput(ObjectStorageTables.OPERATION).withRecords(List.of(new QRecord().withValue("id", id).withValue("actor", "nobody")))));
assertRefused("delete as the system user", () -> new DeleteAction().execute(new DeleteInput(ObjectStorageTables.OPERATION).withPrimaryKey(id)));
QContext.setQSession(new QSession());
assertRefused("insert inside Db.asSystem, not through Db", () -> Db.asSystem(() -> new InsertAction().execute(new InsertInput(ObjectStorageTables.OPERATION).withRecords(List.of(new QRecord().withValue("action", "PUT"))))));
assertRefused("update inside Db.asSystem, not through Db", () -> Db.asSystem(() -> new UpdateAction().execute(new UpdateInput(ObjectStorageTables.OPERATION).withRecords(List.of(new QRecord().withValue("id", id).withValue("actor", "nobody"))))));
assertRefused("delete inside Db.asSystem, not through Db", () -> Db.asSystem(() -> new DeleteAction().execute(new DeleteInput(ObjectStorageTables.OPERATION).withPrimaryKey(id))));
assertUnchanged();
}
/*******************************************************************************
** An operation row is not updated or deleted by an update or delete action
** that does not carry the bypass, whoever asks: a person, the storage
** administrator or the system user, by key or by filter.
*******************************************************************************/
@Test
void testOperationRowIsNeverUpdatedOrDeletedWithoutTheBypass() throws Exception
{
QInstance instance = enforcingInstance();
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("the/audited/key").write();
Serializable id = operations().get(0).getValue("id");
List<QSession> sessions = List.of(new QSession(), new QSession().withPermissions(ObjectStoragePermissions.ADMIN), new QSystemUserSession(instance));
for(QSession session : sessions)
{
QContext.setQSession(session);
String who = session.getClass().getSimpleName() + (session.hasPermission(ObjectStoragePermissions.ADMIN) ? " (admin)" : "");
assertRefused("update by " + who, () -> new UpdateAction().execute(new UpdateInput(ObjectStorageTables.OPERATION).withRecords(List.of(new QRecord().withValue("id", id).withValue("result", "FAILED")))));
assertRefused("delete by key by " + who, () -> new DeleteAction().execute(new DeleteInput(ObjectStorageTables.OPERATION).withPrimaryKey(id)));
assertRefused("delete by filter by " + who, () -> new DeleteAction().execute(new DeleteInput(ObjectStorageTables.OPERATION).withQueryFilter(new QQueryFilter(Db.eq("action", "PUT")))));
}
assertUnchanged();
}
/*******************************************************************************
** An instance that enforces permissions inside the core actions.
*******************************************************************************/
private static QInstance enforcingInstance() throws QException
{
ObjectStorageQBitConfig config = new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY).withBucketScoping(false);
return (StorageTestSupport.setUp(config, instance -> instance.setEnforcePermissionsInCoreActions(true)));
}
/*******************************************************************************
** The operation log, read by the service.
*******************************************************************************/
private static List<QRecord> operations() throws QException
{
return (Db.asSystem(() -> Db.query(ObjectStorageTables.OPERATION)));
}
/*******************************************************************************
** The qbit's checker refused the write.
*******************************************************************************/
private static void assertRefused(String what, ThrowingCallable write)
{
assertThatThrownBy(write).as(what).isInstanceOf(QPermissionDeniedException.class).hasMessage(REFUSAL);
}
/*******************************************************************************
** The one row the service wrote is still there, as it was written.
*******************************************************************************/
private static void assertUnchanged() throws QException
{
assertThat(operations()).singleElement().satisfies(row ->
{
assertThat(row.getValueString("objectKey")).isEqualTo("the/audited/key");
assertThat(row.getValueString("result")).isEqualTo("OK");
assertThat(row.getValueString("actor")).isEqualTo("system (no user)");
});
}
}
@@ -0,0 +1,125 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.objectstorage.internal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.api.StorageAlertSink;
import com.kingsrook.qbits.objectstorage.api.StorageCaller;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport;
import com.kingsrook.qqq.backend.core.logging.CollectedLogMessage;
import com.kingsrook.qqq.backend.core.logging.QCollectingLogger;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.apache.logging.log4j.Level;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** An operation log row that cannot be written does not fail the operation it
** describes, so an operator has to be able to see it: an ERROR log line, the
** storage.operation_log.failed alert and OperationLog.failedWriteCount().
*******************************************************************************/
class OperationLogTest
{
private final List<String> alerts = new ArrayList<>();
/*******************************************************************************
** Reset the runtime, the context and the logger after each test.
*******************************************************************************/
@AfterEach
void tearDown()
{
QLogger.deactivateCollectingLoggerForClass(OperationLog.class);
StorageTestSupport.tearDown();
}
/*******************************************************************************
** A row the insert action refuses (it hands the record back with an error, it
** does not throw) and a row whose insert throws are both counted, logged at
** ERROR and raised as an alert; the caller is not failed. The next row written
** for the bucket resolves the alert and the count stays.
*******************************************************************************/
@Test
void testFailedRowIsAnErrorAnAlertAndACount() throws Exception
{
QInstance instance = StorageTestSupport.setUp(new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY).withBucketScoping(false));
ObjectStorageRuntime.setAlertSink(new StorageAlertSink()
{
/*******************************************************************************
** Record the alert.
*******************************************************************************/
@Override
public void raise(String alertType, String dedupeKey, String severity, String message, Map<String, Object> details)
{
alerts.add("raise " + alertType + " " + dedupeKey + " " + severity + " count=" + details.get("count") + " action=" + details.get("action"));
}
/*******************************************************************************
** Record the resolution.
*******************************************************************************/
@Override
public void resolve(String alertType, String dedupeKey)
{
alerts.add("resolve " + alertType + " " + dedupeKey);
}
});
QCollectingLogger logger = QLogger.activateCollectingLoggerForClass(OperationLog.class);
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("a").write();
assertThat(OperationLog.failedWriteCount()).isZero();
assertThat(alerts).isEmpty();
instance.getTable(ObjectStorageTables.OPERATION).getField("detail").setIsRequired(true);
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("refused").write();
assertThat(OperationLog.failedWriteCount()).isEqualTo(1);
assertThat(alerts).containsExactly("raise storage.operation_log.failed bucket:files error count=1 action=PUT");
instance.getTable(ObjectStorageTables.OPERATION).getField("detail").setIsRequired(false);
String backendName = instance.getTable(ObjectStorageTables.OPERATION).getBackendName();
instance.getTable(ObjectStorageTables.OPERATION).setBackendName("no-such-backend");
new OperationLog.Entry(StorageCaller.user(), "DELETE", "files").key("thrown").write();
assertThat(OperationLog.failedWriteCount()).isEqualTo(2);
assertThat(alerts).last().isEqualTo("raise storage.operation_log.failed bucket:files error count=2 action=DELETE");
List<CollectedLogMessage> errors = logger.getCollectedMessages().stream().filter(message -> Level.ERROR.equals(message.getLevel())).toList();
assertThat(errors).hasSize(2).allSatisfy(message -> assertThat(message.getMessage()).contains("Could not write a storage operation log row"));
instance.getTable(ObjectStorageTables.OPERATION).setBackendName(backendName);
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("b").write();
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("c").write();
assertThat(alerts).hasSize(3).last().isEqualTo("resolve storage.operation_log.failed bucket:files");
assertThat(OperationLog.failedWriteCount()).as("the count is of this node since it started, a later row does not lower it").isEqualTo(2);
assertThat(Db.asSystem(() -> Db.query(ObjectStorageTables.OPERATION))).extracting(row -> row.getValueString("objectKey")).containsExactly("a", "b", "c");
}
}