In a host application that sets QInstance.enforcePermissionsInCoreActions(true), every StorageBucket.put stored the object and then logged:
WARN OperationLog - Could not write a storage operation log row
QPermissionDeniedException: This storage table changes only through storage actions.
at ObjectStoragePermissionChecker.checkPermissionsThrowing(ObjectStoragePermissionChecker.java:68)
at PermissionsHelper.commonCheckPermissionThrowing
at CorePermissionEnforcer.checkTablePermissions
at InsertAction.executeWithPermissionChecks
at Db.insert
at OperationLog.lambda$write$0
at Db.asSystem
at OperationLog.write
at StorageBucket.put
object_storage_operation had no row for any stored file, so the audit of stored objects was silently incomplete.
Cause
With that instance setting, core checks table permissions inside InsertAction, UpdateAction and DeleteAction for every outermost call.
Db.asSystem starts a new context (new CapturedContext(instance, systemSession), authorized scope depth 0), so each write it makes is outermost, wherever it was called from.
ObjectStoragePermissionChecker refuses every write of a table that is not admin-editable to every session, the QSystemUserSession the service itself writes as included. Core calls a custom checker before it looks at the session.
The same refusal applied to every other Db.asSystem write of those tables (the object index, references, capabilities, usage), not only the operation log.
Change (second commit, after review)
The first commit let any QSystemUserSession through in the checker. Review found that too broad: the pass covered insert, update and delete on every table nobody may write directly, so any server code running as the system user (a host, another qbit) could update or delete object_storage_operation rows. The second commit replaces it.
The checker no longer looks at the session class. A write of those tables that reaches ObjectStoragePermissionChecker is refused to a person, to object_storage.admin and to the core system user alike.
The service marks its own writes.Db.insert, Db.update and Db.delete set core's per-call permission bypass on their input (AbstractActionInput.withPermissionBypass(Db.OWN_WRITE_REASON), read in qqq-backend-core 4.1.0-RC.25). Core does not ask a checker about an input that carries a bypass, and logs the reason at DEBUG.
A failed audit row is no longer only a warning.OperationLog logs it at ERROR, raises the storage.operation_log.failed alert on the StorageAlertSink (severity error, dedupe key bucket:<alias>, resolved by the next row written for that bucket) and counts it in OperationLog.failedWriteCount(). It also now detects a row that InsertAction hands back with a record error instead of throwing (a refused value, a record lock); before, that row was dropped with no log line from the qbit at all. Whether the storage operation succeeds is unchanged: it does.
The operator signal
The qbit reports conditions through StorageAlertSink (scan stale, objects missing, quota, connection failed), so the alert is the main signal. The sink is a no-op until a host binds an adapter, so there is also a counter the host can read: OperationLog.failedWriteCount(), the rows this node could not write since it started. A host exports it (a gauge, or a health check of its own) and alerts above zero. The existing health checks (object_storage.connection.<id>) are dependency probes of the store; a missing audit row is not a reason to report a connection down, so no health check was added.
Limits: the count and the alert state are per node. On several nodes, an alert raised by one node is resolved only when that node next writes a row for the bucket.
Tests
DbSystemSessionTest (instance with enforcePermissionsInCoreActions(true)):
testServiceWritesItsOwnTablesWhenCoreActionsEnforcePermissions: an operation log entry written from a person's session is there, and its actor is that person, not the system user that wrote the row (StorageCaller.user() captures the user before Db.asSystem). Db insert, update and delete of an index row work too.
testSystemSessionOutsideTheServiceCannotWriteTheOperationLog: with a QSystemUserSession in the context, and also inside Db.asSystem, a plain InsertAction, UpdateAction and DeleteAction on object_storage_operation are refused; the row is unchanged.
testOperationRowIsNeverUpdatedOrDeletedWithoutTheBypass: update, delete by key and delete by filter are refused to a person, the storage administrator and the system user; the row is unchanged.
OperationLogTest.testFailedRowIsAnErrorAnAlertAndACount: a row refused with a record error and a row whose insert throws are each logged at ERROR, raised as the alert and counted, and the caller is not failed; the next row written resolves the alert once.
Checked against the earlier code: with the first commit's checker the second and third tests fail; with Db not marking its writes the first test fails.
./mvnw -B verify (nothing skipped) on a8e8750 (this branch) and on 59b0ea2 (the release/0.1 port): BUILD SUCCESS on both, 169 tests, 0 failures, 0 errors, 2 skipped (the two Garage conformance tests that are skipped on develop too), 0 Checkstyle violations and the JaCoCo gate passed.
Every Db write, checked
All table writes in the qbit go through Db.insert, Db.update or Db.delete (no other use of the insert, update or delete actions in src/main), so all carry the bypass.
Inside Db.asSystem:
OperationLog.write: operation insert.
ObjectIndexHook.objectWritten / objectRemoved: object insert, update, delete; folder insert and update and the bucket rollup (FolderIndex.ensureFor, applyDelta).
ReferenceScan.run (doRun): reference delete and insert, object and bucket updates.
StorageJobs usage snapshot: usage insert and update.
CapabilityStore and CapabilityProbe: capability insert and update, bucket versioning state.
ConfigSync.sync: connection and bucket insert and update.
Outside Db.asSystem (two, both unchanged in effect): TestConnectionProcess.ReadLoad.runOnePage updates the connection row and BucketProcesses.CheckLoad.apply updates the bucket row, in the session of the person running the process. Both run inside a process step, which core already treated as trusted, both tables are the admin-editable ones, and record security locks still apply to that person.
Limits, and what I would do next
Core's bypass is per call, not per qbit: other server code can set its own withPermissionBypass(reason), and a call nested inside a core action (a process step, a customizer) or made in a host that does not enforce permissions in core actions is never put to a checker. That is core's model and is the same as before this PR. The service only ever inserts operation rows, so a follow-up could make the log append-only in every mode with a pre-update and pre-delete customizer on object_storage_operation that always refuses.
Person-initiated stores. This PR keeps the store succeeding when its audit row fails. For a store a person starts (UI upload and the record actions, StorageCaller.user()), I would recommend failing closed: write the row before the provider call and refuse the operation if it cannot be written, then record the outcome as a second row rather than an update, so the log stays append-only. System callers (scans, scheduled jobs, cleanup) would stay best effort with the alert, because failing a purge or a scan over a log row does more harm than the gap.
Not in this change
The released line that hosts consume is release/0.1. The same two commits are on fix/system-writes-under-core-enforcement-0.1 (based on release/0.1); no pull request into release/0.1 is open.
## What was seen
In a host application that sets `QInstance.enforcePermissionsInCoreActions(true)`, every `StorageBucket.put` stored the object and then logged:
```
WARN OperationLog - Could not write a storage operation log row
QPermissionDeniedException: This storage table changes only through storage actions.
at ObjectStoragePermissionChecker.checkPermissionsThrowing(ObjectStoragePermissionChecker.java:68)
at PermissionsHelper.commonCheckPermissionThrowing
at CorePermissionEnforcer.checkTablePermissions
at InsertAction.executeWithPermissionChecks
at Db.insert
at OperationLog.lambda$write$0
at Db.asSystem
at OperationLog.write
at StorageBucket.put
```
`object_storage_operation` had no row for any stored file, so the audit of stored objects was silently incomplete.
## Cause
- With that instance setting, core checks table permissions inside `InsertAction`, `UpdateAction` and `DeleteAction` for every outermost call.
- `Db.asSystem` starts a new context (`new CapturedContext(instance, systemSession)`, authorized scope depth 0), so each write it makes is outermost, wherever it was called from.
- `ObjectStoragePermissionChecker` refuses every write of a table that is not admin-editable to every session, the `QSystemUserSession` the service itself writes as included. Core calls a custom checker before it looks at the session.
The same refusal applied to every other `Db.asSystem` write of those tables (the object index, references, capabilities, usage), not only the operation log.
## Change (second commit, after review)
The first commit let any `QSystemUserSession` through in the checker. Review found that too broad: the pass covered insert, update and delete on every table nobody may write directly, so any server code running as the system user (a host, another qbit) could update or delete `object_storage_operation` rows. The second commit replaces it.
1. **The checker no longer looks at the session class.** A write of those tables that reaches `ObjectStoragePermissionChecker` is refused to a person, to `object_storage.admin` and to the core system user alike.
2. **The service marks its own writes.** `Db.insert`, `Db.update` and `Db.delete` set core's per-call permission bypass on their input (`AbstractActionInput.withPermissionBypass(Db.OWN_WRITE_REASON)`, read in qqq-backend-core 4.1.0-RC.25). Core does not ask a checker about an input that carries a bypass, and logs the reason at DEBUG.
3. **A failed audit row is no longer only a warning.** `OperationLog` logs it at ERROR, raises the `storage.operation_log.failed` alert on the `StorageAlertSink` (severity `error`, dedupe key `bucket:<alias>`, resolved by the next row written for that bucket) and counts it in `OperationLog.failedWriteCount()`. It also now detects a row that `InsertAction` hands back with a record error instead of throwing (a refused value, a record lock); before, that row was dropped with no log line from the qbit at all. Whether the storage operation succeeds is unchanged: it does.
### The operator signal
The qbit reports conditions through `StorageAlertSink` (scan stale, objects missing, quota, connection failed), so the alert is the main signal. The sink is a no-op until a host binds an adapter, so there is also a counter the host can read: `OperationLog.failedWriteCount()`, the rows this node could not write since it started. A host exports it (a gauge, or a health check of its own) and alerts above zero. The existing health checks (`object_storage.connection.<id>`) are dependency probes of the store; a missing audit row is not a reason to report a connection down, so no health check was added.
Limits: the count and the alert state are per node. On several nodes, an alert raised by one node is resolved only when that node next writes a row for the bucket.
## Tests
`DbSystemSessionTest` (instance with `enforcePermissionsInCoreActions(true)`):
- `testServiceWritesItsOwnTablesWhenCoreActionsEnforcePermissions`: an operation log entry written from a person's session is there, and its `actor` is that person, not the system user that wrote the row (`StorageCaller.user()` captures the user before `Db.asSystem`). `Db` insert, update and delete of an index row work too.
- `testSystemSessionOutsideTheServiceCannotWriteTheOperationLog`: with a `QSystemUserSession` in the context, and also inside `Db.asSystem`, a plain `InsertAction`, `UpdateAction` and `DeleteAction` on `object_storage_operation` are refused; the row is unchanged.
- `testOperationRowIsNeverUpdatedOrDeletedWithoutTheBypass`: update, delete by key and delete by filter are refused to a person, the storage administrator and the system user; the row is unchanged.
`OperationLogTest.testFailedRowIsAnErrorAnAlertAndACount`: a row refused with a record error and a row whose insert throws are each logged at ERROR, raised as the alert and counted, and the caller is not failed; the next row written resolves the alert once.
Checked against the earlier code: with the first commit's checker the second and third tests fail; with `Db` not marking its writes the first test fails.
`./mvnw -B verify` (nothing skipped) on `a8e8750` (this branch) and on `59b0ea2` (the `release/0.1` port): BUILD SUCCESS on both, 169 tests, 0 failures, 0 errors, 2 skipped (the two Garage conformance tests that are skipped on `develop` too), 0 Checkstyle violations and the JaCoCo gate passed.
## Every `Db` write, checked
All table writes in the qbit go through `Db.insert`, `Db.update` or `Db.delete` (no other use of the insert, update or delete actions in `src/main`), so all carry the bypass.
Inside `Db.asSystem`:
- `OperationLog.write`: operation insert.
- `ObjectIndexHook.objectWritten` / `objectRemoved`: object insert, update, delete; folder insert and update and the bucket rollup (`FolderIndex.ensureFor`, `applyDelta`).
- `ObjectIndex.scan` (`doScan`, `upsertPage`, `purgeExpiredDeletedRows`, `FolderIndex.load`), `markBucket`, `recomputeStats` (`FolderIndex.writeRollups`), `headBacklog`, `refresh`: object, folder and bucket writes.
- `ReferenceScan.run` (`doRun`): reference delete and insert, object and bucket updates.
- `StorageJobs` usage snapshot: usage insert and update.
- `CapabilityStore` and `CapabilityProbe`: capability insert and update, bucket versioning state.
- `ConfigSync.sync`: connection and bucket insert and update.
Outside `Db.asSystem` (two, both unchanged in effect): `TestConnectionProcess.ReadLoad.runOnePage` updates the connection row and `BucketProcesses.CheckLoad.apply` updates the bucket row, in the session of the person running the process. Both run inside a process step, which core already treated as trusted, both tables are the admin-editable ones, and record security locks still apply to that person.
## Limits, and what I would do next
- Core's bypass is per call, not per qbit: other server code can set its own `withPermissionBypass(reason)`, and a call nested inside a core action (a process step, a customizer) or made in a host that does not enforce permissions in core actions is never put to a checker. That is core's model and is the same as before this PR. The service only ever inserts operation rows, so a follow-up could make the log append-only in every mode with a pre-update and pre-delete customizer on `object_storage_operation` that always refuses.
- **Person-initiated stores.** This PR keeps the store succeeding when its audit row fails. For a store a person starts (UI upload and the record actions, `StorageCaller.user()`), I would recommend failing closed: write the row before the provider call and refuse the operation if it cannot be written, then record the outcome as a second row rather than an update, so the log stays append-only. System callers (scans, scheduled jobs, cleanup) would stay best effort with the alert, because failing a purge or a scan over a log row does more harm than the gap.
## Not in this change
The released line that hosts consume is `release/0.1`. The same two commits are on `fix/system-writes-under-core-enforcement-0.1` (based on `release/0.1`); no pull request into `release/0.1` is open.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
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>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What was seen
In a host application that sets
QInstance.enforcePermissionsInCoreActions(true), everyStorageBucket.putstored the object and then logged:object_storage_operationhad no row for any stored file, so the audit of stored objects was silently incomplete.Cause
InsertAction,UpdateActionandDeleteActionfor every outermost call.Db.asSystemstarts a new context (new CapturedContext(instance, systemSession), authorized scope depth 0), so each write it makes is outermost, wherever it was called from.ObjectStoragePermissionCheckerrefuses every write of a table that is not admin-editable to every session, theQSystemUserSessionthe service itself writes as included. Core calls a custom checker before it looks at the session.The same refusal applied to every other
Db.asSystemwrite of those tables (the object index, references, capabilities, usage), not only the operation log.Change (second commit, after review)
The first commit let any
QSystemUserSessionthrough in the checker. Review found that too broad: the pass covered insert, update and delete on every table nobody may write directly, so any server code running as the system user (a host, another qbit) could update or deleteobject_storage_operationrows. The second commit replaces it.ObjectStoragePermissionCheckeris refused to a person, toobject_storage.adminand to the core system user alike.Db.insert,Db.updateandDb.deleteset core's per-call permission bypass on their input (AbstractActionInput.withPermissionBypass(Db.OWN_WRITE_REASON), read in qqq-backend-core 4.1.0-RC.25). Core does not ask a checker about an input that carries a bypass, and logs the reason at DEBUG.OperationLoglogs it at ERROR, raises thestorage.operation_log.failedalert on theStorageAlertSink(severityerror, dedupe keybucket:<alias>, resolved by the next row written for that bucket) and counts it inOperationLog.failedWriteCount(). It also now detects a row thatInsertActionhands back with a record error instead of throwing (a refused value, a record lock); before, that row was dropped with no log line from the qbit at all. Whether the storage operation succeeds is unchanged: it does.The operator signal
The qbit reports conditions through
StorageAlertSink(scan stale, objects missing, quota, connection failed), so the alert is the main signal. The sink is a no-op until a host binds an adapter, so there is also a counter the host can read:OperationLog.failedWriteCount(), the rows this node could not write since it started. A host exports it (a gauge, or a health check of its own) and alerts above zero. The existing health checks (object_storage.connection.<id>) are dependency probes of the store; a missing audit row is not a reason to report a connection down, so no health check was added.Limits: the count and the alert state are per node. On several nodes, an alert raised by one node is resolved only when that node next writes a row for the bucket.
Tests
DbSystemSessionTest(instance withenforcePermissionsInCoreActions(true)):testServiceWritesItsOwnTablesWhenCoreActionsEnforcePermissions: an operation log entry written from a person's session is there, and itsactoris that person, not the system user that wrote the row (StorageCaller.user()captures the user beforeDb.asSystem).Dbinsert, update and delete of an index row work too.testSystemSessionOutsideTheServiceCannotWriteTheOperationLog: with aQSystemUserSessionin the context, and also insideDb.asSystem, a plainInsertAction,UpdateActionandDeleteActiononobject_storage_operationare refused; the row is unchanged.testOperationRowIsNeverUpdatedOrDeletedWithoutTheBypass: update, delete by key and delete by filter are refused to a person, the storage administrator and the system user; the row is unchanged.OperationLogTest.testFailedRowIsAnErrorAnAlertAndACount: a row refused with a record error and a row whose insert throws are each logged at ERROR, raised as the alert and counted, and the caller is not failed; the next row written resolves the alert once.Checked against the earlier code: with the first commit's checker the second and third tests fail; with
Dbnot marking its writes the first test fails../mvnw -B verify(nothing skipped) ona8e8750(this branch) and on59b0ea2(therelease/0.1port): BUILD SUCCESS on both, 169 tests, 0 failures, 0 errors, 2 skipped (the two Garage conformance tests that are skipped ondeveloptoo), 0 Checkstyle violations and the JaCoCo gate passed.Every
Dbwrite, checkedAll table writes in the qbit go through
Db.insert,Db.updateorDb.delete(no other use of the insert, update or delete actions insrc/main), so all carry the bypass.Inside
Db.asSystem:OperationLog.write: operation insert.ObjectIndexHook.objectWritten/objectRemoved: object insert, update, delete; folder insert and update and the bucket rollup (FolderIndex.ensureFor,applyDelta).ObjectIndex.scan(doScan,upsertPage,purgeExpiredDeletedRows,FolderIndex.load),markBucket,recomputeStats(FolderIndex.writeRollups),headBacklog,refresh: object, folder and bucket writes.ReferenceScan.run(doRun): reference delete and insert, object and bucket updates.StorageJobsusage snapshot: usage insert and update.CapabilityStoreandCapabilityProbe: capability insert and update, bucket versioning state.ConfigSync.sync: connection and bucket insert and update.Outside
Db.asSystem(two, both unchanged in effect):TestConnectionProcess.ReadLoad.runOnePageupdates the connection row andBucketProcesses.CheckLoad.applyupdates the bucket row, in the session of the person running the process. Both run inside a process step, which core already treated as trusted, both tables are the admin-editable ones, and record security locks still apply to that person.Limits, and what I would do next
withPermissionBypass(reason), and a call nested inside a core action (a process step, a customizer) or made in a host that does not enforce permissions in core actions is never put to a checker. That is core's model and is the same as before this PR. The service only ever inserts operation rows, so a follow-up could make the log append-only in every mode with a pre-update and pre-delete customizer onobject_storage_operationthat always refuses.StorageCaller.user()), I would recommend failing closed: write the row before the provider call and refuse the operation if it cannot be written, then record the outcome as a second row rather than an update, so the log stays append-only. System callers (scans, scheduled jobs, cleanup) would stay best effort with the alert, because failing a purge or a scan over a log row does more harm than the gap.Not in this change
The released line that hosts consume is
release/0.1. The same two commits are onfix/system-writes-under-core-enforcement-0.1(based onrelease/0.1); no pull request intorelease/0.1is open.🤖 Generated with Claude Code
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>