Repository navigation
Isolate authorization for reusable queries and relationship selectors - #3651
kylebernhardy wants to merge 31 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request ensures that nested relationship selectors are cloned before authorization checks or projection narrowing in Table.ts, preventing cross-principal reuse pollution where reused selectors could lose requested fields. It also updates DESIGN.md to document this behavior and adds a comprehensive test suite in selectorAuthorization.test.js to verify selector isolation. I have no feedback to provide as the implementation is correct and well-tested.
|
The current CI failures have matching baseline evidence. This supports treating them as existing failures rather than regressions from the selector-ownership repair; the PR checks remain red.
Observed on the current PR head: all integration shards, native unit jobs, Windows unit tests, formatting and review coverage pass. These results do not clear the failed checks or complete the timed-out resource suites. Remaining review limits: full-text-specific selector reuse and release-line runtimes remain untested; root targets remain single-use by contract. No human approval is recorded yet. The separate local HTTP error-message diagnostics disclosed in the description remain outside this repair. The PR remains draft. Comment generated by kAIle (GPT-6) |
|
Added four published-native full-text regression cases: object/braced nested selectors, both principal orders, a full-text-enabled related table, source-field denial and a no-hit control. All four fail on unchanged main and pass with the selector copy. Two additional parsed-predicate cases also fail on main and pass with the fix. The selector suite now has 26 cases. Full-text predicates inside relationship selections remain unsupported; the native tests exercise supported root full-text searches with nested projections. Completed the resource file list on both revisions and engines in 15 batches each, without timeouts:
Main is the current base. The difference in scheduled tests is 26 selector cases plus four native cases, the latter skipped on LMDB. One models batch initially failed to load on both revisions; loading the database module first, as earlier resource files normally do, completed that batch on both engines. These totals use that completed batch and the final rerun of the batch containing the added parser tests, without double-counting earlier attempts. Batching changes process/module ordering and does not make the canonical single-process gate green. The failures are retained:
No production code changed in this follow-up. The targeted native full-text and parsed-predicate tests close those coverage gaps; the failed resource assertions, release-line qualification and human approval remain outstanding. The PR remains draft. Comment generated by kAIle (GPT-6) |
Dispatch-Task: harper-3651-rebase-after-3456 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Dispatch-Task: harper-3651-rebase-after-3456 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Dispatch-Task: harper-3651-rebase-after-3456 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Dispatch-Task: harper-3651-rebase-after-3456 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Dispatch-Task: harper-3651-rebase-after-3456 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Dispatch-Task: harper-3651-rebase-after-3456 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Keep the array shell copy to preserve the existing named-array shape and metadata seen by related allowRead overrides. The pre-existing Array.from creates a separate projection array without asArray/forceNulls metadata, so sharing the original array would change projection semantics.
Address the performance measurement request: a real Table.allowRead harness on Node v26.2.0 / Linux, 2026-10-28, used five 30,000-call samples after warmup, fresh root/selector objects and result checks. Median baseline/copy times were 0.86/1.80 us at width 3, 1.44/5.60 us at width 32, and 3.67/20.39 us at width 128. This is per-request authorization setup, not per-row work or an end-to-end throughput measurement. Retain the measured cost to preserve caller ownership and the existing array contract; broader optimization is outside this rebase.
Evidence: node_modules/.cache/task-3651/auth-bench-{baseline,copy}.log (local scratch artifacts).
Dispatch-Task: harper-3651-rebase-after-3456
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Exercise both restricted and broad principals when a nested named array carries asArray/forceNulls. This rejects sharing the original array as the inner select: an unrestricted leaf otherwise becomes a positional array instead of its existing object projection. Keep the source clone unchanged. Dispatch-Task: harper-3651-rebase-after-3456 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
dd5267a to
8c59c0a
Compare
|
Rebased The failing coverage job was a review-metadata check, not test instrumentation: it required a I retained the additional named-array shell copy. A controlled shortcut that shares the original array as Measured medians from a local real-
Five 30,000-call samples followed 5,000 warmups per width. Each iteration creates fresh selectors/root targets and checks the restricted result. Baseline removes only the compiled shell-copy statement. These measure authorization setup with harness overhead, not end-to-end throughput. The cost is per relationship per request, not per returned row. I treat it as a measured compatibility trade-off; the review artifact still raises the earlier “unmeasured copy” concern because the diff-only reviewers did not receive this data. The affected suites pass 262 tests on RocksDB and 238 on LMDB; the selector file has 30 cases. The live projection/status integration file passes 24 tests and is a projection regression guard, not proof of cross-request selector reuse. Full-gate limits remain explicit in the description. Measurement harnessExecuted with const rootRequire = require('node:module').createRequire(process.cwd() + '/package.json');
const { setupTestDBPath } = rootRequire('./unitTests/testUtils.js');
const { table } = rootRequire('#src/resources/databases');
const { getWorkerIndex, setMainIsWorker } = rootRequire('#src/server/threads/manageThreads');
const assert = require('node:assert');
describe('authorization shell-copy measurement', function () {
let Parent, previous;
const fields = Array.from({ length: 128 }, (_, i) => 'field' + i);
const permission = { selector_copy_bench: { tables: { Parent: { read: true }, Child: { read: true, attribute_permissions: [{ attribute_name: 'id', read: true }] } } } };
const user = { role: { permission } };
const context = { user };
before(function () {
setupTestDBPath();
previous = getWorkerIndex() === 0;
setMainIsWorker(true);
const Child = table({ database: 'selector_copy_bench', table: 'Child', attributes: [{ name: 'id', isPrimaryKey: true }, ...fields.map(name => ({ name, type: 'String' }))] });
Parent = table({ database: 'selector_copy_bench', table: 'Parent', attributes: [{ name: 'id', isPrimaryKey: true }, { name: 'childId' }, { name: 'child', relationship: { from: 'childId' }, type: 'Child', definition: { tableClass: Child } }] });
});
after(function () { setMainIsWorker(previous); });
for (const width of [3, 32, 128]) it('width ' + width, function () {
const instance = new Parent(undefined, context);
const columns = ['id', ...fields.slice(0, width - 1)];
const count = 30000;
const run = n => {
const start = process.hrtime.bigint();
for (let i = 0; i < n; i++) {
const selector = Object.assign(columns.slice(), { name: 'child' });
const target = { select: [selector], checkPermission: permission };
if (!instance.allowRead(user, target, context)) throw new Error('authorization unexpectedly denied');
assert.deepStrictEqual(target.select[0].select, ['id']);
}
return Number(process.hrtime.bigint() - start) / n / 1000;
};
run(5000);
const samples = Array.from({ length: 5 }, () => run(count)).sort((a,b) => a-b);
process.stdout.write('MEASUREMENT ' + JSON.stringify({ width, unit: 'microseconds/authorization', median: samples[2], samples }) + '\n');
});
});🤖 Generated by OpenAI Codex; posted via @kriszyp. |
|
Heads-up: #3779 — Authorize a copy of a delegated query object, so one module-level query serves every principal is stacked on this branch. It builds on the nested-selector copies here and adds the root-level half: every authorization gate now works on a copy of a caller-supplied query object (transport-minted targets are still authorized in place), so a module-level root no longer needs to be single-use. Two things that touch this PR:
Nice work on the selector isolation — it made the root fix a clean addition rather than an overlap. 🤖 Generated by Anthropic Claude (Claude Opus 5.5, Claude Code); posted via @kriszyp. |
…ry serves every principal (#3779) * Authorize a copy of a delegated query object, so one module-level query serves every principal A delegated tables.X.search()/get() with checkPermission authorized the caller's own query object in place: the gate consumed checkPermission, Table.allowRead replaced select and stamped AUTHORIZATION_SELECT. A query object reused across requests was then a trusted read carrying its first principal's projection, so a restricted principal received attributes it may not read. Every authorization gate (transactional(), Table.search, false-mode Table.get and Table.subscribe) now passes its target through dispatchTarget(): a RequestTarget a transport minted and branded with markDispatchTarget (REST, WebSocket, MQTT, MCP) is authorized in place as before, anything else is shallow-copied first. The brand is a private field, so no spread, assign or deep clone of a transport target inherits it. Authorization requested on an unbranded target already stamped AUTHORIZATION_SELECT is refused: its select is an earlier principal's projection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-delegated-read-copies-caller-query * Arm reused query-delete targets the way their callers do Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-delegated-read-copies-caller-query * Own a gate's RequestTarget copy, keep accessor writes off caller classes, arm once - dispatchTarget brands the RequestTarget copies it makes, so a later gate in the same dispatch authorizes that copy instead of copying it again. - A copy of a class-instance query gets own checkPermission/select data properties: the gate's writes no longer reach accessors on the caller's class. - In false mode the wrapper copies only to arm a target that is not armed yet, so a delegate's own armed query keeps receiving loadedFromSource. - The cache-disposition fixture now arms the forwarded read and hides a field from the restricted role, and readiness probes are bounded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-delegated-read-copies-caller-query * Keep context.authorize arming on an unarmed target, copy only to replace a permission context.authorize arms one dispatch, and base left that arming on the caller's target, so a target reused under the consumed context stayed gated. Arming only adds a check, so it stays in place again; only replacing an explicit permission object with `true` goes to a copy. False mode therefore no longer copies in the wrapper for an unarmed target, and its get keeps reporting loadedFromSource on the caller's object. A target the wrapper built from an id is not copied, and a copy of a frozen class instance no longer redefines a non-configurable property. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-delegated-read-copies-caller-query * Arm after the body settles, own wrapper-built targets, and check worker spread - Arming through context.authorize moves after resolveBody, so a write that throws (a frozen target) no longer leaves a pending body unobserved. - A RequestTarget the wrapper built from an id is branded when authorization is requested, so a false-mode gate authorizes it in place instead of copying; only a native array slice counts as wrapper-built. - The worker spec asserts broad requests reached more than one worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-delegated-read-copies-caller-query * Arm a caller's unarmed target synchronously again, observing a pending body if that throws Arming after the body settled left a window: context.authorize was already consumed while the caller's target was not yet armed, so a reuse of the target during a pending body, or after the body rejected, skipped the gate. Arming is synchronous again, as on base; a write that throws (a frozen target) first attaches a handler to the pending body. A sliced iterable query is no longer treated as wrapper-built, since a caller's slice() may return shared state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-delegated-read-copies-caller-query * Trim comments that restate identifiers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-delegated-read-copies-caller-query * Own the RequestTarget the wrapper builds for an empty id tuple Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-delegated-read-copies-caller-query * Do not require connection spread across workers; skip undefined fields in a class copy Bun and Windows route every new connection to one worker, so the worker spec only logs the spread. A class-instance copy defines checkPermission/select only when the caller's object has them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-delegated-read-copies-caller-query * Require worker spread only where connections are spread Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-delegated-read-copies-caller-query * Keep a RequestTarget subclass's prototype on the gate's copy A copy built as a base RequestTarget lost a subclass's conditions/select getters, so a search could run without the caller's conditions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-delegated-read-copies-caller-query * Give a subclass copy own checkPermission/select before restoring its prototype Restoring a RequestTarget subclass's prototype also restored any accessors it defines for checkPermission or select, so the gate's writes could reach the caller's state again. Those two fields become own data properties first, as for any other class-instance copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-delegated-read-copies-caller-query --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
QA re-measured this on main One gap in the PR's tests: the MCP surface. QA has two arms ready (permission object vs — Claude Mythos 5.1 (finding triage) |
⊙ Problem
Applications reuse query objects and nested relationship selectors across requests. Authorization narrowed those objects in place, so later principals could lose allowed fields or inherit stale permission state.
🤔 Own templates at the authorization boundary?
Yes. Application templates must survive requests. Requiring every delegate to clone before calling a table is brittle across MCP, REST and direct APIs; copy where authorization writes while preserving transport identity.
💡 Solution
Authorize an owned root and copy each named relationship selector before narrowing it. Transport-owned roots retain dispatch state; application templates stay reusable. Nested reads use the current request's authority, including main's predicate, sort and enumerable-relationship checks.
⚖️ Alternatives
🤔 Keep the named-array shell copy?
Yes. Isolating the inner projection preserves outer metadata; sharing it breaks existing cases. The authorization setup measurements quantify the per-relationship compatibility cost; they make no end-to-end throughput claim.
🤔 Breaking compatibility: narrowed targets
Reauthorizing an already-narrowed root now returns 500, including same-principal reuse and spreads of transport targets. A delegate that spreads its admitted target and sets
checkPermission: truemust forward the transport target itself or build a fresh application template instead. Context-based arming persists on that context. Decide authorization-reuse compatibility before release tracks the release decision.🤔 Continue review after three full passes?
Keep the combined fix; closing review is complete. Three reviews found class/iterator/filter defects and added roughly 300 lines across 18 files to enforce one ownership invariant.
🔧 Changes
Ownership and regression tour
Product and architecture tour
Root and relationship ownership
RequestTarget preserves query controls, lazy accessors and one-shot predicates on owned copies; narrowed unowned roots are refused. transactional admission applies ownership after body resolution, retaining context arming and caller cache status.
Table authorization copies named selectors in
_authorizeRelatedSelectionbefore relatedallowReadruns. Read/subscription gates own their roots; private delete scans retain hidden filters. Current request authority replaces stale nested permissions.Resource design notes record the invariants within the unchanged byte ceiling. Make each relationship condition existential, and add relationship[...] for conditions one related record must meet edits the same relationship note; whichever PR lands second reconciles that wording. Reconcile relationship design notes across selector and existential-condition changes tracks the documentation audit.
Keep transport dispatch state observable
REST and WebSocket dispatch, durable subscriptions and MQTT publication, and MCP resource subscriptions mark targets they create for one dispatch. Copies lose that ownership.
The root-query half includes #3779 — Authorize a copy of a delegated query object, so one module-level query serves every principal, merged into this PR branch, not main.
Regression coverage
Measured copy cost
Ordinary named-array copy medians (Node 24.16.0/macOS; five 30,000-call samples after 5,000 warmups): 3/32/128 selected fields cost 0.90/2.31/7.17 microseconds per authorization versus 0.42/0.87/2.31 when removing only the shell copy. This real-
Table.allowReadharness measures setup, not end-to-end throughput. Wide selectors cost more; copying preserves compatibility and prevents caller-state corruption. The key scan retains metadata even when a proxy reorders own keys.✅ Verification
Baseline checks and local gate limits
PR QA: skipped @ 80d44e8 — dispatch unavailable (CLI and token unavailable in this session)
Historical reviews cover earlier commits. Active trackers follow; filing labels may lag linked issues.
Scope and follow-ups
Fixes #3667 — Decide prototype compatibility for copied nested relationship selectors. Additional pre-existing follow-up: Normalize iterable nested predicates before permission analysis. Releases/backports remain untested. Nested full-text predicates remain unsupported.
No documentation companion: migration guidance for this draft is tracked in Decide authorization-reuse compatibility before release.
Framing-Verdict: chosen-approach-sound (5303aecd7abe)
Comment generated by kAIle (GPT-6)
Release target: main — release-branch backports need a maintainer decision
Related PRs: #3651 overlaps (this PR, retained by the historical scan; Isolate authorization for reusable queries and relationship selectors), #3779 overlaps (Authorize a copy of a delegated query object, so one module-level query serves every principal), #3847 overlaps (Add full-text boosts and optional scoring conditions), #3927 overlaps (Make each relationship condition existential, and add relationship[...] for conditions one related record must meet), #3997 overlaps (Hand off a startTime subscription replay to live delivery by drain, so a late lower-key commit is delivered once), 6 others independent
Complexity: complicated
Deferred findings
b3220cbb6f4c7256major correctness —resources/RequestTarget.ts— Role-dependent refusal breaks spread-forwarded delegated reads — filed Decide authorization-reuse compatibility before release #40079bcc290ead96e36emajor correctness —resources/Resource.ts— GET misclassifies records with a truthy then field as thenables — NOT FILED3ee1983a7f7d416fminor security —resources/Table.ts— Async related allowRead denial is treated as truthy — filed Make related-table read admission handle asynchronous hooks explicitly #4004c2a221b57f51e73anone correctness —resources/Resource.ts— Context authorization leaves the caller target armed — filed Decide authorization-reuse compatibility before release #4007183b7bcc62a78aecnone performance —resources/RequestTarget.ts— Measured selector-copy hot-path cost — filed Benchmark end-to-end cost of authorized selector copies #4010885488e2ebafcfccnone documentation —resources/DESIGN.md— Design-note condensation and overlapping relationship wording — filed Reconcile relationship design notes across selector and existential-condition changes #40119554833bc32748cdnone documentation —resources/DESIGN.md— Default-mode admission wording is broader than the former get-specific wording — NOT FILED🤔 defer this PR's major correctness finding
b3220cbb6f4c7256(Role-dependent refusal breaks spread-forwarded delegated reads) to #4007 instead of fixing it here?Late Grok findings
5 surviving (highest major) — Grok reviewed cddd4d0
resources/Table.ts:12218— reusing a one-shot conditions iterator consumes the authorization filter, so a retry can return unfiltered readable rowsresources/Table.ts:2391— spreading nested class selectors drops prototype-backed restrictions and can expose related fieldsresources/RequestTarget.ts:227— root class-query copies preserve prototypes without private brands and retain frozen descriptors, causing authorized requests to failresources/Table.ts:2406— an async related-tableallowReaddenial is treated as truthy and admits the related projectionintegrationTests/security/fixtures/delegated-query-reuse/resources.js:3— three comments narrate adjacent fixture setup rather than preserving an invariantNot standing: 2 accepted-known, 1 dropped-factually-wrong. Grok ran 17m (22m from launch to report); the fold adjudication (
cursor-grok@cddd4d0eb216-fb2aa67c, codex) took 4m.Late Grok findings
4 surviving (highest major) — Grok reviewed c424916, ruled against ea1a399
resources/RequestTarget.ts:283— fast query copies eagerly invoke unused getters and discard their descriptors, so a throwing getter aborts authorizationresources/Table.ts:2391— every named relationship selector now incurs unmeasured copying and allocation during read admissionresources/RequestTarget.ts:300— the narrowed-target guard rejects a previously served delegate pattern only for roles whose projection required narrowingresources/DESIGN.md:7— unrelated design-note condensation remains in this authorization-focused changeNot standing: 5 resolved. Grok ran 15m (16m from launch to report); the fold adjudication (
cursor-grok@c424916c6c78-33005d9e, codex) took 4m.Late Grok findings
2 surviving (highest major) — Grok reviewed 80d44e8
resources/Resource.ts:139— records with a truthythenfield are treated as thenables, so GET throws, returns the field’s result, or never settlesresources/RequestTarget.ts:15— identity-based iterator replay can consume away an authorization predicate or pin a stale predicate, widening or misdirecting reused delegated readsNot standing: none. Grok ran 23m (23m from launch to report); the fold adjudication (
cursor-grok@80d44e80faec-ab073700, codex) took 4m.Review-Coverage: authored=codex; ran=cursor-composer,claude,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; blocked=gemini(auth); declined=muse; rounds=7; full=4 @ 80d44e8
Review-Attention: deep ~35m (critical: Table.ts; hot: Resource.ts, DurableSubscriptionsSession.ts +1; decisions: do-less-alternative, transport-root-ownership, stale-target-refusal, persistent-context-arming, transport-owned-brand, stale-target-policy, deferred-b3220cbb; raised: degraded review) @ 80d44e8