Skip to content

Restore operation-scoped read authorization and add explicit row filters - #1915

Merged
kriszyp merged 4 commits into
mainfrom
fix/subscribe-loadasinstance-auth
Jul 30, 2026
Merged

Restore operation-scoped read authorization and add explicit row filters#1915
kriszyp merged 4 commits into
mainfrom
fix/subscribe-loadasinstance-auth

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 23, 2026

Copy link
Copy Markdown
Member

Summary

  • restore the Harper 5.1 operation-scoped allowRead contract instead of automatically invoking application overrides per row or subscription event
  • run the read gate before collection query planning, scans, or subscription audit setup, preventing unauthorized callers from starting expensive work
  • add explicit JavaScript rowFilter(record, context) and subscription eventFilter(event, context) predicates for applications that intentionally need row-level narrowing
  • preserve filtered HNSW traversal, OR/range filtering, source-revalidated record checks, subscription snapshots, replay, live events, and reload snapshots through those explicit predicates
  • close the loadAsInstance = false subscription gap with one collection-scoped allowRead check in the built-in table handler; custom false-mode handlers still own authorization unless they delegate
  • carry attribute-permission projection narrowing into the separately parsed REST QUERY body
  • make subscription termination authoritative and preserve the complete admitted request target for continuous reauthorization

This is a targeted semantic rollback of #1786 rather than a full code revert. Predicate-aware HNSW support remains, but application row filtering is now explicit and happens only after the operation override admits the request.

The narrow v5.1 backport remains #1914.

Compatibility model

The legacy allowRead, allowUpdate, allowCreate, and allowDelete hooks remain one-time operation gates and are deprecated in favor of operation overrides (get, put, delete, and so on), which have the complete target and access to request context.

  • default instance mode evaluates the relevant hook once for the operation
  • built-in loadAsInstance = false table handlers use one request/collection verdict
  • custom false-mode handlers retain their historical ownership of authorization unless they delegate to a built-in handler
  • a collection override can attach rowFilter/eventFilter after admission when row-level behavior is actually required

This maximizes compatibility with Harper 5.1 and HarperDB 4.7. Central Manager currently contains custom false-mode handlers with their own authorization; Host Manager does not use Resource/loadAsInstance handlers.

Security details

  • denied collection reads fail before a table scan starts
  • async and throwing allowRead hooks fail closed
  • Promise-backed REST QUERY bodies are resolved before admission, and client checkPermission fields are removed recursively before relationship authorization
  • denied attributes cannot be restored through a QUERY body select
  • async false-mode gates reserve the normal read snapshot before returning and remain pre-response through async/mapped overrides
  • predicate errors or thenables terminate subscriptions and settle their iterators
  • revocation closes the queue, discards buffered events, and stops initial replay/drains
  • reauthorization receives a fresh clone of the complete initially admitted target
  • previousCount counts accepted events and has an independent inspected-entry bound

Validation

  • TypeScript build, oxlint, Prettier, and git diff --check
  • full resource unit suite: 1,269 passing, 14 pending
  • full core unit suite: 3,799 passing, 188 pending
  • focused authorization/search/subscription unit suite: 73 passing
  • LMDB QUERY authorization regression suite: 9 passing
  • QUERY attribute/row-filter integration: 3 passing
  • filtered subscription integration: 3 passing
  • live subscription revocation integration: 6 passing
  • final full integration run: 1,571 passing, 0 failing; six optional real-Ollama tests cancelled by the existing JSON import-attribute startup issue

Review

Thorough cross-model review plus Harper auth-domain adjudication found no remaining code blockers. The review caught and drove fixes for Promise-backed/nested QUERY permission controls, full-target reauthorization, replay-after-close, audit-before-auth ordering, async gate timing and snapshot retention, transformed iterable authorization, GraphQL async-search consumption, terminal predicate failures, and single-record history counting.

Cross-model coverage: author Codex; independent Claude CLI ✓ / Gemini ✓ / final Harper-domain artifact review ✓.

Generated with GPT-5.6 Codex.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the transactional logic in resources/Resource.ts to ensure that table subscriptions still enforce connection grants and row-level delivery markers when opting out of instance loading (loadAsInstance = false). It also adds comprehensive unit tests to verify this behavior. The review feedback suggests replacing a fixed setTimeout in the new tests with a condition-wait helper to prevent potential test flakiness in slower environments.

Comment thread unitTests/resources/vectorIndex.test.js Outdated
@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp
kriszyp requested review from Devin-Holland and removed request for cb1kenobi July 23, 2026 19:03
@kriszyp
kriszyp requested a review from Devin-Holland July 23, 2026 20:48
@kriszyp
kriszyp marked this pull request as ready for review July 24, 2026 00:00
@kriszyp
kriszyp force-pushed the fix/subscribe-loadasinstance-auth branch from 702f3e1 to 8fdcf3e Compare July 26, 2026 22:18
@kriszyp kriszyp changed the title Enforce subscription authorization for false-mode tables Restore operation-scoped read authorization and add explicit row filters Jul 26, 2026

@Devin-Holland Devin-Holland left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving at 1e8e4f64, and re-stating scope deliberately — the approval you were carrying from me was given to a different PR. At the sha I approved on 07-23 (85b0eee1) this was 2 files, +140/-1 (resources/Resource.ts +7/-1 and one test). It is now 24 files, +1117/-1020, and range-diff shows zero commit correspondence — both of my approved commits were dropped and replaced. So that approval was stale in a way worth naming rather than silently carrying forward.

Verified this round, by reading the code at head:

  1. The read gate fails closed on both paths. Search (resources/Table.ts:2823-2831): a synchronously throwing allowRead is caught and converted to AccessViolation; a thenable one gets Promise.resolve(allowed).then(onFulfilled, () => ({ allowed: false })), so a rejection both fails closed and cannot surface as an unhandled rejection. Subscribe (:3645-3654): await inside try/catch, so rejection and throw land identically, and if (!allowed) throw new AccessViolation.

  2. The gate really does precede the work. On the async path the real search is only initialized inside the authorized branch, and in subscribe the check sits ahead of the auditStore assertion and addSubscription. Denied collection reads don't start a scan or audit setup.

  3. No bypass around the gated iterable. The async gate replaces iterate on an ExtendedIterable, so I checked the consumption surface in @harperfast/extended-iterable: [Symbol.asyncIterator], [Symbol.iterator], forEach, map, and asArray all delegate to this.iterate. Sync consumption of an async-gated result throws (Can not synchronously iterate while allowRead is asynchronous) rather than returning a truncated success — the right direction.

  4. Deleting rejected-thenable-allowread-no-unhandled.test.ts is defensible, and I went looking for the property rather than assuming. The per-row thenable allowRead path is exactly what this PR rolls back, and the guarantee survives in code: evaluateFilter (:3683-3695) attaches decision.then(undefined, () => {}) before throwing ClientError, and both allowRead paths in (1) handle rejection. unitTests/resources/IterableEventQueue.test.js:77 is a reasonable successor.

    One note on shape, not a request: the deleted test asserted a process-level absence of unhandled rejections; the replacement asserts terminal-error delivery at the unit level. On an authorization path an unhandled rejection can take a worker down, so the net is slightly narrower than it was. Worth knowing, given how cheap that integration test was to keep running.

  5. The rowFilter/tombstone asymmetry is deliberate and documented. A rowFilter-only subscriber never receives deletes: deletes are classified by a falsy value (:3737), so hasAuthoritativeRow is false and allowsEvent returns Boolean(eventFilter) — i.e. false. That's the security-correct direction (delivering a tombstone for a row the subscriber was never allowed to see would leak its existence), and resources/DESIGN.md:97 states the contract: rowFilter covers full-row events, eventFilter handles tombstones. My only suggestion is that this deserves to reach the public docs, because a subscriber maintaining a materialized view with rowFilter alone will silently diverge on deletes and the failure is invisible.

What I did not cover, so this approval is scoped to the above: roughly two-thirds of the diff — the query-planning and HNSW predicate composition in Table.ts, the 216 changed lines in Resource.ts, the replay/snapshot and reload-resnapshot mechanics, and the REST QUERY checkPermission-stripping path described in DESIGN.md:99. I also could not execute the suites you cite: unitTests/mocha.init.js needs a built dist/, and building in my checkout fails on unrelated dependency drift (structon, busboy, @harperfast/rocksdb-js API mismatch) — an artifact of my environment, not of this PR. So I'm relying on your reported 1,269/3,799/73/9 runs for test evidence.

Net: I found no defect in the authorization semantics I examined, and the fail-closed behaviour is right in every path I traced. Approving on that basis, with the coverage boundary stated so it isn't mistaken for a full sign-off on a 1,100-line auth rewrite.

🤖 Reviewed by Claude Opus 5 via Claude Code

kriszyp added 2 commits July 30, 2026 12:38
#1842's operationScopedWriteAuth test asserted a denying allowRead on the
still-armed caller target yielded an empty set. Under operation-scoped read
authorization the armed target is a one-shot admission gate, so the deny
throws AccessViolation instead.
@kriszyp
kriszyp merged commit cc8ebfb into main Jul 30, 2026
69 of 70 checks passed
@kriszyp
kriszyp deleted the fix/subscribe-loadasinstance-auth branch July 30, 2026 19:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants