fix(folders,locking): a system context is recognised, and PATCH answers the lock - #3456
Merged
rubenvdlinde merged 1 commit intoSep 5, 2026
Conversation
…rs the lock Three defects found while validating a live demo instance, none of them by the suite. 1. A repair step or cron job saving an object with a bound folder could not run at all. `FolderManagementHandler::assertFolderIsAccessible()` default-denies when there is no `IUser`, and a system operation has no user by definition, so the two were indistinguishable. `runAsSystem()` did not help: it flips a depth counter, it does not set a session user. Every one of these callers catches and logs, so `occ upgrade` still reported success while the work silently did not happen. The scope in this repo is four write paths: RematerialiseCalculationsCommand, DsarDpiaDetectionJob, VocabularyImportService (reached from both SeedVocabularyRegister on upgrade and ImportSkosCsvCommand), and ImportHandler when its fallback admin cannot read the bound folder. `SystemOperationContext::isActive()` is now recognised here as it already is in MultiTenancyTrait and PermissionHandler. It resolves the app's own principal and runs the SAME checks against that principal's mount. The guard is not skipped and not widened: an anonymous request has no principal to resolve and still lands on the default-deny. 2. A locked object refused PUT with 423 naming the holder and let PATCH and POST-patch reach validation first, so they answered 400 for a malformed payload and a bare 500 for a valid one. Two doors to the same object, two different answers, and the wrong one sends the caller off to fix their payload. All three now go through one `lockRefusalResponse()`, placed before the merge and before validation, over the single predicate `ObjectEntity::isLockedBySomeoneElse()` with run identity arriving ambiently through `FlowRunContext::currentRunUuid()` (openregister#3454). The refusal itself is built once by `LockedException::forObject()`, which the service-layer guard now throws too, so a lock taken between the pre-read and the save answers 423 rather than 500. 3. The folder-binding window is not a race and not intended. Object folders are created under `Open Registers/` in whichever home the CREATING identity had, because `getOpenRegisterUserFolder()` resolves the session user; the compensating share back to everyone else is still a TODO that shares nothing. So a stored binding sits in exactly one user's mount, and re-validating it on every save asked every later editor to prove they were the creator. That is the reported 403: a non-admin cannot write a case somebody else created. Re-validation of a binding the app itself wrote now asks the question that fits it — is this a folder OpenRegister manages? — through `assertManagedFolderIsAccessible()`. The caller-supplied `@self.folder` gate is untouched and still default-denies. A binding pointing outside `Open Registers/` is still refused, which is exactly the planted cross-tenant binding the re-validation was added for. The managed test runs first so an ACCEPTED save no longer stamps a false `folder_access_denied` row into the audit trail. Tests were proven red by neutralising each guard in turn, and each keeps a control that stays green so a blanket exemption cannot pass as a fix.
rubenvdlinde
deleted the
fix/folder-guard-system-context-and-lock-parity
branch
September 5, 2026 17:22
Contributor
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| test-l10n-parity | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 174/174 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ✅ | ||||
| Playwright | ⏭️ deferred — runs on the promotion into beta/main, not on a pull request into development | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-09-05 17:22 UTC
Download the full PDF report from the workflow artifacts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was broken
Three defects found while validating a live demo instance. None of them was found by the suite.
1. A repair step or cron job could not save an object with a bound folder at all
FolderManagementHandler::assertFolderIsAccessible()default-denies when there is noIUser. A system operation has no user by definition, so a repair step and an anonymous request were indistinguishable to it.runAsSystem()does not help:SystemOperationContext::run()flips a depth counter, it never sets a session user.Every one of these callers catches and logs, so the failure is silent: the step records "could not run" and
occ upgradestill reports success. That is how dossiq'sRealignStatutoryVocabularydid nothing while leaving 18 cases and 7 caseTypes holding corrupted confidentiality values.Scope in this repo — four write paths, all behind a catch-and-log:
lib/Command/RematerialiseCalculationsCommand.php:253occ, prints "save failed" and still exits SUCCESSlib/BackgroundJob/DsarDpiaDetectionJob.php:263lib/Service/VocabularyImportService.php(4 sites)SeedVocabularyRegisteron upgrade, andImportSkosCsvCommandlib/Service/Configuration/ImportHandler.php:2887SystemOperationContext::isActive()is now recognised here as it already is inMultiTenancyTrait::hasRbacPermission()andPermissionHandler::hasPermission(). It resolves the app's own principal and runs the same four checks against that principal's mount. The guard is not skipped and not widened: an anonymous request has no principal to resolve and still lands on the default-deny.2. PATCH reached validation before the lock guard
A locked object refused PUT with 423 naming the holder, while PATCH and POST-patch went straight to the merge. On the rig they answered 500 for a valid payload (the service-layer guard threw an untyped
\Exceptioninto a hardcoded 500) and 400 for one that fails hard validation. Two doors to the same object, two different answers, and the wrong one sends the caller off to fix their payload.All three doors now go through one
lockRefusalResponse(), placed before the merge and before validation. It asks the single predicateObjectEntity::isLockedBySomeoneElse()with run identity arriving ambiently throughFlowRunContext::currentRunUuid()(#3454) — the condition is not restated. The refusal is built once byLockedException::forObject(), which the service-layer guard now throws too, so a lock taken between the pre-read and the save answers 423 rather than 500.3. The folder-binding window is not a race, and not intended
It is an identity defect.
getOpenRegisterUserFolder()resolves the session user, so an object folder is created underOpen Registers/in whichever home the creating identity happened to have, and the compensating share back to everyone else is still a TODO that shares nothing. Measured on the rig: alice's case folder is/alice/files/Open Registers/Rig Probe Register/<uuid>, and one created underoccis/openregister/files/....So a stored binding sits in exactly one user's mount, and re-validating it on every save asked every later editor to prove they were the creator. That is the reported 403: a non-admin cannot write a case somebody else created.
Re-validation of a binding the app itself wrote now asks the question that fits it — is this a folder OpenRegister manages? — through the new
assertManagedFolderIsAccessible(). The caller-supplied@self.foldergate is untouched and still default-denies. A binding pointing outsideOpen Registers/is still refused, which is exactly the planted cross-tenant binding the re-validation (#1431) was added for.The managed test runs first, because
assertFolderIsAccessible()writes afolder_access_deniedaudit row before every throw: asking it first would stamp a denial into the forensic record for every save that is then allowed. Verified on the rig — two accepted saves left the audit count unchanged at 10, two genuine refusals took it to 12.The mount lookup uses
IUserMountCache::getMountsForFileId(), notIRootFolder::getById(). The latter needs the owning user's mounts to be set up and answered0on the rig for a folder that demonstrably exists.Tests
Proven red first, by neutralising each guard in turn and confirming the failure names the right thing. Each keeps a control that stays green, so a blanket exemption cannot pass as a fix.
FolderManagementHandlerSystemContextTest(new, 7 tests) — a sessionless system context resolves the app principal and saves; the same context is still refused a folder its own principal cannot read; a userless request outside a system scope is still denied; a managed binding admits a user who did not create it; a binding outside the managed tree is still refused; an accepted save writes no denial audit row; the creator still passes when the mount cache is silent.ObjectsControllerTest::testAllThreeWriteDoorsRefuseALockedObjectIdentically— compares the whole response (status and body together) across PUT, PATCH and POST-patch, so a divergence in either half fails rather than being averaged away, then asserts the shared refusal names the holder. Red without the fix: PATCH returned200with an empty body, having gone straight through to the unstubbedsaveObject.Verified locally
CI is bottlenecked, so this was verified by exit code on a throwaway rig — its own compose project on port 8749, never :8080. Torn down with
down -v.phpunit(19,217 tests, 46,995 assertions)composer phpcspsalmcomposer phpstanphpmdon each changed file, both rulesetshydra-gatesv1.15.1, full treephpcs was canary-tested rather than trusted: a deliberate violation planted in a changed file made it exit 1 and name that file, so the clean run is a real clean run.
Rig evidence, before → after:
occsave of a folder-bound object in a system contextFolderAccessDeniedExceptionfolder_access_deniedOpen Registers/composer.lockcarries a within-caretconduction/hydra-gates1.15.0 → 1.15.1 bump, taken before running the gates so a stale vendored package could not invent failures or hide one.Not fixed here, and deliberately
getOpenRegisterUserFolder()returning the session user is the root cause of defect 3, and moving object folders into the app's own home is the real repair. It is not in this PR: it relocates files out of users' Files trees while the share-back is still a TODO stub, which is a visible change to make on its own evidence rather than alongside a lock fix. The guard change above makes the symptom correct today without moving a single file.🤖 Generated with Claude Code