This repository was archived by the owner on May 29, 2026. It is now read-only.
fix(security): wave-12 engine-primitive enforcement (readOnly, default-closed flag, bulk safeguards, dot-syntax, per-object authz)#2010
Merged
Conversation
Closes five engine-primitive gaps documented in the Wave-11.5 audit
(/tmp/wave11-or-engine-primitives.md) where leaf-app fix agents had
silently been relying on guarantees that did not exist:
Fix 1 - JSON-Schema readOnly enforcement on write path
* New ValidateObject::validateReadOnlyConstraints() compares incoming
property values against the previously-stored object data on UPDATE
and rejects mutations of properties declared readOnly: true.
* CREATE intentionally bypassed (no prior value to violate).
* Wired into ObjectService::saveObject right after hard-validation.
Fix 2 - PermissionHandler default-closed flag (BC-safe)
* New IAppConfig flag 'openregister:enforce_default_closed' (default
false). When true, schemas without an authorization block AND
without explicit "public": true default-CLOSE create/update/delete
for authenticated non-admins. Reads remain default-open
(@publicpage is the OR-wide read model).
* With the flag off (BC default) the engine emits a deprecation
warning the first time each write action hits a schema with no
auth block, so leaf-app maintainers see actionable signal ahead of
the next-major default flip.
Fix 3 - Bulk-save path safeguards
* New SaveObjects::applyBulkSafeguards() runs per-row before the
transform/upsert pipeline:
- strips client-supplied @self/_owner/_organisation/_authorization
for non-admin callers (closes wave-11 SB1 organisation-injection
and SB2 bulk-path owner-injection in one place);
- calls PermissionHandler::hasPermission per row;
- enforces appendOnly schemas on UPDATE rows;
- runs Opis JSON-Schema validation when _validation: true;
- honours _rbac: false only when the caller is admin.
* SaveObject::setSelfMetadata also gates @self.organisation behind
callerIsAdmin() (defence-in-depth for the single-object path).
Fix 4 - ConditionMatcher dot-syntax resolution
* ConditionMatcher::resolveDynamicValue() now resolves $user.uid,
$user.email, $user.displayName, $user.groups, and
$organisation.uuid (parity with the bare $organisation token).
* Unknown $user.<X> / $organisation.<X> tokens log a warning and
resolve to null, which the matcher treats as a deny - closes the
silent-deny gap where $user.unknownThing previously fell through
as a literal string.
Fix 5 - Per-object _authorization column wired into resolution
* PermissionHandler::resolveAuthorization() now accepts an optional
ObjectEntity and merges its non-empty getAuthorization() on top of
the schema/register baseline action-by-action. The column was
dead storage before; the override path lets schema authors seal
or unlock individual objects without rewriting the schema.
Tests
* 38 new regression tests across four files:
- Wave12ReadOnlyEnforcementTest (9)
- Wave12PermissionHandlerDefaultClosedTest (10, covers Fix 2 + 5)
- Wave12BulkSafeguardsTest (7)
- Wave12ConditionMatcherDotSyntaxTest (12)
* Updated 3 existing tests whose assertions encoded the wave-11
organisation-injection behaviour now closed by Fix 3.
* Full unit suite: 12955 tests, 24 failures (matches baseline),
3119 errors (matches baseline) - no regressions.
* PHPStan: 12 errors (matches baseline - no new errors).
* PHPCS clean on all modified lib/ files.
Backwards compatibility
* Every new behaviour is OFF by default or opt-in:
- readOnly enforcement is silently no-op when no schema declares
readOnly: true (no fleet impact yet).
- default-closed is behind the enforce_default_closed flag (false
by default).
- bulk safeguards inject only when PermissionHandler is wired
(production DI does inject it; legacy test fixtures gracefully
degrade).
- dot-syntax resolution adds new behaviour without changing
existing bare-token semantics.
- per-object _authorization defaults to empty (BC behaviour).
* Version bumped to 0.2.13-unstable.85 for the immutable cache-bust.
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
Context
This PR closes 5 engine-primitive gaps identified in the Wave-11.5 audit (
/tmp/wave11-or-engine-primitives.md) where ~15 fleet leaf apps had been silently relying on OR engine guarantees that did not exist. Together they address the 4 fleet-class SHIP-BLOCKING issues surfaced in wave-11:B1in audit): schemas without anauthorizationblock grant create/update/delete to every authenticated user.Ain audit):readOnly: truewas pure metadata, never enforced on writes./tmp/wave11-openregister-report.mdSB2):POST /api/bulk/{register}/{schema}/saveskipped PermissionHandler + Opis validation + @self stripping + appendOnly + PropertyRBAC.@selfstrip missedorganisationon both single-object and bulk paths → cross-tenant data injection.Per-fix summary
Fix 1 - JSON-Schema readOnly enforcement
ValidateObject::validateReadOnlyConstraints()compares incoming property values against the previously-stored object on UPDATE. CREATE is bypassed (no prior value).ObjectService::saveObjectright after hard-validation.readOnly: true(i.e. the entire fleet today). Schema authors can now start using readOnly as a real primitive.Fix 2 - PermissionHandler default-closed flag
openregister:enforce_default_closed(defaultfalse). Whentrue, schemas withoutauthorizationAND without explicit"public": truedefault-CLOSE create/update/delete for authenticated non-admins. Reads remain default-open.false, preserving wave-11 behaviour for the ~15 leaf apps that lack auth blocks. The engine logs a one-shot deprecation WARNING per action when the flag is off and a schema-without-auth grants a write — actionable signal for the next-major flip. Tracking issue filed for that flip + a fleet sweep of*_register.json.Fix 3 - Bulk-save path safeguards
SaveObjects::applyBulkSafeguards()runs per-row before the transform/upsert pipeline:@self.{owner,organisation,authorization}(and underscored forms) for non-admins — closes wave-11 SB1 + SB2 in a single place.PermissionHandler::hasPermissionper row._validation: true._rbac: falseonly for admin callers (non-admin payloads can't bypass).SaveObject::setSelfMetadataalso gates@self.organisationbehindcallerIsAdmin()(defence-in-depth for the single-object path) — closes wave-11 SB1 organisation injection on both write boundaries.Fix 4 - ConditionMatcher dot-syntax
ConditionMatcher::resolveDynamicValue()now resolves\$user.uid,\$user.email,\$user.displayName,\$user.groups,\$organisation.uuid.\$user.<X>/\$organisation.<X>tokens log a warning and return null, which the matcher treats as deny — closes the silent-deny where\$user.unknownThingused to fall through as a literal string and silently never match.\$user/\$userId/\$organisationcontinue to resolve identically.Fix 5 - Per-object _authorization wired into resolution
PermissionHandler::resolveAuthorization()accepts an optional ObjectEntity and merges its non-emptygetAuthorization()action-by-action on top of the schema/register baseline.Tests added
tests/Unit/Service/Object/Wave12ReadOnlyEnforcementTest.php(9 tests)tests/Unit/Service/Object/Wave12PermissionHandlerDefaultClosedTest.php(10 tests; covers Fix 2 + Fix 5)tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php(7 tests)tests/Unit/Service/Wave12ConditionMatcherDotSyntaxTest.php(12 tests)Quality gates
Follow-ups
Tracking issues filed for:
*_register.jsonto add explicit authorization blocks ahead of the next-major default-closed flip.Do NOT admin-merge
Per the wave-12 brief: this is a production app, open the PR for review, do not admin-merge.