Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/26594-record-field-access-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/client': minor
---

feat(client): `data.fieldAccess(object, id)` reads the field projection of one record

Clause-②: yes (widening)
Metadata-protocol: added

`client.data.fieldAccess(object, id)` calls `GET /data/:object/:id/field-access` and resolves to `GetRecordFieldAccessResponse`: the fields the caller may read, and the fields the write gate admits on an update of that stored row. A field permission whose `when` names only some rows is unlocked on the rows it does not name. A row that is absent or unreadable rejects with the record read's 404.
10 changes: 10 additions & 0 deletions .changeset/26594-record-field-access-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/rest': minor
---

feat(rest): `GET /data/:object/:id/field-access` serves the field projection of one record

Clause-②: yes (widening)
Metadata-protocol: added

A record's edit form can ask which fields it may write on THAT record: the route answers `{ success: true, data: { object, recordId, readable, editable } }` (`GetRecordFieldAccessResponseSchema`), with `editable` judged on the stored row by the write gate through the security service's `getRecordFieldAccess`. The record is read first exactly as `GET /data/:object/:id` reads it, so an absent row or one the caller cannot read answers that route's 404 and the projection discloses nothing more. The route takes no query parameter (a located 400 names any). Without a security service that offers the method it answers 501, and `/auth/me/permissions` remains the row-less reader.
10 changes: 10 additions & 0 deletions .changeset/26594-record-field-access-security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/plugin-security': minor
---

feat(plugin-security): `getRecordFieldAccess` — a record's field projection from the write gate's own verdict

Clause-②: yes (widening)
Metadata-protocol: added

The `security` service answers `getRecordFieldAccess(object, recordId, context)`. It reads the row as the caller reads it (the reader the write gate's pre-image comes from) and asks the field-level write gate itself, `computeForbiddenFieldWrites`, for an update of that row naming every field, over the same permission sets, posture and delegator intersection the gate takes. A field permission whose `when` names only some rows therefore locks the field only on those rows, and the answer cannot disagree with a write of that row. A row that is absent or unreadable answers `null`. `getWritableFields` and every other row-less reader are unchanged and still apply each row-conditioned entry as its lock.
12 changes: 12 additions & 0 deletions .changeset/26594-record-field-access-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/spec': minor
---

feat(spec): a record-scoped field projection, judged on the stored row by the write gate

Clause-②: yes (widening)
Metadata-protocol: added

`GetRecordFieldAccessResponseSchema` (`@objectstack/spec/api`) declares the answer of `GET /api/v1/data/{object}/{id}/field-access`: `{ object, recordId, readable, editable }`, the field projection of ONE record. `ISecurityService` declares its source as the optional `getRecordFieldAccess(object, recordId, context)`: `editable` is the write gate's own verdict on an update of the stored row, so a field permission whose `when` names only some rows locks the field only on those rows, and `readable` is the row-blind read projection. `null` means the caller has no row there (absent or unreadable, deliberately the same answer); `undefined` means no answer.

The `FieldPermissionSchema.when` description now names that reader: the row-less readers (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) keep reporting a row-conditioned entry as its lock, and the record-scoped projection judges it on the row. Nothing an author writes changes meaning.
17 changes: 13 additions & 4 deletions content/docs/permissions/field-level-security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,19 @@ fields: {
address, a stored row the caller cannot read, a condition that does not
resolve for the request — the entry is applied as its **lock**: no edit
grant on that write, and a WARN line says why.
- **Readers that hold no row** (`/auth/me/permissions`, the writable-field
projections, `os explain`) report a row-conditioned entry as its lock, so
a client never advertises an edit the gate refuses. `os explain` names
each such entry as a row-conditioned lock in its `fls` layer.
- **Readers that hold no row** (`/auth/me/permissions`, the object-wide
writable-field projections, `os explain`) report a row-conditioned entry
as its lock, so a client never advertises an edit the gate refuses.
`os explain` names each such entry as a row-conditioned lock in its `fls`
layer.
- **The record-scoped projection holds the row.**
`GET /api/v1/data/{object}/{id}/field-access` (`client.data.fieldAccess`)
answers the fields the caller may read and the fields it may write on
**that** record, and its `editable` list is the write gate's own verdict on
an update of the stored row. So a lock that applies from a stage onward
disables the field only on the rows its condition names — this is the
answer a record's edit form reads. A row that is absent or that the caller
cannot read answers the record read's `404`.
- **Union without deny is unchanged.** A matched conditioned lock is still
out-voted by another set's unconditioned `editable: true`; an entry
without `when` votes on every row, as before (ADR-0066 ⑧, note of
Expand Down
2 changes: 1 addition & 1 deletion content/docs/protocol/objectql/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ fields:

> There is no separate `create` flag for fields. `editable` governs both create-time and update-time writes; read stripping is governed by `readable`.

> `when` narrows **where** the entry votes: on a matching row it votes as declared, on every other row it is absent (as if the set had not declared the field). The write gate judges the stored row before an update and each incoming row of an insert, in process; a row it cannot judge (a predicate update, a stored row the caller cannot read, a condition that does not resolve for the request) applies the entry as its lock. `readable: false` beside a `when` is refused at parse time, and `objectstack validate` judges the condition like an RLS predicate. Readers that hold no row (`/auth/me/permissions`, `os explain`) report the entry as its lock. See [Field-level security](/docs/permissions/field-level-security#row-conditioned-edit-locks-when).
> `when` narrows **where** the entry votes: on a matching row it votes as declared, on every other row it is absent (as if the set had not declared the field). The write gate judges the stored row before an update and each incoming row of an insert, in process; a row it cannot judge (a predicate update, a stored row the caller cannot read, a condition that does not resolve for the request) applies the entry as its lock. `readable: false` beside a `when` is refused at parse time, and `objectstack validate` judges the condition like an RLS predicate. Readers that hold no row (`/auth/me/permissions`, `os explain`) report the entry as its lock; the record-scoped projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) judges the stored row exactly as the write gate does. See [Field-level security](/docs/permissions/field-level-security#row-conditioned-edit-locks-when).

### Static `readonly` fields on the write path

Expand Down
Loading
Loading