Skip to content
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
rubenvdlinde merged 1 commit into
developmentfrom
fix/wave-12-engine-primitives
May 29, 2026
Merged

fix(security): wave-12 engine-primitive enforcement (readOnly, default-closed flag, bulk safeguards, dot-syntax, per-object authz)#2010
rubenvdlinde merged 1 commit into
developmentfrom
fix/wave-12-engine-primitives

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

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:

  • openbuilt default-OPEN write (B1 in audit): schemas without an authorization block grant create/update/delete to every authenticated user.
  • scholiq readOnly no-op (A in audit): readOnly: true was pure metadata, never enforced on writes.
  • OR bulk-path bypass (/tmp/wave11-openregister-report.md SB2): POST /api/bulk/{register}/{schema}/save skipped PermissionHandler + Opis validation + @self stripping + appendOnly + PropertyRBAC.
  • OR @self.organisation injection (SB1): the wave-9 fix(security): wave-7 critical C1 (RevertHandler RBAC) + C2 (@self injection allowlist) #2008 @self strip missed organisation on both single-object and bulk paths → cross-tenant data injection.

Per-fix summary

Fix 1 - JSON-Schema readOnly enforcement

  • New ValidateObject::validateReadOnlyConstraints() compares incoming property values against the previously-stored object on UPDATE. CREATE is bypassed (no prior value).
  • Wired into ObjectService::saveObject right after hard-validation.
  • BC: silent no-op when no schema property declares 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

  • New IAppConfig flag openregister:enforce_default_closed (default false). When true, schemas without authorization AND without explicit "public": true default-CLOSE create/update/delete for authenticated non-admins. Reads remain default-open.
  • BC strategy: flag defaults to 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

  • New SaveObjects::applyBulkSafeguards() runs per-row before the transform/upsert pipeline:
    1. Strips @self.{owner,organisation,authorization} (and underscored forms) for non-admins — closes wave-11 SB1 + SB2 in a single place.
    2. Calls PermissionHandler::hasPermission per row.
    3. Enforces appendOnly schemas on UPDATE rows.
    4. Runs Opis JSON-Schema validation when _validation: true.
    5. Honours _rbac: false only for admin callers (non-admin payloads can't bypass).
  • SaveObject::setSelfMetadata also gates @self.organisation behind callerIsAdmin() (defence-in-depth for the single-object path) — closes wave-11 SB1 organisation injection on both write boundaries.
  • BC strategy: safeguards activate only when PermissionHandler is wired (production DI does inject it; legacy 7-arg test fixtures degrade gracefully).
  • Performance note: per-row PermissionHandler checks are O(N) over the input batch. Existing baseline benchmarks ran at ~5000 obj/s with no per-row checks; expected throughput after this PR is closer to 2000-3000 obj/s. Tracking issue filed for a real benchmark + re-tuning.

Fix 4 - ConditionMatcher dot-syntax

  • ConditionMatcher::resolveDynamicValue() now resolves \$user.uid, \$user.email, \$user.displayName, \$user.groups, \$organisation.uuid.
  • Unknown \$user.<X> / \$organisation.<X> tokens log a warning and return null, which the matcher treats as deny — closes the silent-deny where \$user.unknownThing used to fall through as a literal string and silently never match.
  • BC: bare \$user / \$userId / \$organisation continue to resolve identically.

Fix 5 - Per-object _authorization wired into resolution

  • PermissionHandler::resolveAuthorization() accepts an optional ObjectEntity and merges its non-empty getAuthorization() action-by-action on top of the schema/register baseline.
  • Precedence (highest wins): per-object → schema → register → null.
  • BC: defaults to empty per-object _authorization (existing entity behaviour); the column was dead storage before, so nothing changes for any caller that doesn't start populating it.

Tests added

  • 38 new regression tests across 4 files:
    • 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)
  • Updated 3 existing tests whose assertions encoded the wave-11 organisation-injection behaviour now closed by Fix 3.

Quality gates

  • PHPUnit: 12955 tests (baseline+39), 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.
  • php -l: clean on all touched files.
  • Version bumped to 0.2.13-unstable.85.

Follow-ups

Tracking issues filed for:

  1. Fleet sweep of *_register.json to add explicit authorization blocks ahead of the next-major default-closed flip.
  2. Bulk-save performance benchmark and re-tuning after per-row guard application.

Do NOT admin-merge

Per the wave-12 brief: this is a production app, open the PR for review, do not admin-merge.

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.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant