Skip to content

Commit a90163d

Browse files
fix(app-shell): the field-permission editor shows and writes exactly what the runtime enforces for a field with no explicit rule (#27006)
Fixes #26597 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:权限集编辑器(Studio 访问、Setup 权限集)中,没有字段规则的字段行改为显示对象本身的“读 / 改”,灰色虚线框并注明“跟随对象”;有规则的字段照常显示,规则授予而对象未授予的格子加琥珀色圈并说明原因。单击和批量按钮只写入屏幕上真正变化的格子;“全部可读”改名“全部只读”;权限集预览列出全部字段规则。 - 为什么改:编辑器对“无字段规则”字段的显示与运行时实际执行不一致,管理员按屏幕判断会得到相反的结果(复现细节按维护者指示不公开)。 - 风险与回滚:只改界面(app-shell、i18n 文案、两篇文档);运行时与 spec 均未改动,运行时一侧只新增一个固定现有行为的测试。回滚即还原本 PR。可见变化:未设规则的字段框变为灰色。 - 先看:`permission-slice.ts` 的 `fieldRuleCell` / `applyFieldBulk`(显示与批量写入的唯一来源);`PermissionMatrixEditor.tsx` 的 `FieldRuleBox` 与 `FieldsSubTable`;`previews/PermissionPreview.tsx` 的字段列表。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/permission-slice.fieldRule-26597.test.ts`,预期 16 passed。 - 需要你做的:无,席位落地。 ## What changes The field-permission editor now shows and writes exactly what the runtime enforces for a field with no explicit rule. - **One drawing, one source** (`ui/packages/app-shell/src/views/metadata-admin/permission-slice.ts`): - `fieldRuleCell(entry, objectPerm)` is what a field row draws. With no entry, the row draws the object row's own Read and Edit, read through the spec's `objectPermissionGrants` (the fold the evaluator answers from, super-user bits included), and is marked inherited. With an entry, the row draws the entry's bits, read as the runtime merge reads them (`=== true`, as `fieldPermissionVote` does). A bit the entry grants and the object row does not is marked capped. - `fieldRuleSeed` is what a click starts from: the entry, or on a row without one exactly the bits the row draws, so a click changes only the box it lands on. - `applyFieldBulk` drives the three field shortcuts. Each moves the visible rows to its target and leaves a row that already draws the target as it is, so a shortcut writes only the rows whose boxes change. Clear deletes the entries, and those rows draw the object grant again. `withFieldBits` (keeps an authored `when`) moved here from the editor unchanged. - **The editor** (`PermissionMatrixEditor.tsx`): `FieldsSubTable` receives the object row's permission and draws each box through `FieldRuleBox`. An inherited box is grey with a dashed edge and carries `data-field-rule="inherited"` and a tooltip. A capped box carries an amber ring, `data-capped` and a tooltip saying a holder of this set alone cannot use it. A legend line explains the grey boxes. `updateFieldPerm` and `updateFieldWhen` seed from `fieldRuleSeed`, and `bulkSetFields` calls `applyFieldBulk`. Both object layouts (the page table and the Studio matrix) render the same sub-table. - **The preview** (`previews/PermissionPreview.tsx`, also shown per set under a position) lists every field entry the set declares, tagged hidden, read-only or editable, with a `when` named in the chip's title. - **Strings** (`@object-ui/i18n`, `studio` namespace): `perm.field.bulk.readable` reads "Read-only all" (zh 「全部只读」) because the shortcut switches Edit off; four new keys explain the grey and amber boxes; `engine.permissionPreview.fls.readOnly` (en + zh) tags a read-only rule in the preview. - **Docs**: `content/docs/permissions/field-level-security.mdx` (the Studio bullet) and `access-recipes.mdx` (step 2 now says to set Edit off). - **Runtime: no change.** The editor now follows the contract the spec, ADR-0066 ⑧ and the field-level-security docs already state. The runtime half is pinned by a new test, `packages/plugins/plugin-security/src/field-rule-object-grant.test.ts`. Same-default sweep across `ui/`: the field sub-table's render, the click seed (`updateFieldPerm`), the condition seed (`updateFieldWhen`) and the bulk shortcuts are all in the one editor and are all fixed here. The preview's field list filtered entries the same way and is fixed too. `MePermissionsProvider.checkField` already defers a field without an entry to the object grant. The explain panel draws no field rows. `apps/console/src/preview-samples.ts` is sample data. ## Tests - `permission-slice.fieldRule-26597.test.ts` (16): the six cells (no entry / read-only / editable × object Edit on / off) against the write gate's verdict, the super-user fold, the capped marks, the seed, and every shortcut's writes, including that it writes nothing for rows already at its target. - `PermissionMatrixEditor.fieldRuleInherited-26597.test.tsx` (8): the rendered boxes and their `data-field-rule` / `data-capped` marks, and the saved payload after a click, a shortcut and Clear. - `previews/PermissionPreview.fieldRules-26597.test.tsx` (3): every declared entry is listed and tagged. - `field-rule-object-grant.test.ts` in plugin-security (7): the same six cells through a real `ObjectQL` over SQLite with the real `SecurityPlugin` middleware, refusals asserted on `code` + `status` (and `details.forbiddenFields` for the field gate), plus the read of a field without an entry. - Updated: `PermissionMatrixEditor.fieldBulk.test.tsx` and `PermissionMatrixEditor.fieldWhen-25866.test.tsx` (the renamed shortcut; the Clear case now asserts the inherited mark), `previews/adminPreviews.i18n-10862-s2.test.tsx` (the new preview row in en and zh). - Ablation (one-time, not kept, run at `ab5961d4`): with `fieldRuleCell`'s no-entry branch mutated so it no longer reads the object grant, 12 of the 24 cases in the two new editor-side files went red. The mutation went through `scripts/ablation-replace.mjs` (anchor hit 1 → 0, blob changed) and was restored to the HEAD blob (`db141c2b`). - One-time HTTP measurement (not kept): the six cells driven through `bootStack`'s real HTTP door by a member holding only the fixture set agreed with both pins in every cell. ## Acceptance notes - **Not in this PR (observation, reported to the coordinator).** The object matrix draws an object row from the set's explicit row only and does not model the `'*'` wildcard, so for a set whose grant on an object comes only from `'*'` the object row and the field rows under it do not show that grant. This predates this change. - **Dispatch vs role file.** The dispatch asked for `dispatch-gates.mjs --commands … CHANGED_PATHS`; the role file says to derive without paths so the script reads the change set from the merge base. The role file was followed. - `@object-ui/app-shell` is private; the changeset names it with `@object-ui/console` (which ships it) and `@object-ui/i18n`, as the other app-shell changesets in `.changeset/` do. ## Verification All readings at `a723bac9` (this branch merged with `origin/main` `c6853b12`), unless a line says otherwise. <details><summary>Builds and type-checks</summary> - `pnpm turbo run build --concurrency=2 --filter='@object-ui/app-shell^...' --filter='@objectstack/plugin-security^...'`: exit 0 - `pnpm --filter @object-ui/app-shell run type-check` (`tsc --noEmit`, then `tsc -p tsconfig.test.json`): exit 0 - `pnpm --filter @object-ui/i18n run type-check`: exit 0 - `pnpm --filter @objectstack/plugin-security run typecheck`: exit 0 </details> <details><summary>Tests</summary> - `pnpm --dir ui exec vitest run --maxWorkers=2` over metadata-admin `PermissionMatrixEditor*`, `permission-slice*`, `previews/PermissionPreview*`, `previews/adminPreviews.i18n-10862-s2.test.tsx`, `PositionPermissionSetsSection.test.tsx`, `studio-design/StudioDesignSurface.accessTabs-12456.test.tsx` and the two `@object-ui/i18n` studio-namespace tests: Test Files 42 passed (42), Tests 318 passed (318), exit 0. - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/field-rule-object-grant.test.ts`: Test Files 1 passed (1), Tests 7 passed (7), exit 0. - Declared narrowing: the rest of the app-shell suite is CI's (`Test UI`). </details> <details><summary>Gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 96 families for the 15-path change set. All 96 ran locally, each exit 0, `pnpm --dir ui exec vitest run scripts/` included (Test Files 178 passed, 2 skipped). `--ran`: "96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN". - `ui/package.json` gates: `check:i18n-keys`, `check:i18n-drift`, `check:i18n-dead-keys`, `check:i18n-designer-parity`, `check:designer-field-key-parity` and `check:new-line-citations` (0 new citations): exit 0 each. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01J8VVAkZns5rNnK2rtszzbQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 25a697e commit a90163d

15 files changed

Lines changed: 869 additions & 66 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
'@object-ui/i18n': patch
5+
---
6+
7+
fix(app-shell): the field-permission editor shows and writes exactly what the runtime enforces for a field with no explicit rule
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
- **A field row without a rule.** In the permission-set editor (Studio Access, and the permission sets under Setup), a field the set declares no field rule for is not constrained by the set: the object grant decides. Its row now shows the object row's own Read and Edit, in grey with a dashed edge, and says it follows the object. A field rule shows its own Read and Edit in the usual colour. A rule that grants Read or Edit the object row does not is ringed in amber, and its tooltip says that a holder of this set alone cannot use it.
13+
- **Every control writes only what changes on screen.** A click on a row without a rule changes only the box it lands on. The field shortcuts move each visible row to their target and leave a row that already shows it as it is: **Read-only all** (renamed from **Read all**, since it switches Edit off), **Write all**, and **Clear**, which returns the visible rows to the object grant. Adding a row condition (**When (edit)**) to a row without a rule changes no box.
14+
- **The permission-set preview** (also shown for each permission set under a position) lists every field rule the set declares, tagged hidden, read-only or editable.
15+
- **`@object-ui/i18n`:** in the `studio` namespace, `perm.field.bulk.readable` reads "Read-only all" (「全部只读」), four new keys explain the grey and amber boxes (`perm.field.inherited.legend`, `perm.field.inherited.tip`, `perm.field.capped.read` and `perm.field.capped.edit`), and `engine.permissionPreview.fls.readOnly` tags a read-only rule in the preview.
16+
17+
What to do: open the field rows of the permission sets you maintain and check them against what you intend. Coloured boxes are rules the set declares; grey boxes follow the object. **Clear** on a row returns it to the object grant.

‎content/docs/permissions/access-recipes.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Every business app meets this shape: a contract's legal terms after approval, an
5050
**In the permission-set editor** (Setup → Access Control → Permission Sets, then open the set):
5151

5252
1. Expand the object (`clm_contract`) to show its field rows.
53-
2. On each field row to lock (`governing_law`, `liability_cap`), keep **Read** on and **Edit** off.
53+
2. On each field row to lock (`governing_law`, `liability_cap`), set **Read** on and **Edit** off.
5454
3. In the row's **When (edit)** column, choose **Add condition** and write the stage condition: `status in ["approved", "signing", "active"]`. The editor lints it like a row-level security clause, and a condition the publish gate would refuse holds the save.
5555

5656
**The same entries as metadata:**

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,10 @@ fields: {
6868
row under **When (edit)**, in the same CEL editor as the row-level security
6969
clauses, linted the same way; a condition the publish gate would refuse
7070
holds the save. Switching **Read** off clears the condition, and the
71-
**Read all** / **Write all** shortcuts keep it.
71+
**Read-only all** / **Write all** shortcuts keep it. A field row with no
72+
rule shows the object's own Read / Edit in grey, marked as following the
73+
object; a click or a shortcut writes a rule only for the boxes it changes,
74+
and **Clear** returns the visible rows to the object grant.
7275

7376
For an administrator's walk-through (the editor path, the matching metadata,
7477
and when a validation rule is the better tool), see the recipe
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#26597] A field a permission set has NO entry for is constrained only by the
5+
* set's object grant — measured through a real `ObjectQL` over a real SQL
6+
* driver, with the real `SecurityPlugin` middleware in front of it.
7+
*
8+
* A permission set constrains only the fields it names: a field the set does
9+
* not name passes the field write gate and the read mask
10+
* (`FieldMasker.detectForbiddenWrites` / `maskResults`: "Fields without a
11+
* permission entry pass through"; ADR-0066 ⑧), so the object grant decides.
12+
*
13+
* This is the runtime half of a pair. Studio's field-permission editor draws a
14+
* field row from the same table (`fieldRuleCell` in `@object-ui/app-shell`'s
15+
* metadata-admin `permission-slice.ts`, pinned by its
16+
* `permission-slice.fieldRule-26597.test.ts`): change the two together.
17+
*
18+
* The table: three field rules (no entry / read-only entry / editable entry) on
19+
* two objects (one whose object grant includes Edit, one without), each written
20+
* by a caller holding ONLY this set.
21+
*/
22+
23+
import { describe, it, expect, vi, afterEach } from 'vitest';
24+
import { ObjectQL } from '@objectstack/objectql';
25+
import { SqlDriver } from '@objectstack/driver-sql';
26+
import { PermissionSetSchema } from '@objectstack/spec/security';
27+
28+
import { SecurityPlugin } from './security-plugin.js';
29+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
30+
import { installDefaultOrganization } from './default-organization.testkit.js';
31+
32+
const SYS = { context: { isSystem: true } } as never;
33+
const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!;
34+
35+
const EDIT_ON = 'qa_fr_edit_on';
36+
const EDIT_OFF = 'qa_fr_edit_off';
37+
const SET = 'qa_fr_editor';
38+
const CALLER = { userId: 'usr_fr_member', positions: [], permissions: [SET], posture: 'MEMBER' };
39+
40+
/** The field each rule is declared on, per object. */
41+
const FIELD_FOR = { absent: 'notes', readOnly: 'locked', editable: 'open' } as const;
42+
type Rule = keyof typeof FIELD_FOR;
43+
44+
/**
45+
* May a holder of this set ALONE edit the field? Studio's editor draws exactly
46+
* this (`RUNTIME_EDIT_VERDICT` in `permission-slice.fieldRule-26597.test.ts`).
47+
*/
48+
const EDIT_VERDICT: Record<string, Record<Rule, boolean>> = {
49+
[EDIT_ON]: { absent: true, readOnly: false, editable: true },
50+
[EDIT_OFF]: { absent: false, readOnly: false, editable: false },
51+
};
52+
53+
interface Refusal { code?: string; status?: number; details?: { forbiddenFields?: string[] } }
54+
type Outcome = { kind: 'landed' } | ({ kind: 'refused' } & Refusal);
55+
56+
interface Rig {
57+
engine: ObjectQL;
58+
update: (object: string, data: Record<string, unknown>) => Promise<Outcome>;
59+
stored: (object: string) => Promise<Record<string, unknown> | null>;
60+
teardown: () => Promise<void>;
61+
}
62+
const rigs: Rig[] = [];
63+
afterEach(async () => {
64+
for (const rig of rigs.splice(0)) await rig.teardown();
65+
});
66+
67+
function outcome(p: Promise<unknown>): Promise<Outcome> {
68+
return p.then(
69+
() => ({ kind: 'landed' as const }),
70+
(e: any) => ({ kind: 'refused' as const, code: e?.code, status: e?.statusCode ?? e?.status, details: e?.details }),
71+
);
72+
}
73+
74+
const objectFields = {
75+
id: { name: 'id', type: 'text', primaryKey: true },
76+
name: { name: 'name', type: 'text' },
77+
notes: { name: 'notes', type: 'text' },
78+
locked: { name: 'locked', type: 'text' },
79+
open: { name: 'open', type: 'text' },
80+
};
81+
82+
async function boot(): Promise<Rig> {
83+
const engine = new ObjectQL();
84+
engine.registerDriver(
85+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never,
86+
true,
87+
);
88+
await engine.init();
89+
engine.registerApp({
90+
id: 'com.objectstack.qa.field-rule-object-grant',
91+
name: 'Field rule: a field without an entry follows the object grant',
92+
version: '1.0.0',
93+
type: 'plugin',
94+
scope: 'system',
95+
objects: [
96+
{ name: EDIT_ON, label: 'Edit granted', sharingModel: 'public_read_write', fields: objectFields },
97+
{ name: EDIT_OFF, label: 'Edit not granted', sharingModel: 'public_read_write', fields: objectFields },
98+
],
99+
} as never);
100+
await engine.syncSchemas();
101+
await installDefaultOrganization(engine);
102+
const row = { id: 'r1', name: 'row', notes: 'n0', locked: 'l0', open: 'o0' };
103+
await engine.insert(EDIT_ON, [row], SYS);
104+
await engine.insert(EDIT_OFF, [row], SYS);
105+
106+
const set = PermissionSetSchema.parse({
107+
name: SET,
108+
objects: {
109+
[EDIT_ON]: { allowRead: true, allowCreate: true, allowEdit: true },
110+
[EDIT_OFF]: { allowRead: true, allowCreate: true },
111+
},
112+
// No entry for `notes` on either object.
113+
fields: {
114+
[`${EDIT_ON}.locked`]: { readable: true, editable: false },
115+
[`${EDIT_ON}.open`]: { readable: true, editable: true },
116+
[`${EDIT_OFF}.locked`]: { readable: true, editable: false },
117+
[`${EDIT_OFF}.open`]: { readable: true, editable: true },
118+
},
119+
});
120+
const services: Record<string, unknown> = {
121+
manifest: { register: vi.fn() },
122+
objectql: engine,
123+
metadata: {
124+
get: async (_type: string, name: string) => engine.getSchema(name) ?? null,
125+
list: async () => [MEMBER_DEFAULT, set],
126+
},
127+
};
128+
const ctx = {
129+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
130+
registerService: vi.fn(),
131+
hook: () => undefined,
132+
getService: (name: string) => {
133+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
134+
return services[name];
135+
},
136+
};
137+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
138+
await plugin.init(ctx as never);
139+
await plugin.start(ctx as never);
140+
141+
const rig: Rig = {
142+
engine,
143+
update: (object, data) => outcome(engine.update(object, data, { where: { id: 'r1' }, context: { ...CALLER } } as never)),
144+
stored: async (object) =>
145+
((await engine.findOne(object, { where: { id: 'r1' }, context: { isSystem: true } } as never)) ?? null) as Record<string, unknown> | null,
146+
teardown: async () => { try { await engine.destroy(); } catch { /* noop */ } },
147+
};
148+
rigs.push(rig);
149+
return rig;
150+
}
151+
152+
describe('a field without an entry follows the object grant (#26597)', () => {
153+
for (const object of [EDIT_ON, EDIT_OFF]) {
154+
for (const rule of ['absent', 'readOnly', 'editable'] as const) {
155+
const field = FIELD_FOR[rule];
156+
const allowed = EDIT_VERDICT[object][rule];
157+
it(`${object} × ${rule}: an update naming the field is ${allowed ? 'written' : 'refused'}`, async () => {
158+
const r = await boot();
159+
const got = await r.update(object, { [field]: 'changed' });
160+
if (allowed) {
161+
expect(got).toEqual({ kind: 'landed' });
162+
expect((await r.stored(object))?.[field]).toBe('changed');
163+
return;
164+
}
165+
expect(got).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 });
166+
// On the Edit-granting object the refusal is the FIELD gate's, naming the field.
167+
if (object === EDIT_ON) expect(got).toMatchObject({ details: { forbiddenFields: [field] } });
168+
// Refused means untouched.
169+
expect((await r.stored(object))?.[field]).not.toBe('changed');
170+
});
171+
}
172+
}
173+
174+
it('a field without an entry is served on read wherever the object grants Read', async () => {
175+
const r = await boot();
176+
for (const object of [EDIT_ON, EDIT_OFF]) {
177+
const seen = (await r.engine.findOne(object, { where: { id: 'r1' }, context: { ...CALLER } } as never)) as Record<string, unknown> | null;
178+
expect(seen?.notes).toBe('n0');
179+
}
180+
});
181+
});

‎ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.fieldBulk.test.tsx‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
/**
44
* objectui#2600 B4 — field-level bulk + filter. Expanding a wide object used to
55
* mean toggling two checkboxes per field, one field at a time. The sub-table
6-
* now carries a field filter and Read-all / Write-all / Clear shortcuts that
7-
* act over the VISIBLE (filtered) fields — mirroring the per-object row.
6+
* now carries a field filter and Read-only all / Write all / Clear shortcuts
7+
* that act over the VISIBLE (filtered) fields — mirroring the per-object row.
8+
* What a shortcut writes on a row with no rule, and what Clear returns a row
9+
* to, is pinned in `PermissionMatrixEditor.fieldRuleInherited-26597.test.tsx`.
810
*/
911

1012
import '@testing-library/jest-dom/vitest';
@@ -85,8 +87,8 @@ describe('PermissionMatrixEditPage — field-level bulk + filter (objectui#2600
8587
expect(screen.queryByLabelText('a_account.email readable')).toBeNull();
8688
expect(screen.getByText('2 / 8')).toBeInTheDocument();
8789

88-
// Read-all now only revokes write on the visible (filtered) fields.
89-
fireEvent.click(screen.getByRole('button', { name: 'Read all' }));
90+
// Read-only all now only revokes write on the visible (filtered) fields.
91+
fireEvent.click(screen.getByRole('button', { name: 'Read-only all' }));
9092
expect(screen.getByLabelText('a_account.region editable')).not.toBeChecked();
9193
expect(screen.getByLabelText('a_account.region readable')).toBeChecked();
9294

@@ -95,15 +97,16 @@ describe('PermissionMatrixEditPage — field-level bulk + filter (objectui#2600
9597
expect(screen.getByLabelText('a_account.email editable')).toBeChecked();
9698
});
9799

98-
it('Clear drops the overrides so fields fall back to the read-only default', async () => {
100+
it('Clear drops the rules so the rows draw the object grant again', async () => {
99101
await renderExpanded();
100102

101103
fireEvent.click(screen.getByRole('button', { name: 'Write all' }));
102104
expect(screen.getByLabelText('a_account.name editable')).toBeChecked();
103105

104106
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
105-
// Default field posture: readable, not editable.
107+
// No rule: the row draws the object row's grant (Read, no Edit), marked inherited.
106108
expect(screen.getByLabelText('a_account.name readable')).toBeChecked();
107109
expect(screen.getByLabelText('a_account.name editable')).not.toBeChecked();
110+
expect(screen.getByLabelText('a_account.name editable')).toHaveAttribute('data-field-rule', 'inherited');
108111
});
109112
});

0 commit comments

Comments
 (0)