feat(operations): add a put operation, and fix the target-database authorization mismatch - #2347
feat(operations): add a put operation, and fix the target-database authorization mismatch#2347dawsontoth wants to merge 11 commits into
put operation, and fix the target-database authorization mismatch#2347Conversation
`update` and `upsert` both merge: for a record that already exists they land on `Table.patch` (ResourceBridge.upsertRecords), so an attribute left out of the payload keeps its stored value and `null` stores a null. There was no way to REMOVE an attribute through the operations API — a full replace existed only over REST (`PUT /Table/id` -> `resource.put`), which a client talking to the operations API can't reach. Clients worked around it by deleting the record and inserting it again; that leaves the record absent between two writes, resets `__createdtime__`, and shows subscribers a delete followed by an insert. `full_record: true` selects `Table.put` instead. It is orthogonal to each operation's create rule, so one flag covers both useful shapes: update + full_record -> full replace, still skips a record that isn't there upsert + full_record -> full replace, still creates one (= REST PUT) It has no effect on `insert`, which never writes over an existing record. The merge default is untouched: that is the v4-compatible behaviour existing clients depend on, and 5bd3639 / b302a16 made it deliberate. `.strict()` on the validator key, not the whole object: `validateBySchema` allows unknown keys and discards Joi's coerced value, so without it a string `"false"` would validate and reach the bridge still a string, letting truthiness decide a destructive question. A role with `attribute_permissions` on the table is refused. Attribute permissions are checked against the attributes a request SUPPLIES, which is enough for a merge but not for a replace, where an omitted attribute is removed — such a role could otherwise erase an attribute it has no `update` permission for by leaving it out. REST closes the same gap in `Table.allowUpdate` by restoring those attributes from the stored record, which needs the stored record; authorization runs before anything is read, so it denies instead. Covered by integrationTests/database/full-record-write.test.ts: the removal, the merge default, an explicit null staying a stored null, `__createdtime__` surviving, both create rules, parity with REST PUT, a relationship foreign key still resolving, the attribute-permission denial, and the non-boolean rejection. Refs: HarperFast/studio#1643 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`full_record` can drop an attribute, but only by replacing the whole record — so
a caller who wants to remove one field has to resend every other field, and any
field it doesn't resend is silently dropped too. That makes it the wrong tool for
"delete this one property": it forces a read-modify-write on the client, which
races every other writer.
`__unset__` is the narrower one. It names attributes to remove and merges
everything else as usual:
{ "operation": "update", "database": "dev", "table": "dog",
"records": [{ "id": 1, "__unset__": ["age"] }] }
`age` is removed; every attribute the request didn't mention keeps its value.
An array of names rather than a map with ignored values: the values would carry
no meaning, and a shape that accepts anything invites callers to read
significance into it.
Resolved server-side in `upsertRecords`, against the record the write transaction
has already loaded, and written as a full replace. That is the point of doing it
here rather than in the client: the merge and the write are one transaction.
Reserved like `__createdtime__`/`__updatedtime__` (`terms.UNSET_ATTRIBUTES`), and
never stored. Both copies of the attribute-collection loop skip it —
`insertUpdateValidate.js` and its documented async twin in `insert.ts` — because
`upsertRecords` feeds that list to `Table.addAttributes`, so on an open
(non-schemaDefined) table an unrecognised `__unset__` would be registered as a
real table attribute.
Refused: the primary key (in the bridge, which is the only layer that knows which
attribute that is — `_writeUpdate` re-asserts it on a full update, so unsetting it
would otherwise be silently ignored), the system timestamps, and any malformed
value.
Attribute permissions are checked precisely, unlike the blanket `full_record`
denial: `getRecordAttributes` contributes the names `__unset__` REMOVES instead of
the key itself, so removing an attribute needs the same `update` permission that
writing it would. A role may unset what it can write and nothing else.
Covered by 11 further probes in
integrationTests/database/full-record-write.test.ts, including the open-table
attribute-registration guard and the permission check.
One behaviour found while testing and pinned rather than changed: on a legacy open
table the operations-API attribute projection reports every REGISTERED attribute,
filling in `null` for one the record does not have — so a removed attribute still
reads back as `key: null` through `get_attributes`, identically to a record that
never had it. `SELECT *` reflects the stored record. This is a read-path artifact,
not storage, and it applies equally to `full_record`; clients removing attributes
from open tables need to know it.
Refs: HarperFast/studio#1643
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces support for removing attributes over the operations API via the full_record: true flag (for full replaces) and the __unset__ directive (for merging while removing specific attributes). It also unifies target database resolution to resolve authorization bypass vulnerabilities and adds validation and permission checks for these new features. Feedback on the changes suggests making the takeUnsetAttributes helper function in ResourceBridge.ts self-safeguarding by adding defensive checks to verify that the unset parameter is a valid array before processing it.
…ey apply to
`verifyPerms` runs for every operation, and both new directives pass validation on
requests where they mean nothing — `full_record` is an accepted key on the shared
insert/update/upsert validator, and unknown keys pass validation everywhere. So
neither check may test the directive alone:
- The `full_record` denial fired for ANY operation whose body carried the flag.
An attribute-scoped role doing an `insert`, a `search_by_*`, or anything else
with a stray `full_record: true` got a spurious 403 — plausible for a generic
client wrapper that sets the flag on every write. Those operations never reach
`Table.patch`, so there is nothing there for the check to protect. Now gated on
`update`/`upsert`, which is what the flag actually changes, and what the PR
already claimed ("no effect on insert").
- `getRecordAttributes` contributed the names `__unset__` removes for every
operation. On an `insert` the directive removes nothing (`upsertRecords` strips
it, and an insert never writes over an existing record), so demanding `insert`
permission on attributes the request isn't touching denied that too.
Caught in review by claude[bot] on #2346 for the first; the second is the same
defect one file over, found by looking for it.
Both directions covered: the existing probes still assert the denials fire on
`update`, and three new ones assert an `insert` carrying either directive and a
search carrying a stray flag are NOT denied for an attribute-scoped role.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on and handlers
`verifyPerms` resolved the database an operation targets as
`requestJson.schema ?? requestJson.database`. The handlers resolve it in
`commonUtils.transformReq`, which runs AFTER authorization. The two disagreed in
three ways, each of which let a request be authorized against one target and
executed against another:
1. Neither key present. `operationSchema` was undefined, so `schemaTableMap`
stayed empty; `hasPermissions` iterates that map, so it authorized by vacuous
truth, and `getAttributePermissions` returned an empty map so attribute
permissions went unchecked too. `transformReq` meanwhile defaults to the
default database, and the handler wrote there. Any non-empty role therefore had
unconditional read/write/delete on `data`: verified for insert/update/upsert,
delete, csv_data_load, create_attribute, and all three search_* operations. A
role scoped exclusively to another database read, updated and deleted records in
`data`.
2. `database: 0` (or any falsy-but-present value). `??` kept the `0`, which is not
a database, so the map stayed empty as in (1) — while `transformReq`, which
tests falsy, defaulted to `data` and wrote there. `Joi.number()` is an accepted
type for the field, so `0` arrives validated.
3. Opposite precedence. `verifyPerms` preferred `schema`; `transformReq` prefers
`database`. So `{schema:'data', database:'elsewhere'}` was authorized against
`data` and written to `elsewhere`. This one reaches any named database, not just
the default.
Fix: one resolver, `commonUtils.resolveTargetDatabase`, which `transformReq` now
delegates to, so authorization and the handlers cannot drift again. Falsy rather
than nullish, and `database` over `schema`, matching what the handlers have always
done.
Plus a fail-closed backstop in `verifyPerms`: a named table with an empty
`schemaTableMap` is denied rather than authorized. That is the shape of this whole
bug class, and of the SQL path's GHSA-5c29-q62v-jrwf, whose fix carries the same
guard. With the shared resolver it is unreachable by construction, so no test
covers it; it is there so a future change to target resolution fails safe.
Affected 4.2.0 through 5.2.6. `transformReq`'s default arrived in 4.2.0
(5d5e58f); 4.1 declared `schema` required, so earlier versions are not
exploitable. The vacuous-map behaviour itself dates to ~4.0.8 (76ce6a2) but was
inert until that default existed. 5da23c3 only carried the expression into the
5.x file.
Not affected: REST, MQTT/WebSocket and GraphQL bind the database on the resource
class at path-match time and never take it from the request body. The SQL path is
the same bug class but a separate, already-fixed code path.
Regression coverage in integrationTests/database/full-record-write.test.ts asserts
all three cases, and asserts them in both directions — a denial alone does not
prove resolution, since the backstop also denies an unresolved target, so the role
that DOES hold rights on the default database must still be allowed and its write
must land there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
50a9974 to
7923bd8
Compare
full_record, __unset__), and fix the target-database authorization mismatch
Raised by gemini-code-assist on #2347: `unset` was cast to `string[]` and queried with `.includes()` without checking that it is one. Not reachable today — `validation/insertValidator.ts` rejects a malformed `__unset__`, and every current route into `upsertRecords` goes through `dataLayer/insert.ts` create/update/upsertRecords, including the replication catchup path. But the bridge's own `insertUpdateValidate` does not check this key, so the helper's safety lived entirely in a different layer, across a module boundary an internal caller could reach directly. If one did: a non-iterable reaching the `for…of` in `upsertRecords` throws and takes the write down (the shape of #2194), and a bare string iterates per character, deleting single-letter attributes. Ignored with a warning rather than thrown, because this is the write and replication apply path where throwing aborts the commit and can wedge a subscription. Skipping the directive degrades to a plain merge, which removes nothing — the safe direction, and the same choice `resources/tracked.ts` makes for an unrecognized CRDT operation. Departs from the suggested patch in one way: the key is deleted BEFORE the shape is judged, and on every exit. Returning early on a nullish value, as suggested, leaves `__unset__` on a record that is about to be written and stores the directive as data. Membership is tested with `in` for the same reason, so an explicitly-undefined key still comes off. Behaviour through the operations API is unchanged, so the existing probes cover it (a malformed `__unset__` is still rejected at validation, and the directive is still never stored). The internal-caller path this hardens cannot be reached from an integration test, so nothing here exercises the new branch directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed; no blockers found. |
…n't change
The refactor to `resolveTargetDatabase` made `transformReq` assign `req.schema`
unconditionally. That is not equivalent to what it replaced: the old code left
`req.schema` alone when it was already the resolved value (schema set, no
database), and re-assigning an identical value still throws a TypeError on a
frozen or sealed object under strict mode — which every module here is. Verified:
old / guarded ok
unconditional (as pushed) THROWS: TypeError
on a frozen `{ schema: 'data' }`.
`transformReq` is called from ~20 sites across the operations, job, and
replication paths, so an unconditional write widened the blast radius of the
authorization fix for no benefit. Now guarded, which is byte-identical to the old
behaviour on all 11 input shapes (both keys absent, either alone, both, falsy
`0`/`''`/`null` in each position) while keeping the shared resolver.
Nothing is known to pass a frozen request, and this is not confirmed as the cause
of the shard-4 `Worker (index 0) exited with code 0 before reporting ready`
failure on this PR — that suite (`record-caching-cross-worker.test.ts`) passes
locally on this branch, 3/3, and the CI failure was an instance that never booted
rather than an assertion. But a write that cannot change the outcome is not worth
having a failure mode at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**Numeric `table: 0` bypassed table authorization and the new backstop.**
`hdbTable` is `Joi.alternatives(Joi.string(), Joi.number())`
(validation/common_validators.ts), so a table named `0` validates — and both
`if (operationSchema && table)` and the backstop's own `if (table && …)` tested it
for truthiness, so it never entered `schemaTableMap` and `hasPermissions` iterated
nothing. That is the same vacuous-truth shape this branch fixes for the target
database, repeated one guard below it, and the same falsy-vs-nullish distinction.
Both now test `!= undefined`, as does `getAttributePermissions`, which skipped
attribute permissions for the same input.
**The bulk-load path could unset an attribute with no permission check.**
`bulkLoad.validateChunk` feeds `insert.validation()`'s attribute list to
`verifyBulkLoadAttributePerms` (dataLayer/bulkLoad.ts:434-444). This branch made
that list skip `__unset__` without contributing the names it removes — the
compensating step `getRecordAttributes` performs for the direct path — so
`import_from_s3` with `action: "update"` and `[{"id":1,"__unset__":["salary"]}]`
deleted `salary` unchecked while the direct `update` returned 403. The async
collector now contributes the removed names; the sync twin in
`insertUpdateValidate.js` deliberately still does not, because that list creates
table attributes and registering one in order to delete it would be backwards.
The two lists are not interchangeable and both comments now say so.
**`__unset__` on an `insert` deleted attributes the same request supplied.**
`createRecords` sets `requires_no_existing`, so `toWrite === record` and the loop
stripped the named keys from the submitted record — a silent partial write
returning 200, while the comments, the authorization scoping and the PR text all
described the directive as inert there. Now refused: an insert has no existing
record to remove from, so the directive can only be a misunderstanding, and a
directive that quietly does nothing is worse than one that says no — the same rule
the primary-key refusal follows.
Also from the review: the default write path no longer allocates a throwaway array
per record (a shared frozen empty array), since the directive is absent from
essentially every record.
Testing: two new probes cover the numeric table and the insert refusal, and both
are mutation-checked — reverting the guards to truthy fails the numeric-table
probe, and applying the directive on insert instead of refusing fails two. 30/30
in the suite; 125 tests across security, terminology, auto-fields and
patch-disjoint-field-merge still pass.
The bulk-load fix has NO test: the JSON bulk path needs `import_from_s3` or
`csv_url_load`, neither reachable from a local integration run, and CSV cannot
express the array the directive takes. It is a read-verified change, and
`validation()`'s attribute list is consumed only by that permission check
(verified — bulkLoad.ts:442 is its sole consumer), so the change is scoped to it.
Two review findings are NOT addressed here: `__unset__` still degrades a merge to
a last-writer-wins put under contention, and the reserved key still has no
migration for tables carrying a pre-existing `__unset__` attribute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Blocker 1 (the merge → last-writer-wins put) is filed as #2350 (P2, Bug) rather than fixed here. The PR body carries the corrected claim, so the #2350 lays out the three options: a removal operation in the 🤖 Filed by Claude Code |
…move from
Delta review of the previous three commits found the insert refusal guarding the
wrong condition. It keyed on `requires_no_existing` — the insert flag — so the
same silent partial write was still reachable one branch over:
`insertUpdateValidate` requires a primary key only for `update`, so
`{operation:'upsert', records:[{name:'Penny', color:'black', __unset__:['color']}]}`
took the `id == undefined → Table.create` path and stored an auto-keyed record with
`color` stripped, returning 200. An `upsert` naming a primary key that isn't stored
had the same shape.
Now keyed on `!existingRecord`, which is the actual invariant: no stored record
means nothing to remove from, so the directive can only be a misunderstanding. That
covers insert, upsert-without-a-key, and upsert-of-an-absent-key together. `update`
never reaches it — a missing record is already skipped upstream.
This is the fix-one-of-N-surfaces mistake: the condition was written from the
operation that exhibited the bug rather than from the property being protected.
Also from the review:
- Managed timestamps are refused off `Table.createdTimeProperty`/
`updatedTimeProperty` instead of the validator's static legacy list, so a
schema-declared `createdAt: Float @createdTime` is caught too. Those properties
resolve `assignCreatedTime` OR the legacy `__createdtime__` spelling
(resources/Table.ts:504-505), so one check covers both and there is no second
list to drift. The validator's list is removed rather than kept alongside it.
- The malformed-directive warning is latched and names the table, following
`warnedNullSourcePut` in resources/Table.ts — it sits on the write and
replication apply path, where one line per record buries the log under a single
bad batch.
- Comment density trimmed. Both reviews flagged it; the removed lines narrated
review history or addressed the reviewer rather than the next reader. Kept: why
the malformed case warns instead of throwing, why the refusals live in this layer,
and the falsy-vs-nullish reasoning.
Tests: 33 in the suite, all passing, and the new guards are mutation-checked —
reverting to the insert flag fails the two new upsert probes, and removing the
managed-timestamp loop fails both timestamp probes. 118 more across security,
terminology and auto-fields still pass.
One test correction worth noting: the first version of the upsert probe asserted
"no record with this name and null breed/color", which matched eight records other
probes had legitimately stripped attributes from. It now uses a name unique to the
probe.
Still open from the review, unfixed: `__unset__` remains a whole-record put under
contention (#2350), the directive refusals happen after dynamic attribute
expansion so a rejected write can still have registered new attributes on an open
table, and the bulk-load authorization fix has no test because the JSON bulk path
needs S3/URL input. The CSV entry point takes a different attribute list
(bulkLoad.ts:82, papaparse header fields) which this change does not touch, and
CSV cannot express the array anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ot per process Raised by claude[bot] on #2347, and correct on both counts. The latch was a single module-level boolean, so the first table to log the warning suppressed it for every other table on the worker until restart — and the comment cited `warnedNullSourcePut` as precedent while not matching it. That flag is declared inside `makeTable` (resources/Table.ts:486) and its own comment says "one warn per table per worker", so the precedent was per table all along; only this copy was process-wide. Now a `WeakSet` keyed on the table, so a bad batch on one table cannot silence the warning elsewhere, and a dropped table is not kept alive by the latch. No test: the malformed branch is unreachable through the operations API — the Joi validator rejects a malformed `__unset__` before the bridge sees it — so this guards an internal caller that does not exist yet, and asserting on log output across two tables would not be a meaningful probe of it. Suite still 33/33. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kriszyp
left a comment
There was a problem hiding this comment.
Ok, I think this makes sense. However, would it make sense to add a put operation (upsert + full_record) instead? If we provided this, do we believe that update+full_record functionality and unset are actually meaningful gaps in functionality? Or do you have immediate use for these in the studio?
🤖 Reviewed with Codex
…e permissions Raised by kriszyp on #2347. `getRecordAttributes` returned an empty set for any request carrying `action`, on the grounds that bulk loads check attribute permissions per chunk in `bulkLoad.validateChunk`. But `action` is an unknown-but-accepted key on the insert/update/upsert validator, so adding `action: "update"` to a DIRECT request skipped every attribute check — the pre-existing half, plus the `__unset__` removal authorization this branch added. The opt-out is now keyed on the operation being a known bulk load (`BULK_OPS`, whose values are the same handler names `requiredPermissions` is keyed by, so it matches exactly the identity `verifyPerms` already receives), never on a field the caller controls. Real bulk loads still opt out; a direct request cannot. Regression test covers all three shapes: the `__unset__` removal denied, the same request with `action` attached still denied, and a plain write of the same attribute with `action` attached denied — that last one is the pre-existing half. Mutation-checked: restoring the `action` test fails it. Also from the same review: `takeUnsetAttributes` uses an own-property check rather than `in`. An inherited `__unset__` — a record built on a prototype carrying one, or after prototype pollution — entered the path with no directive of its own, and `delete` cannot remove an inherited value, so the caller would go on to delete the attributes it named. No test: `JSON.parse` yields plain objects, so this is unreachable through the operations API and guards the same internal boundary the malformed-value handling does. Two of that review's findings were already fixed in 14f9dca and need no change here: managed timestamps are validated off `Table.createdTimeProperty`/ `updatedTimeProperty` with the schema aliases tested, and the no-existing-record refusal is keyed on `!existingRecord` with upsert-create coverage. Tests: 34 in the suite; 118 across security/terminology/auto-fields; 581 in northwind + job-queue, which exercise the CSV and bulk-load paths this change alters. All pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…operation Scope reduction, per kriszyp's review on #2347: one operation instead of a flag plus a per-record directive. { "operation": "put", "database": "dev", "table": "dog", "records": [{ "id": 1, "dog_name": "Penny" }] } The stored record becomes exactly that, so an attribute the request omits is removed. Creates a missing record and replaces an existing one — the same `Table.put` REST's `PUT /Table/id` performs, so the same audit type, replication shape, and retained `__createdtime__`. `update`/`upsert` keep merging, which is the v4-compatible behaviour existing clients depend on and the reason this is a separate operation rather than semantics changed in place. This covers what studio#1643 actually needs: its row editor holds the whole record already. Removed with the flag and the directive: - `full_record` as a client-facing field on update/upsert - `__unset__`, its reserved key, its validation, the attribute-collector divergence in both `insertUpdateValidate.js` and `insert.ts`, and the `getRecordAttributes` name expansion - `takeUnsetAttributes` and its latch Three problems retire with them, all raised in review and none now reachable: `__unset__` degrading a merge to a whole-record put under contention (kept as a feature request, see below), the reserved key reinterpreting a pre-existing `__unset__` attribute with no migration, and the directive's interaction with dynamic attribute expansion. The internal marker is an ARGUMENT, not a field: `putRecords` calls `upsertRecords(obj, true)`. Reading it off the request — which the first draft of this commit did — let a client send `full_record: true` with an `update` and get a replace, which makes the operation name stop describing the write and bypasses the attribute-scoped denial, since that is keyed on the operation. A test pins it; restoring the request read fails that test and nothing else. Kept from the previous rounds, unchanged in substance: - The attribute-scoped denial, now keyed on `op === write.put.name`. A replace removes what the request omits, and `checkAttributePerms` only sees what it supplies. - The target-database resolution fix and its fail-closed backstop, including the numeric `table: 0` guard. - The bulk-load opt-out keyed on the operation rather than a caller-supplied `action`. The `__unset__` half of that fix goes away with the directive; the pre-existing half — a direct request skipping every attribute check by attaching `action` — stays fixed and stays tested. Wired as a first-class operation: `OPERATIONS_ENUM.PUT`, the dispatch map, the replication catchup switch, `requiredPermissions` with `[insert, update]` (what `upsert` and REST `PUT` both require), the grantable `data` operation group, and the `new_attributes`/`txn_time` result cleanup. Returns `put_hashes`; a put never skips. Tests: `integrationTests/database/put-operation.test.ts`, renamed from full-record-write — 18 probes, all passing. Every guard mutation-checked: making put ignore the marker fails 5, reading the marker off the request fails 1, dropping the attribute-scoped denial fails 1. 143 tests across the security, terminology, auto-fields and patch-merge suites pass, plus 575 in northwind, which exercises the CSV and bulk-load paths. `__unset__` is not abandoned — #2350 is being reworked from "this is broken" into "add it properly", with concurrency-safe removal at the patch layer rather than a read-then-replace in the bridge. Refs: HarperFast/studio#1643 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
full_record, __unset__), and fix the target-database authorization mismatchput operation, and fix the target-database authorization mismatch
|
Yes — done in 1ac8d30. On your second question, the honest answer is that neither was a gap:
So it's filed as #2350 for doing properly (now a Feature, P3), with the reverse-value, removal-versus-set resolution, and namespace problems written up. Dawson wants it eventually — it's a genuinely nicer tool than Three of your findings retire with the directive rather than being fixed: the stale snapshot, the reserved-key compatibility hazard (no key to reserve now), and the refusal-after-attribute-expansion ordering. The two that were about One thing your review caused that I'd flag: collapsing to a single operation exposed a bug in my own first draft of it. I initially had 🤖 Reply from Claude Code |
Two changes on one branch, reviewable in order: a
putoperation for the operations API, and an authorization fix found while building it.put: create-or-replace, replacing the earlierfull_recordflag and__unset__directiveput{ "operation": "put", "database": "dev", "table": "dog", "records": [{ "id": 1, "dog_name": "Penny" }] }The stored record becomes exactly that, so an attribute the request omits is removed. Creates a missing record, replaces an existing one.
Why it's needed. There is currently no way to remove an attribute from a record through the operations API.
updateandupsertboth land onTable.patchfor a record that already exists (ResourceBridge.upsertRecords), which merges: an omitted attribute keeps its stored value, andnullstores a null. A full replace existed only over REST (PUT /Table/id→resource.put), which a client speaking the operations API can't reach — and can't reach at all through a proxied operations endpoint. Reported downstream as HarperFast/studio#1643, where Studio's row editor silently dropped a property deletion.The workaround available today is
deletetheninsert, which leaves the record absent between two writes (concurrent reads, relationship resolutions from the far side, and replicas all observe the gap), resets__createdtime__, and shows subscribers a delete followed by an insert instead of one write.putisTable.put— the same write REST performs, so the same audit type, replication shape, and retained created time.update/upsertare deliberately untouched.5da23c3eforiginally usedTable.putand hand-copied missing properties to emulate a merge;5bd363946replaced that with a realTable.patch, andb302a16e7added theputfallback for a missing record. Merge semantics is load-bearing v4 compatibility, so this is a new operation rather than changed behaviour.Two details worth review
The internal marker is an argument, not a request field.
putRecordscallsupsertRecords(obj, true). The first draft readfull_recordoff the request, which let a client sendfull_record: truewith anupdateand get a replace — making the operation name stop describing the write, and bypassing the attribute-scoped denial below, which is keyed on the operation. A test pins it; restoring the request read fails that test and nothing else.A role with
attribute_permissionson the table may not useput. Attribute permissions are checked against the attributes a request supplies (checkAttributePerms), which is enough for a merge but not for a replace, where an omitted attribute is removed — so such a role could otherwise erase an attribute it has noupdatepermission for by leaving it out. REST closes the same gap inTable.allowUpdateby restoring those attributes from the stored record, which needs the stored record; authorization runs before anything is read, so it denies instead. Roles that scope no attribute are unaffected. Happy to swap this for a restore-based approach if you'd rather it mirror REST exactly.Scope reduction
This replaces an earlier design (a
full_recordflag onupdate/upsert, plus a per-record__unset__directive), per review. Three problems retire with it, none now reachable:__unset__degraded a merge to a whole-record last-writer-wins put under contention — reachable single-node via the optimistic-lock retry, and unrecoverable in replication because the audit carried only the resolved put, not the deletion intent.__unset__key reinterpreted a pre-existing user attribute of that name, with no migration.__unset__is not abandoned — #2350 is now the feature request for doing it properly, with removal preserved as intent at the patch layer rather than resolved in the bridge. It's a genuinely better tool thanputfor "drop one field, don't resend the rest"; it just needs a reverse that carries the prior value, a defined removal-versus-set resolution, and a namespace that can't collide.The authorization fix
Found while testing the attribute-permission denial; unrelated to the feature.
verifyPermsresolved the target database asrequestJson.schema ?? requestJson.database. The handlers resolve it incommonUtils.transformReq, which runs after authorization. The two disagreed three ways, each letting a request be authorized against one target and executed against another.Impact: any authenticated non-empty role had unconditional read/write/delete on the default
datadatabase, and could direct a write into any other named database. Not privilege widening — a role scoped exclusively to a second database, with zero permissions ondata, readdata.Dogin full (including an attribute it was explicitly denied), updated it, and deleted the record.1. Neither key present.
operationSchemawasundefined, soschemaTableMapstayed empty;hasPermissionsiterates that map, so it authorized by vacuous truth, and attribute permissions went unchecked too. Verified with a role holdingupdate: falseondata.Dog:Affected operations, all verified by running them:
insert/update/upsert(write),delete,csv_data_load(async write — jobs authorize only at submit, so it rides into the worker),create_attribute(schema mutation), and all threesearch_*(read).describe_*and everyrequires_suoperation fail closed.search_by_idwithget_attributes:['*']500s rather than leaking.2.
database: 0, or any falsy-but-present value.??kept the0, which is not a database, so the map stayed empty as in (1) — whiletransformReq, which tests falsy, defaulted todataand wrote there.Joi.number()is an accepted type, so0arrives validated.3. Opposite precedence.
verifyPermspreferredschema;transformReqprefersdatabase. So{"schema":"data","database":"elsewhere"}was authorized againstdataand written toelsewhere— the worst of the three, since it reaches any named database.Fix
One resolver,
commonUtils.resolveTargetDatabase, whichtransformReqdelegates to, so the two cannot drift again. Falsy rather than nullish,databaseoverschema— matching what the handlers have always done, so no handler behaviour changes.transformReqwrites only when the value would change: an unconditional assignment throws on a frozen object under strict mode.Plus a fail-closed backstop in
verifyPerms: a named table with an emptyschemaTableMapis denied rather than authorized. Both that guard and the map population test!= undefined, becausehdbTableaccepts a number and a truthy test dropped a table named0— the same vacuous-truth shape one guard over.getAttributePermissionstoo.Also here, from review: the bulk-load attribute-permission opt-out is keyed on the operation being a known bulk load, never on a caller-supplied
action.actionis an unknown-but-accepted key on this validator, so inferring the opt-out from its presence let any direct request skip every attribute check by attaching it.Affected versions
4.2.0 through 5.2.6 (current).
transformReq's default arrived in 4.2.0 (5d5e58fc3); 4.1 declaredschemarequired. The vacuous-map behaviour dates to ~4.0.8 (76ce6a2e3) but was inert until that default existed.release_4.7.19has the identical expression atoperation_authorization.js:350.Not affected: REST, MQTT/WebSocket and GraphQL bind the database on the resource class at path-match time and never take it from the request body. The SQL path is the same bug class but a separate, already-fixed code path — and its fix is the stronger one, carrying the same per-reference backstop.
Regression risk
In-repo: nil. All 77 schema-less table-naming requests in
integrationTestsrun as super_user, which returns before the target is used; all 491 non-SU requests already carryschemaordatabase.External: real, and release-note-worthy. A deployment whose clients send schema-less operations under a restricted role will start getting 403s. That is the vulnerability being closed, but it will look like a breaking change to anyone unknowingly relying on it.
Two intentional behaviour changes beyond the denials:
structure_user: ['data']+create_tablewith no database goes deny → allow (correct, previously untested), andsearch_by_idwithget_attributes:['*']and no database goes 500 → a proper allow/deny.Testing
integrationTests/database/put-operation.test.ts— 18 probes, all passing on rocksdb.put: the removal,updateandupsertstill merging (the controls), an explicitnullstaying a stored null,__createdtime__surviving and__updatedtime__re-stamping, create-and-replace,put_hashesreporting, parity with RESTPUT(same stored attribute set through both doors), a relationship foreign key still resolving from the far side, a client-suppliedfull_recordnot turning an update into a replace, and the attribute-permission denial with its scope (the same role can still merge and insert).Authorization: all three resolution divergences, asserted in both directions — a denial alone doesn't prove resolution, because the backstop also denies an unresolved target, so the role that does hold rights on the default database must still be allowed and its write must land there. A numeric
table: 0. A caller-suppliedactionnot skipping the attribute check.Also pinned: on a legacy open table the operations-API attribute projection reports every registered attribute, filling in
nullfor one the record doesn't have — so an absent attribute reads back askey: null, identically to one that was removed. A read-path artifact, not storage (verified by inserting a record that never had it), predating this work, and worth a test because it makes a successful removal look like a failed one.Every guard mutation-checked — reverting it has to turn a test red:
putignores the full-record markerputdenial dropped??instead of||in the resolverschemabeforedatabaseaction-keyed bulk opt-out restoredAlso green:
integrationTests/security/*.test.ts,apiTests/terminology.test.mjs(which deliberately exercises schema-less operations and asserts thedata.default),database/{auto-fields,patch-disjoint-field-merge}— 143 with the put suite — plus 575 innorthwind, which exercises the CSV and bulk-load paths this touches.Cross-model review
Two passes via
prepush-review.mjs,reviewers=codex+harper-domain,independent=true. Gemini failed on quota both times and both Cursor legs were skipped (theirgit fetchhits a local SSH-agent failure), so coverage is codex + domain only. Both passes predate this scope reduction — they reviewed thefull_record+__unset__design, and their findings are what drove it. Their still-live findings are fixed; the rest retired with the directive.Not done
putreplicates as aput, the same as RESTPUTalready does, but there's no multi-node probe in this suite.HARPER_STORAGE_ENGINE=lmdb). The suite is written to run under it.resolveTargetDatabasehas no direct unit test.reference/operations-api/operations.mdin the documentation repo needs aputentry, plus a note about the open-table read projection. Happy to open that PR once this shape is agreed.putdenial for attribute-scoped roles is blunt — it refuses rather than restoring unwritable attributes from the stored record the way REST does.🤖 Generated with Claude Code