Repository navigation
Grant the primary key and keep protected attributes for attribute-restricted roles on REST, GraphQL and MQTT - #3297
Merged
Conversation
This was referenced Oct 20, 2026
kriszyp
force-pushed
the
fix/resource-path-translated-permissions
branch
from
October 23, 2026 07:31
3b76834 to
75e734c
Compare
… resource-path full puts The resource path (REST, GraphQL, MQTT) authorized attribute-restricted roles against the stored role as written. An unlisted primary key got no access, so reads omitted `id` and an MQTT publish carrying it was refused; and a full put (REST PUT, retained MQTT publish) erased every attribute the role does not list. - getTablePermissions grants an unlisted primary key the translator's implicit access (implicitPrimaryKeyPermission, shared with permissionsTranslator), memoized per stored entry. - allowUpdate no longer writes stored values into the payload it authorizes; it records the role's update map, and a full put by such a role is staged as a patch: the payload, the key, and a removal of each omitted updatable attribute. Its log entry, replay, replication and source write therefore leave protected attributes as stored. With no base it validates as a full record. - PUT bodies are resolved before allow* runs, as QUERY bodies already were, so the attribute check sees the real keys (closes the PUT half of #1434). Fixes #3284 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ED1ufN1nEZ76qGD8BRKYEX Dispatch-Task: harper-3284-resource-path-translated-permissions
- Refuse an array body for attribute-restricted roles in allowCreate/allowUpdate: its keys are indices, and its elements are later written without a check of their own. - Clear the instance's update map on every write, so it authorizes only the write it was checked for. - Never generate a removal for the created/updated time attributes or for an inherited Object.prototype name. - Scope the Bun/Windows skip to the MQTT tests, and bound the test waits. - DESIGN.md: state the put-only source and racing-delete limits. Refs #3284 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ED1ufN1nEZ76qGD8BRKYEX Dispatch-Task: harper-3284-resource-path-translated-permissions
…ep created time on restricted puts - PATCH and POST bodies now reach allowUpdate/allowCreate resolved, as PUT and QUERY bodies do, so an attribute-restricted role can no longer write a protected attribute through them (#1434). - A restricted full put drops supplied created/updated times from its patch, so the stored created time survives as it does on an unrestricted put. - The update map is cleared by the patch and publish paths only, so an override's own write (lock(), addTo()) before super.put no longer drops it. Refs #3284, #1434 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ED1ufN1nEZ76qGD8BRKYEX Dispatch-Task: harper-3284-resource-path-translated-permissions
…put; no key grant on keyless tables - The update map recorded by allowUpdate now stays on the request's instance until a full put takes it, so an override's own update() before super.put can no longer turn the put into an unrestricted replace. A later full put in the same request is restricted too. - A keyless table gets no implicit primary-key entry (it named an `undefined` attribute, which broke restricted reads and admitted a body key "undefined"). - publish bodies are also resolved before authorization. Refs #3284 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ED1ufN1nEZ76qGD8BRKYEX Dispatch-Task: harper-3284-resource-path-translated-permissions
…es with a then field - A full put takes the update map only when its root is a record, so a non-record write cannot consume it unapplied; omitted attributes are detected by own keys. - On a sealed table, no removal is generated for a listed attribute the schema does not declare. - A resolved write body is run as data even when it has a non-function `then`, which a REST PUT/PATCH body could carry once bodies are resolved before authorization. - The body-first method check is computed once per wrapper, not per call. Refs #3284 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ED1ufN1nEZ76qGD8BRKYEX Dispatch-Task: harper-3284-resource-path-translated-permissions
…materialize tracked payloads - allowUpdate's update map is no longer taken by the first full put: it restricts every full put on the request's instance, so a failed and retried super.put cannot become an unrestricted replace. - A tracked payload is materialized before the replacement patch is built, so values behind getRecord() are written rather than removed. - Sealed-table membership is a Set built once per restricted put. Refs #3284 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ED1ufN1nEZ76qGD8BRKYEX Dispatch-Task: harper-3284-resource-path-translated-permissions
…ed attribute names - allowUpdate/allowCreate check the keys of a tracked payload's materialized record, the same record the restricted replacement writes. - Declared attribute names are kept per table, refreshed in updatedAttributes(), instead of being built on each restricted put to a sealed table. - Removal markers are own data properties; trimmed comments that restated the code. Refs #3284 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ED1ufN1nEZ76qGD8BRKYEX Dispatch-Task: harper-3284-resource-path-translated-permissions
A JSON body or MQTT message with a `getRecord` field was taken for a tracked object, so a role allowed to write that field got a 403. Also trims comments that restated the code. Refs #3284 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ED1ufN1nEZ76qGD8BRKYEX Dispatch-Task: harper-3284-resource-path-translated-permissions
… update bodies too - A promised body re-dispatched without a context now carries the ambient context explicitly, so a falsy body is not re-read as the (id, context) form. - The static `update` entry also resolves a promised body before authorization. - The replacement patch defines the primary key as an own property. Refs #3284 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ED1ufN1nEZ76qGD8BRKYEX Dispatch-Task: harper-3284-resource-path-translated-permissions
…eeping the public contract The dispatcher again passes allow* the body as given (a promise for REST), as their public signature declares, so an override that calls body.then() keeps working. Table's own allowUpdate and allowCreate resolve a promised body before checking its keys. Resource.ts is back to main. Also: on a sealed table, only declared attributes count toward the implicit primary-key grant, and that memo is reset when the schema changes. Refs #3284 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ED1ufN1nEZ76qGD8BRKYEX Dispatch-Task: harper-3284-resource-path-translated-permissions
…ep a body's parse error - After resolving a promised body, Table's allowUpdate/allowCreate continue with Table's own method, so a subclass override that calls super is not run a second time. - A body that fails to resolve passes the hook and fails the write with its own error, rather than being reported as an authorization failure. Refs #3284 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ED1ufN1nEZ76qGD8BRKYEX Dispatch-Task: harper-3284-resource-path-translated-permissions
A thenable is not a record root, so a promise handed straight to put/update by app code takes the same path it does on main (the write fails) instead of becoming a patch that removes every writable attribute. Refs #3284 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ED1ufN1nEZ76qGD8BRKYEX Dispatch-Task: harper-3284-resource-path-translated-permissions
Refs #3284 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ED1ufN1nEZ76qGD8BRKYEX Dispatch-Task: harper-3284-resource-path-translated-permissions
…/DESIGN.md resources/DESIGN.md is at its line budget on main, so the note moves to the directory that owns permissions, indexed from the root. Refs #3284 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ED1ufN1nEZ76qGD8BRKYEX Dispatch-Task: pr-maint-1739871cdd61c444abd7c428c452d4e3
They return a promise when the body is a promise, so the declarations say so, and the callers that await the result are typed to match. Refs #3284 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ED1ufN1nEZ76qGD8BRKYEX Dispatch-Task: pr-maint-1739871cdd61c444abd7c428c452d4e3
Dispatch-Task: pr-maint-1739871cdd61c444abd7c428c452d4e3 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
…tion helpers Preserve translated key semantics and restricted replacement patches while reusing the current body-resolution helpers. Cover readonly-field races and conditional staging, and update tests for resolved request bodies. Dispatch-Task: pr-maint-1739871cdd61c444abd7c428c452d4e3 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Keep invalid roots from falling back to full replacements while an attribute permission map is installed. Use the existing source put fallback when an inherited patch has no implementation, and propagate direct body rejections. Cover source shapes and invalid inputs with real-module tests; isolate the new fixtures and document the remaining source and direct-caller limits. Dispatch-Task: pr-maint-1739871cdd61c444abd7c428c452d4e3 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Cover body-only ids and legacy full updates in both instance modes, and check then-field persistence through the operations API. Qualify the design index and record the existing trusted-caller input boundary explicitly. Dispatch-Task: pr-maint-1739871cdd61c444abd7c428c452d4e3 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
kriszyp
force-pushed
the
fix/resource-path-translated-permissions
branch
from
November 4, 2026 07:34
75e734c to
702d08c
Compare
Assert own undefined keys in both instance modes and their MessagePack representation, retaining JSON and protected-field checks. Document the accepted PATCH representation without changing runtime deletion semantics. Dispatch-Task: pr-maint-1739871cdd61c444abd7c428c452d4e3 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Use the shared package binding so the permissions suite stays in the type-strip lane and uses the same codec entry as the runtime. Dispatch-Task: pr-maint-1739871cdd61c444abd7c428c452d4e3 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
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.
⊙ Problem
For attribute-restricted roles, the resource path omits an unlisted primary key and a full PUT or retained MQTT publish erases stored attributes the role cannot update. This fixes Resource path authorizes against untranslated role permissions: no primary-key auto-grant, and a full put by an attribute-restricted role erases unlisted attributes.
🤔 Retained replacement policy and limits
Preserving protected attributes is the agreed policy; rejecting every restricted replacement would break retained MQTT use. Array bodies reaching a single Table authorization check are refused. Existing PATCH limits remain: operation-shaped values are operations, and put-only sources with cold or stale caches can lose protected values. Delete/retry can create partial rows.
💡 Solution
Super users, unrestricted replacements and the operations API retain existing rules.
⚖️ Alternatives
Framing-Verdict: chosen-approach-sound
🤔 Direct instance PATCH/create inputs
Custom thenables can be checked after resolution but written from their original fields. This reproduces on main. Direct callers must supply records; protocol dispatch resolves bodies first. Separate hardening is needed.
🔧 Changes
resources/Table.ts: permission lookup adds the cached implicit grant; schema refresh invalidates it. allowUpdate keeps the checked map on the receiver. Legacy full update and put stage restricted patches; staging retains main’s pending-write/error handling. Source write-through selects implemented patch or materialized put for restricted writes. The replacement helper rejects invalid roots before staging. Validation requires a complete record when creating a missing row.security/permissionsTranslator.ts: implicitPrimaryKeyPermission uses the existing grant/timestamp primitives and last-entry rule. Operations-API translation is unchanged.🤔 Rechecks and documentation ownership
A second denied or pending
allowUpdateclears the instance restriction. This retained lifetime policy requires trusted overrides to honor failed or unfinished rechecks. The cross-layer invariant stays in security’s design note, linked from the resource map, to preserve both directories’ existing byte budgets. Compression retains the existing enforcement rationale; restoring it requires different pruning.🤔 Navigation and event deadlines
The index links detailed exceptions rather than repeating them. The subscription test waits for its completion event with a cancellable deadline; polling retains the same timeout. Legacy PUT signatures and their existing shift comment stay unchanged. One older non-record test accepts any rejection; newer direct-root cases assert specific statuses.
🤔 Accepted writable-key representation
Omitted writable fields keep own enumerable keys valued
undefined, matching PATCH; JSON omits them while JavaScript and MessagePack retain them. This compatibility difference from unrestricted PUT is accepted for this maintenance rebase. Own-key assertions cover both engines and instance modes. Physical deletion needs a separate change coordinated across commit, transaction logs, replay and replication.🤔 Serializer evidence scope
Primary-store checks establish the observed table shape. The shared-codec round-trip establishes MessagePack serialization of that shape, not table-encoder persistence. Restart, replay and replication remain explicit coverage gaps.
🤔 Existing denial return contract
Direct
allowUpdatecan returnundefinedrather than literalfalsewhen update permission is absent. Main has the same behavior; dispatch rejects falsy verdicts. An explicit-boolean cleanup remains separate from this maintenance work.✅ Verification
69435ff93ea7.Full gate results and base comparisons
Before the accepted-shape test/doc change, eight complete files passed with 339 RocksDB (1 pending) and 278 LMDB (20 pending); protocol integration passed 10/10. Production modules are unchanged.
Base:
df882a311be6. The final base refresh preserves all 19 branch patches exactly. Full gate results below tested7bd0cb707a50on basee58d105ee3f6; current focused checks appear above.Mocha verified the three main CI groups partition all 485 files exactly. The monolithic main command hit its 30-minute deadline and is not reported as passing. The two installation-guard failures reproduce identically on exact main production code. Full-scope component retry/probe failures also occur on the base, with variable watcher/recovery cases; focused comparisons pass. The full-scope origin-floor log-key equality failure reproduces at the same assertion on exact main; 23 new permission cases fail there and pass on the branch. These unrelated tests were not changed. The four real-Ollama integration failures reproduce in isolation; direct requests return 404 because the generative model is missing. No model or service was changed.
Negative controls: the tested base’s production modules make 5 protocol cases fail while all 5 controls pass. Removing only the recovered staging fix makes the new precondition test fail on unhandled 412 rejections. Every temporary base build is restored byte-for-byte afterward.
The related-PR scan is partial: open candidates were checked, but merged history beyond its first 50 entries was not scanned. Candidate patches were compared separately for permission, source and publish interactions.
The stacked Apply attribute read permissions to subscription and MQTT deliveries remains untouched and needs its separate rebase after this parent settles.
Docs: this maintenance rebase adds no API or configuration.
🤖 Generated by OpenAI Codex; posted via @kriszyp.
Release target: v5.4 — existing PR milestone; task base remains main
Related PRs: #3485 overlaps (Compartment mode: enforce allowedDirectory on plain file imports and support named imports from dynamic CommonJS packages), #3126 overlaps (Decode multipart forms for REST resources and stream uploaded Blobs), #3316 overlaps (Apply related-table role permissions to @relationship reads), #3229 overlaps (HTTP preconditions with optional locks), #3307 overlaps (Apply attribute read permissions to subscription and MQTT deliveries), 4 others independent
Complexity: complicated
Deferred findings
a296b96988cfe551major correctness —resources/Table.ts— Restricted full PUT retains omitted writable keys as own undefined properties — NOT FILEDc7f3f26b28bcb0a8major security —resources/Table.ts— false-mode PUT/PATCH with no body skips allowUpdate and writes RequestTarget fields as the record — NOT FILED1c06d86d6598a09dminor data-integrity —resources/Table.ts— full-vs-patch validation decided once; retry after delete or LMDB same-transaction create — NOT FILED3c7dab65a3e9c048minor security —resources/Table.ts— REST PUT creating a record is authorized by update permission, not insert — NOT FILEDcc9ffdaded7d17efminor correctness —resources/Table.ts— restricted PUT applies {op}-shaped values as CRDT operations — NOT FILED075082de79d55b0eminor security —resources/Table.ts— Denied or pending authorization clears the prior instance restriction — NOT FILEDf2433a9c31837cdbminor correctness —resources/Table.ts— Explicit id-less PUT targets lack a body-primary-key fallback — NOT FILEDdf355c4d6f7b31e8minor data-integrity —resources/Table.ts— Cold or stale cache loses protected values through source PUT fallback — NOT FILEDd6cab136a64b609dnit security —resources/Table.ts— restricted array PUT refused outright; numeric-attribute-name bypass — NOT FILEDde2e7130035a0facnit correctness —resources/Resource.ts— function-valued then field on a record body treated as thenable — NOT FILED🤔 defer this PR's major correctness finding
a296b96988cfe551(Restricted full PUT retains omitted writable keys as own undefined properties) to a separate issue instead of fixing it here?Origin — the dispatch brief this PR was written from
Fix harper#3284: the resource path (REST, GraphQL, MQTT) authorizes against the untranslated role permission, so the documented primary-key auto-grant is missing and a full put by an attribute-restricted role erases attributes the role cannot access. Apply translated permissions (permissionsTranslator.getRolePermissions) on the resource path via getTablePermissions or userView. Full-put policy: PRESERVE every attribute the role cannot write (keeps MQTT retained publish usable for restricted roles); if you find a reason refusal is required instead, ask before implementing.
Acceptance
Integration tests on REST and MQTT for a role with attribute_permissions [title, owner]: REST/GraphQL reads include
id; MQTT publish carryingidis accepted; full PUT and retained MQTT publish withoutsecretleave the storedsecretunchanged; a payload that sets an attribute the role cannot write is still refused; super user and ops API unchanged. Shown failing on origin/main.Dispatch: task
harper-3284-resource-path-translated-permissions· queued by finding-triage/wave-36 · ran by claude/opus/high · worker kzyp-xps-1Late Grok findings
1 surviving (highest minor), 7 previously adjudicated — Grok reviewed 7bd0cb7
resources/Table.ts:12041— rejected bodies receive an allowed authorization verdict (previously adjudicated)resources/Table.ts:4497— restricted PUT selects an unimplemented source PATCH (previously adjudicated)resources/Table.ts:5702— direct thenables fall through to full replacementsecurity/DESIGN.md:308— the Table permission invariant remains outside its owning directory (previously adjudicated)unitTests/resources/permissions.test.js:723— fixture tables remain in the configured test database (previously adjudicated)security/DESIGN.md:298— unrelated documentation compression broadens review scope (previously adjudicated)integrationTests/security/resource-attribute-permissions.test.ts:243— the then-field test bypasses restricted replacement (previously adjudicated)unitTests/resources/permissions.test.js:1174— publish delivery retains a five-second deadline (previously adjudicated)Not standing: none. Grok ran 21m (22m from launch to report); the fold adjudication (
cursor-grok@7bd0cb707a50-0437d1df, codex) took 3m.Late Grok findings
none surviving, 7 previously adjudicated — Grok reviewed 7ee00fc
security/DESIGN.md:308— the Table permission note remains outside its owning directory (previously adjudicated)security/DESIGN.md:298— unrelated security prose remains rewritten (previously adjudicated)integrationTests/security/resource-attribute-permissions.test.ts:244— the HTTP fixture proves acceptance without checking persistence (previously adjudicated)unitTests/resources/permissions.test.js:1413— the five-second delivery deadline remains disputed (previously adjudicated)security/DESIGN.md:312— invalid-root wording leaves the full-replacement boundary implicit (previously adjudicated)DESIGN.md:154— the index omits the note's protected-value exceptions (previously adjudicated)resources/Table.ts:5681— the dual PUT signatures could use additional explanation (previously adjudicated)Not standing: 1 accepted-known. Grok ran 23m (23m from launch to report); the fold adjudication (
cursor-grok@7ee00fc0e22f-0676edf6, codex) took 2m.Late Grok findings
1 surviving (highest major), 1 previously adjudicated — Grok reviewed 702d08c
resources/Table.ts:12157— restricted full PUT retains omitted writable keys as own propertiesunitTests/resources/permissions.test.js:1223— non-record-root assertions accept unrelated exceptions (previously adjudicated)Not standing: none. Grok ran 15m (16m from launch to report); the fold adjudication (
cursor-grok@702d08cbc15f-c0fe6a1e, codex) took 2m.Review-Coverage: authored=codex; ran=muse,gemini,cursor-composer,cursor-muse,cursor-grok,claude; late=cursor-grok:adjudicated; adjudicated=domain; declined=cursor-kimi; rounds=25; full=13 @ 69435ff
Review-Attention: deep ~45m (critical: Table.ts; sensitive: permissionsTranslator.ts; decisions: do-less-alternative, replacement-shape, restriction-lifetime, deferred-a296b969) @ 69435ff