Skip to content

Commit 7111e14

Browse files
objectstack-fleet[bot]zhuangjianguoclaude
authored
feat(ui): the Studio permission-set form offers the field permission when — patch round of #25980 (#25866) (#26010)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb7q6UepuLZRqHF7Q7j1iobb)_ Part of #25866 Clause-②: yes (widening) Metadata-protocol: none Ruling-ref: 6408558941 ## Why a second PR #25980 landed the key (merged by os-zhuang at 08:51Z, squash `c8cb510a`) while the seat's patch round on it (verdict 6411666422, after contract review 6411620876 PASS) was being written: auto-merge had been armed before the verdict, and the merge queue took the approved head. The merge deleted the branch; the patch push recreated it, so the branch is rebuilt here from `main` with the patch commit, plus a merge commit that keeps the pushed history reachable (no content beyond `main` and the patch round — the diff against `main` is the seven files below). This PR is that patch round: the Studio clause of ruling 6408558941 (part ①, item 4), the ADR sentence, and the record corrections the seat asked for. #25866 was closed by #25980; this PR is a follow-up and leaves it so. ## What lands - **Studio form (ruling part ①, "the Studio permission-set form offers the key").** `ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx`: each field row carries a **When (edit)** column. An authored condition shows as a chip; the row opens the same CEL editor the RLS clauses use (`CelPredicateField`, clause `check`), linted the same way, and a blocking finding holds the save (package-door autosave and Save button alike) as an RLS clause does. The Read-off cascade in `updateFieldPerm` clears the condition, so the shape the spec refuses (`when` beside `readable: false`) is never authored; `bulkSetFields` rewrites the two booleans and keeps an authored `when` (`withFieldBits`); Clear drops the entry; blank text removes the key; a condition typed on a Read-off entry switches Read on. `FieldPerm` (`permission-slice.ts`) gains `when`, carried verbatim by the package-door slice merge. en and zh rows in `i18n.ts`. - **ADR-0066 ⑧ note.** The garbled last sentence now reads "Taking an edit away that another set grants remains the job of the muting/deny layer ⑦ still owes." - **Docs.** `content/docs/permissions/field-level-security.mdx` names the Studio control under the `when` section. - **Changeset** `.changeset/25866-studio-field-permission-when.md`: `@object-ui/app-shell` patch (Metadata-protocol `none`: the key itself landed with #25980). The merged changeset of #25980 is left as it landed (check-changeset-overwrite). ## Record corrections the seat asked for - The "Not measured" Studio paragraph of #25980's body stated a false premise (`../objectui`, unreadable). That body already carries the correction made after the contract review; #25980 is merged, so it stays as the record of that PR, and this PR is where the Studio clause is delivered. - "byte-identical" for the evaluator (Core bullet of #25980's body), softened here: bits are read `=== true` — identical to the previous merges for a parsed entry (the schema materialises both booleans), and fail-closed where the old truthiness read was wider: a malformed stored entry carrying a non-boolean truthy value no longer grants (contract review ①.8). ## Acceptance notes - `PermissionMatrixEditor.fieldWhen-25866.test.tsx` (6): offer, show and save; a stored condition on load; Read off clears; the Edit toggle and Read all / Write all keep, Clear drops; blank removes the key; a refused condition holds the save and a fixed one releases it. - Re-run green: the authored-keys census (`permission-slice.authoredKeys.test.ts` — the bulk rewrite moved into `withFieldBits` so the census reads no spread-first literal), field bulk / envelope / basics / autosave / RLS-hold / dirty, `permission-slice`, the zh base-term census, the Studio access-tabs test (43/43); `@object-ui/app-shell` type-check (source and test tsconfig) and ESLint, all from the ui root as `ui/AGENTS.md` prescribes. - The ui CI families (Type Check UI, Lint UI, Test UI shards, Build & E2E UI) are CI's own lanes; they are cited from this PR's check-runs in the os-dev report addendum. ## 维护者速读(草稿) - Studio 权限集表单的字段行新增「条件(改)」列:已填写的条件以 chip 展示,点开后用与 RLS 子句相同的 CEL 编辑器编辑、同样 lint;会被发布门拒绝的条件会挂起保存。 - 关闭「读」会清除条件(规范拒绝 `readable: false` 旁的 `when`,表单永不生成该形状);「全部可读 / 全部可写」保留条件;「清空」删除条目;空文本删除键。 - ADR-0066 ⑧ 注的末句已改写。本 PR 是 #25980 的补丁轮(该 PR 已先行合入),不关闭 #25866。 ## Verification See the os-dev report addendum on #25866 for the gate run record on this head (exit codes captured before any pipe, reconciled with `dispatch-gates --ran`) and the ui test, type-check and lint runs. --- _Generated by [Claude Code](https://claude.ai/code/session_01N3GzdgZRZ3z4wYfkWX1RJd)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01N3GzdgZRZ3z4wYfkWX1RJd --------- Co-authored-by: zhuangjianguo <zhuangjianguo@steedos.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6676991 commit 7111e14

7 files changed

Lines changed: 416 additions & 32 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
feat(ui): the Studio permission-set form offers the field permission `when` (row condition on the edit vote)
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
Part of #25866 — the Studio clause of ruling 6408558941 (part ①, item 4), after #25980 landed the key itself.
11+
12+
- **Before.** The permission matrix's field rows carried two checkboxes, `readable` and `editable`: no way to offer or see a `when`. The Read-off cascade could leave `readable: false` beside an authored `when` (the shape the spec refuses at publish), and the Read all / Write all shortcuts dropped the condition.
13+
- **Now.** Each field row carries **When (edit)**: an authored condition shows as a chip, and the row opens the same CEL editor the row-level-security clauses use, linted the same way; a finding the publish gate would refuse holds the save. Read off clears the condition, Read all / Write all keep it, Clear drops the entry, blank text removes the key. `FieldPerm` gains `when`, carried verbatim by the package-door slice merge.

‎content/docs/permissions/field-level-security.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ fields: {
6464
RLS predicate (`rls-predicate-*` findings on `permissions[].fields[].when`):
6565
the fields it names must exist on the entry's object, no cross-object
6666
reach, no unresolvable `current_user.*` variable.
67+
- **In Studio.** The permission-set form offers the condition on each field
68+
row under **When (edit)**, in the same CEL editor as the row-level security
69+
clauses, linted the same way; a condition the publish gate would refuse
70+
holds the save. Switching **Read** off clears the condition, and the
71+
**Read all** / **Write all** shortcuts keep it.
6772

6873
## Hidden vs. Read-Only
6974

‎docs/adr/0066-unified-authorization-model.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ Captured for the record; **out of scope for Phases 1–4 above**. Each is anchor
131131
- **⑦ Permission-set groups + subtractive *muting*.** Pure union does not scale governance ("permission-set explosion"); Salesforce added permission-set-group *muting* precisely to allow taking access away. Roles→permission-sets already bundle; a subtractive/deny layer (precedence step 4) is the missing piece for large-org administration. Pairs with delegated admin (#9).
132132
- **⑧ FLS posture: unlisted fields are visible, and field grants union without deny** *(recorded 2026-07 pre-launch architecture assessment)*. Runtime FLS is block-list-shaped: `field-masker.ts` hides a field only when an explicit rule marks it `readable:false`, so an **undeclared** sensitive field is exposed by default, and `getFieldPermissions` merges most-permissively, so one set's `readable:true` permanently out-votes another's `false`. Consequences: protecting a salary/ID-number field relies on discipline (grant it only where needed), not mechanism. The object-level `private` posture (D2/④) mitigates the unlisted-default-visible half for objects that adopt it; the union half is the field-level face of ⑦ — a muting/deny layer must cover **field** grants, not just object grants, when it lands. Until then, treat "sensitive field on a `public` object" as a smell in review.
133133

134-
> **Note (2026-10-26) — a field permission entry may carry a row condition (#25866, ruling of record 6408558941).** `FieldPermissionSchema.when` is a row predicate in the same canonical CEL as `rowLevelSecurity[].using`, on the **edit** vote only (`when` beside `readable: false` is refused at parse). The condition **narrows where an entry votes**: on a row that matches, the entry votes as declared; on a row that does not, the entry is **absent**, as if the set had not declared the field. It does **not** change the posture recorded here — field grants still **union without deny**: a matched conditioned lock is out-voted by a sibling's unconditioned `editable: true` exactly as before, an entry without `when` votes on every row, and only declaring entries vote. The write gate judges the stored row before an update and the incoming row of an insert, in process (`RLSCompiler.compileRowPredicate` + `storedFormCheckJudge`); a row that cannot be judged (a predicate update, a stored row the caller cannot read, a condition that does not resolve for the request) and every row-blind reader (`/auth/me/permissions`, the field projections, `os explain`) apply the entry as its lock, so no reading is wider than the gate's. The condition is validated like an RLS predicate at authoring (`rls-predicate-*` findings on `permissions[].fields[].when`). The muting/deny layer ⑦ still owes remains the mechanism for taking an edit away that another set grants.
134+
> **Note (2026-10-26) — a field permission entry may carry a row condition (#25866, ruling of record 6408558941).** `FieldPermissionSchema.when` is a row predicate in the same canonical CEL as `rowLevelSecurity[].using`, on the **edit** vote only (`when` beside `readable: false` is refused at parse). The condition **narrows where an entry votes**: on a row that matches, the entry votes as declared; on a row that does not, the entry is **absent**, as if the set had not declared the field. It does **not** change the posture recorded here — field grants still **union without deny**: a matched conditioned lock is out-voted by a sibling's unconditioned `editable: true` exactly as before, an entry without `when` votes on every row, and only declaring entries vote. The write gate judges the stored row before an update and the incoming row of an insert, in process (`RLSCompiler.compileRowPredicate` + `storedFormCheckJudge`); a row that cannot be judged (a predicate update, a stored row the caller cannot read, a condition that does not resolve for the request) and every row-blind reader (`/auth/me/permissions`, the field projections, `os explain`) apply the entry as its lock, so no reading is wider than the gate's. The condition is validated like an RLS predicate at authoring (`rls-predicate-*` findings on `permissions[].fields[].when`). Taking an edit away that another set grants remains the job of the muting/deny layer ⑦ still owes.
135135
- **⑨ Authoring-time validation for capability references** *(recorded 2026-07 pre-launch architecture assessment)*. `systemPermissions` / `requiredPermissions` are free strings today; a typo (`mange_users`) fails closed at runtime — the safe direction — but is undiscoverable: nothing reports that the referenced capability exists nowhere. When D1 lands the `sys_permission` registry, add the authoring/publish-gate lint alongside it: referencing an unregistered capability warns at author time (consistent with ADR-0049 honesty and the contract-first Prime Directive — reject at the producer, don't tolerate at the consumer). **Landed (2026-07):** `validateCapabilityReferences` in `@objectstack/lint` warns (never errors — cross-package capabilities and the fail-closed runtime make a hard gate wrong) when a `requiredPermissions` reference resolves against neither the built-in set (`@objectstack/spec` `PLATFORM_CAPABILITY_NAMES`), nor a capability the stack grants via a permission set's `systemPermissions`, nor a `sys_capability` seed row. Wired into `os validate` and `os lint`. `systemPermissions` (the declaration side) is deliberately not flagged.
136136

137137
> **Note (2026-10-14) — ⑨'s seed-row source is gone.** With `sys_capability` retired by #15204
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#25866 — a field permission entry may carry a row condition on
5+
* its EDIT vote (`when`, the CEL of `rowLevelSecurity[].using`). The matrix
6+
* offers the key beside the Read / Edit boxes and shows an authored one, and
7+
* it never authors the shape the spec refuses (`when` beside
8+
* `readable: false`): Read off clears the condition, the bulk shortcuts keep
9+
* it, blank text removes the key, and a condition the publish gate would
10+
* refuse holds the save the way an RLS clause does.
11+
*/
12+
13+
import '@testing-library/jest-dom/vitest';
14+
import { afterEach, describe, expect, it, vi } from 'vitest';
15+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
16+
import { MemoryRouter } from 'react-router-dom';
17+
18+
interface FieldEntry {
19+
readable?: boolean;
20+
editable?: boolean;
21+
when?: string;
22+
}
23+
interface SavedSet {
24+
fields?: Record<string, FieldEntry>;
25+
}
26+
27+
/** The slice of the metadata client the editor calls here. */
28+
type FakeClient = Record<string, (...args: never[]) => Promise<unknown>>;
29+
30+
let clientImpl: FakeClient;
31+
let saved: SavedSet[] = [];
32+
33+
vi.mock('./useMetadata', () => ({
34+
useMetadataClient: () => clientImpl,
35+
useMetadataTypes: () => ({
36+
loading: false,
37+
error: null,
38+
entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }],
39+
}),
40+
}));
41+
42+
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
43+
44+
import { PermissionMatrixEditPage } from './PermissionMatrixEditor';
45+
46+
afterEach(() => {
47+
cleanup();
48+
saved = [];
49+
});
50+
51+
// Eight fields: the same wide object the bulk cases use.
52+
const FIELDS = ['amount', 'email', 'name', 'owner', 'phone', 'region', 'revenue', 'status'];
53+
54+
function makeClient(storedFields: Record<string, FieldEntry>): FakeClient {
55+
return {
56+
layered: async () => ({
57+
effective: {
58+
name: 'sales_perms',
59+
label: 'Sales',
60+
objects: { a_account: { allowRead: true } },
61+
fields: storedFields,
62+
},
63+
code: null,
64+
overlay: null,
65+
overlayScope: null,
66+
}),
67+
getDraft: async () => null,
68+
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
69+
get: async (type: string) => (type === 'object' ? { fields: FIELDS.map((name) => ({ name })) } : null),
70+
save: async (_type: string, _name: string, payload: Record<string, unknown>) => {
71+
saved.push(JSON.parse(JSON.stringify(payload)) as SavedSet);
72+
return payload;
73+
},
74+
};
75+
}
76+
77+
/** The package door autosaves 1.5s after the last edit (objectui#11787). */
78+
const AUTOSAVE = { timeout: 4000 };
79+
const WHEN_LABEL = 'WHEN (edit vote — row condition)';
80+
81+
async function renderExpanded(storedFields: Record<string, FieldEntry> = {}) {
82+
clientImpl = makeClient(storedFields);
83+
render(
84+
<MemoryRouter>
85+
<PermissionMatrixEditPage type="permission" name="sales_perms" packageId="app.a" />
86+
</MemoryRouter>,
87+
);
88+
fireEvent.click(await screen.findByRole('button', { name: /a_account/ }));
89+
await screen.findByLabelText('a_account.amount readable');
90+
}
91+
92+
const whenButton = (field: string) => screen.getByRole('button', { name: `a_account.${field} when` });
93+
94+
async function authorWhen(field: string, expression: string) {
95+
fireEvent.click(whenButton(field));
96+
const editor = await screen.findByLabelText(WHEN_LABEL);
97+
fireEvent.change(editor, { target: { value: expression } });
98+
return editor;
99+
}
100+
101+
/** The next autosave to land after `n` already have. */
102+
async function saveAfter(n: number): Promise<SavedSet> {
103+
await waitFor(() => expect(saved.length).toBeGreaterThan(n), AUTOSAVE);
104+
return saved[saved.length - 1];
105+
}
106+
107+
describe('a field row offers a row condition on its edit vote (objectstack#25866)', () => {
108+
it('offers the key, shows the authored condition, and the saved entry carries it', async () => {
109+
await renderExpanded();
110+
expect(whenButton('amount')).toHaveTextContent('Add condition');
111+
112+
await authorWhen('amount', "status == 'draft'");
113+
// The chip beside the boxes shows the condition as authored.
114+
expect(whenButton('amount')).toHaveTextContent("status == 'draft'");
115+
116+
const set = await saveAfter(0);
117+
expect(set.fields?.['a_account.amount']).toEqual({ readable: true, editable: false, when: "status == 'draft'" });
118+
});
119+
120+
it('shows a stored condition on load; the other rows still offer the key', async () => {
121+
await renderExpanded({ 'a_account.amount': { readable: true, editable: false, when: "status == 'approved'" } });
122+
expect(whenButton('amount')).toHaveTextContent("status == 'approved'");
123+
expect(whenButton('email')).toHaveTextContent('Add condition');
124+
});
125+
126+
it('Read off clears the condition: the spec refuses `when` beside readable:false', async () => {
127+
await renderExpanded({ 'a_account.amount': { readable: true, editable: true, when: 'owner == current_user.id' } });
128+
129+
fireEvent.click(screen.getByLabelText('a_account.amount readable'));
130+
expect(screen.getByLabelText('a_account.amount readable')).not.toBeChecked();
131+
expect(screen.getByLabelText('a_account.amount editable')).not.toBeChecked();
132+
// No affordance on a row that cannot carry a condition.
133+
expect(screen.queryByRole('button', { name: 'a_account.amount when' })).toBeNull();
134+
135+
const set = await saveAfter(0);
136+
expect(set.fields?.['a_account.amount']).toEqual({ readable: false, editable: false });
137+
});
138+
139+
it('the Edit toggle and the bulk Read all / Write all keep the condition; Clear drops it', async () => {
140+
await renderExpanded({ 'a_account.amount': { readable: true, editable: false, when: "status == 'draft'" } });
141+
142+
fireEvent.click(screen.getByLabelText('a_account.amount editable'));
143+
expect(whenButton('amount')).toHaveTextContent("status == 'draft'");
144+
145+
fireEvent.click(screen.getByRole('button', { name: 'Write all' }));
146+
expect(screen.getByLabelText('a_account.amount editable')).toBeChecked();
147+
expect(whenButton('amount')).toHaveTextContent("status == 'draft'");
148+
149+
fireEvent.click(screen.getByRole('button', { name: 'Read all' }));
150+
expect(screen.getByLabelText('a_account.amount editable')).not.toBeChecked();
151+
expect(whenButton('amount')).toHaveTextContent("status == 'draft'");
152+
153+
const first = await saveAfter(0);
154+
expect(first.fields?.['a_account.amount']).toEqual({ readable: true, editable: false, when: "status == 'draft'" });
155+
expect(first.fields?.['a_account.email']).toEqual({ readable: true, editable: false });
156+
157+
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
158+
expect(whenButton('amount')).toHaveTextContent('Add condition');
159+
const second = await saveAfter(1);
160+
expect(second.fields?.['a_account.amount']).toBeUndefined();
161+
});
162+
163+
it('blank text removes the key rather than saving a blank condition', async () => {
164+
await renderExpanded({ 'a_account.amount': { readable: true, editable: false, when: "status == 'draft'" } });
165+
166+
const editor = await authorWhen('amount', ' ');
167+
expect(editor).toHaveValue('');
168+
expect(whenButton('amount')).toHaveTextContent('Add condition');
169+
170+
const set = await saveAfter(0);
171+
expect(set.fields?.['a_account.amount']).toEqual({ readable: true, editable: false });
172+
});
173+
174+
it('a condition the publish gate would refuse holds the save; a fixed one releases it', async () => {
175+
await renderExpanded();
176+
177+
// A valid condition first: it lands, and warms the lint engine, so the
178+
// fault below is judged well inside the autosave pause.
179+
await authorWhen('amount', "status == 'draft'");
180+
await screen.findByText('Valid CEL', {}, { timeout: 3000 });
181+
await saveAfter(0);
182+
183+
fireEvent.change(screen.getByLabelText(WHEN_LABEL), { target: { value: 'status ==' } });
184+
const held = await screen.findByTestId('perm-autosave-held', {}, { timeout: 3000 });
185+
expect(held).toHaveTextContent('Fix the CEL syntax errors before saving.');
186+
// Longer than the autosave's pause: the held edit does not land.
187+
await new Promise((r) => setTimeout(r, 2000));
188+
expect(saved).toHaveLength(1);
189+
190+
fireEvent.change(screen.getByLabelText(WHEN_LABEL), { target: { value: "status == 'approved'" } });
191+
await waitFor(() => expect(screen.queryByTestId('perm-autosave-held')).toBeNull(), { timeout: 3000 });
192+
const set = await saveAfter(1);
193+
expect(set.fields?.['a_account.amount']).toEqual({ readable: true, editable: false, when: "status == 'approved'" });
194+
});
195+
});

0 commit comments

Comments
 (0)