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-form-field-access-data-objectstack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/data-objectstack': patch
---

feat(data-objectstack): `ObjectStackAdapter.getRecordFieldAccess` reads `GET /data/:object/:id/field-access`

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

The adapter implements `DataSource.getRecordFieldAccess` through the client's `data.fieldAccess`. A record the read answers 404 for, and a server without the route (501 `NOT_IMPLEMENTED`), resolve to `null`, so the caller keeps its row-blind answer. Any other refusal is thrown, as a read's is.
10 changes: 10 additions & 0 deletions .changeset/26594-record-form-field-access-permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/permissions': patch
---

feat(permissions): `RecordFieldAccessScope` answers one record's field locks from the server's record-scoped projection

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

Below `RecordFieldAccessScope`, `checkField(object, field, 'write')` for the scoped record answers from the server's projection of that record wherever `/auth/me/permissions` reports a field-level lock. That reader holds no row, so it reports a lock that applies only from a stage onward (a field permission `when`) on every row; the scope replaces that one input with the write gate's verdict for the stored row. The object-grant fallback, the owner-transfer rule, `read` and `create`, and every other object keep the enclosing provider's answer. Until the answer arrives, and when there is none, the row-blind lock stands. When its `revision` prop moves (the row was saved or re-read), it asks again and the lock stands until the new answer lands. The client never evaluates `when`. A nested scope for the same record asks once.
10 changes: 10 additions & 0 deletions .changeset/26594-record-form-field-access-plugin-form.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/plugin-form': patch
---

fix(plugin-form): the record form's edit dialog disables only the fields the server locks on that record

Clause-②: no
Metadata-protocol: none

An edit form for a stored record (`ModalForm`, the console's edit dialog; `ObjectForm` and every layout beneath it; `DrawerForm`) now asks the data source for that record's field projection and answers its field locks from it, through `RecordFieldAccessScope`. A field locked only from `approved` onward is live on a draft and locked on an approved row, in what the form draws and in what its save sends. The form asks again after a write to the record or a re-read of it, so a save that approves the row locks the field again. Create forms, `view` forms and data sources without `getRecordFieldAccess` behave as before. Row-level write scope is not part of the answer: on a row the caller may read but not write, the fields stay open and the server refuses the save, as before.
10 changes: 10 additions & 0 deletions .changeset/26594-record-form-field-access-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/types': patch
---

feat(types): `DataSource.getRecordFieldAccess` asks the server for one record's field projection

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

`DataSource` gains the optional `getRecordFieldAccess(resource, id)`, resolving to the new `RecordFieldAccess` type (`object`, `recordId`, `readable`, `editable`), or `null` when there is no answer. `editable` is the server's write gate judged on the stored row, so a field permission whose `when` names only some rows locks the field only on those rows. Adapters that do not speak to an ObjectStack server can leave it out.
6 changes: 5 additions & 1 deletion content/docs/permissions/field-level-security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ fields: {
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`.
cannot read answers the record read's `404`. The answer is field-level
only: the object grant and the record's row-level write scope (sharing,
row-level security, ownership) are not part of it, so on a row the caller
may read but not write it still lists fields as editable, and the update
is refused as a whole.
- **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
18 changes: 17 additions & 1 deletion packages/qa/dogfood/test/fixtures/record-field-access-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
// row-blind lock is shown to stay a lock on every row.
// - `rfa_memo` is `private`: a member reads only the rows they own. It is the
// row the caller cannot read, for the existence-leak leg.
// - `rfa_notice` is `public_read`: everyone reads, only the owner writes. A
// row someone else owns is one the member may READ but not WRITE — the
// row-level write scope the record-scoped projection declares it leaves
// out, measured against the PATCH.

import { defineStack } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';
Expand Down Expand Up @@ -54,6 +58,17 @@ export const RecordFieldAccessMemo = ObjectSchema.create({
},
});

export const RecordFieldAccessNotice = ObjectSchema.create({
name: 'rfa_notice',
sharingModel: 'public_read',
label: 'RFA Notice',
pluralLabel: 'RFA Notices',
fields: {
title: Field.text({ label: 'Title', required: true }),
summary: Field.text({ label: 'Summary' }),
},
});

export const recordFieldAccessFixtureStack = defineStack({
manifest: {
id: 'com.dogfood.record-field-access-fixture',
Expand All @@ -63,7 +78,7 @@ export const recordFieldAccessFixtureStack = defineStack({
name: 'Record Field Access Fixture',
description: 'A field lock that holds only from a stage onward (field permission `when`).',
},
objects: [RecordFieldAccessContract, RecordFieldAccessMemo],
objects: [RecordFieldAccessContract, RecordFieldAccessMemo, RecordFieldAccessNotice],
} as any);

/** The `when` the conditioned entry carries, as authored. */
Expand All @@ -81,6 +96,7 @@ export const recordFieldAccessMemberSet: PermissionSet = PermissionSetSchema.par
objects: {
rfa_contract: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
rfa_memo: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
rfa_notice: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
},
fields: {
'rfa_contract.summary': { readable: true, editable: false, when: APPROVED_ONLY },
Expand Down
20 changes: 20 additions & 0 deletions packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe('a row-conditioned field lock, on the record the form edits', () => {
let draftId: string;
let approvedId: string;
let hiddenMemoId: string;
let othersNoticeId: string;

beforeAll(async () => {
stack = await bootStack(recordFieldAccessFixtureStack, { security: recordFieldAccessFixtureSecurity() });
Expand All @@ -43,6 +44,8 @@ describe('a row-conditioned field lock, on the record the form edits', () => {
approvedId = (await ql.insert('rfa_contract', { title: 'A', status: 'approved' }, { context: SYS })).id;
// A private row the member does not own, so cannot read.
hiddenMemoId = (await ql.insert('rfa_memo', { title: 'approved' }, { context: SYS })).id;
// A public-read row someone else owns: readable, not writable, for the member.
othersNoticeId = (await ql.insert('rfa_notice', { title: 'N' }, { context: SYS })).id;
}, 180_000);

afterAll(async () => {
Expand Down Expand Up @@ -118,6 +121,23 @@ describe('a row-conditioned field lock, on the record the form edits', () => {
}
}, 60_000);

it('measure — a row the caller may read but not write: the projection is field-level only, the PATCH refuses the row', async () => {
// The row-level write scope (sharing, row-level security, ownership) is
// declared outside `editable`: the projection lists the fields
// field-level security admits, and the write is refused for the row as a
// whole. A reader ANDs this answer with the record's own write scope; the
// console's edit dialog has never gated on that scope, so the server's
// 403 on save is its answer there, before this card and after it.
const read = await stack.apiAs(memberTok, 'GET', `/data/rfa_notice/${othersNoticeId}`);
expect(read.status, await read.clone().text()).toBe(200);
const { status, body } = await fieldAccess('rfa_notice', othersNoticeId);
expect(status).toBe(200);
const access = GetRecordFieldAccessResponseSchema.parse(body.data);
expect(access.editable).toEqual(expect.arrayContaining(['title', 'summary']));
const patch = await stack.apiAs(memberTok, 'PATCH', `/data/rfa_notice/${othersNoticeId}`, { title: 'N' });
expect(patch.status, await patch.clone().text()).toBe(403);
}, 60_000);

it('the row-less reader still answers as before, and the route takes no query parameter', async () => {
const res = await stack.apiAs(memberTok, 'GET', '/auth/me/permissions');
const body: any = await res.json();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* `ObjectStackAdapter.getRecordFieldAccess` (objectstack#26594) — the record
* form's question to the server: `GET /api/v1/data/:object/:id/field-access`.
*
* Pinned:
* - the wire: that route, the object and id encoded, the envelope unwrapped;
* - "no answer" is `null`, never a throw, for the two refusals that mean it —
* the record read's own 404 (absent or hidden from the caller) and a server
* without the route (501 `NOT_IMPLEMENTED`) — so the form keeps its
* row-blind answer;
* - any other refusal reaches the caller, as a read's does.
*/

import { describe, it, expect, vi } from 'vitest';
import { ObjectStackAdapter } from './index';

type FetchSpy = ReturnType<typeof vi.fn<(url: string, init: RequestInit) => Promise<Response>>>;

function makeDS(fetchImpl: FetchSpy, baseUrl = 'http://test.local') {
const ds: any = new ObjectStackAdapter({ baseUrl, fetch: fetchImpl as any });
ds.connected = true;
ds.connectionState = 'connected';
return ds;
}

const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });

const ACCESS = { object: 'rfa_contract', recordId: 'c/1', readable: ['title', 'summary'], editable: ['title', 'summary'] };

describe('[#26594] ObjectStackAdapter.getRecordFieldAccess', () => {
it("GETs the record's field-access route and hands back the projection", async () => {
const fetchImpl: FetchSpy = vi.fn(async () => json(200, { success: true, data: ACCESS }));
const ds = makeDS(fetchImpl);
await expect(ds.getRecordFieldAccess('rfa_contract', 'c/1')).resolves.toEqual(ACCESS);
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe('http://test.local/api/v1/data/rfa_contract/c%2F1/field-access');
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
});

it('a record the read answers 404 for is no answer, not a failure', async () => {
const fetchImpl: FetchSpy = vi.fn(async () =>
json(404, { success: false, error: { code: 'RECORD_NOT_FOUND', message: 'Record c1 not found in rfa_memo' } }));
await expect(makeDS(fetchImpl).getRecordFieldAccess('rfa_memo', 'c1')).resolves.toBeNull();
});

it('a server without the route (501) is no answer, not a failure', async () => {
const fetchImpl: FetchSpy = vi.fn(async () =>
json(501, { success: false, error: { code: 'NOT_IMPLEMENTED', message: 'no record-scoped projection' } }));
await expect(makeDS(fetchImpl).getRecordFieldAccess('rfa_contract', 'c1')).resolves.toBeNull();
});

it('any other refusal reaches the caller', async () => {
const fetchImpl: FetchSpy = vi.fn(async () =>
json(403, { success: false, error: { code: 'PERMISSION_DENIED', message: 'no read' } }));
await expect(makeDS(fetchImpl).getRecordFieldAccess('rfa_contract', 'c1')).rejects.toMatchObject({ httpStatus: 403 });
});
});
34 changes: 34 additions & 0 deletions ui/packages/data-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
import type { AnalyticsResult, DatasetSelection } from '@objectstack/spec/contracts';
import type {
DataSource,
RecordFieldAccess,
ConnectionState,
// #4564 — the canonical `deleteView` receipt shapes, declared beside the
// `DataSource` interface they belong to. Imported for the local uses below
Expand Down Expand Up @@ -1018,6 +1019,17 @@ export function is404Error(error: unknown): boolean {
return errorCodeIsAnyOf({ code }, ['OBJECT_NOT_FOUND', 'RECORD_NOT_FOUND']);
}

/**
* A server that does not offer the route asked (`501 NOT_IMPLEMENTED`) — for an
* optional capability, "no answer", never a failure of the read.
*/
function isNotImplementedError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const err = error as Record<string, unknown>;
if (err.httpStatus === 501 || err.status === 501 || err.statusCode === 501) return true;
return errorCodeIs({ code: typeof err.code === 'string' ? err.code : '' }, 'NOT_IMPLEMENTED');
}

// `API_ACCESS_DENIED_CODES` / `isApiAccessDeniedError` live in
// `@object-ui/core` (`utils/api-access-denied.ts`) so that `@object-ui/react`
// can classify the denial without depending on this adapter. Re-exported here
Expand Down Expand Up @@ -7231,6 +7243,28 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
return response.json();
}

/**
* The field projection of ONE stored record (objectstack#26594):
* `GET /data/{object}/{id}/field-access` through the client's
* `data.fieldAccess`. Its `editable` is the server's write gate judged on
* the stored row, so a field permission whose `when` names only some rows
* locks the field only on those rows.
*
* `null` when there is no answer to give: a record the caller cannot see
* (the route answers the record read's own 404), or a server with no
* record-scoped projection (501). Any other failure is thrown, as a read's.
*/
async getRecordFieldAccess(resource: string, id: string): Promise<RecordFieldAccess | null> {
await this.connect();
try {
const access = await this.client.data.fieldAccess(resource, String(id));
return access ?? null;
} catch (error: unknown) {
if (is404Error(error) || isNotImplementedError(error)) return null;
throw error;
}
}

/**
* Cancel (recall) the active pending approval request for a given record.
*
Expand Down
Loading
Loading