Skip to content

Commit 69df290

Browse files
fix(app-shell): the permission matrix's object rows show exactly what the runtime enforces for every grant shape (#27051)
Fixes #27010 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:权限集编辑器(Studio 访问、Setup 权限集)的对象行,改为显示运行时对该对象实际生效的对象授权;继承而来的授权用与字段行相同的灰色虚线框标出,并有提示和图例说明来源。在这类行上勾选、选记录范围或用行快捷按钮时,写入的对象条目从该行显示的内容开始。“只看已授权”、已授权对象计数、委派管理员的配套授权列表按同一读法。 - 为什么改:对象行对部分授权形态的显示与运行时执行不一致,管理员按屏幕操作可能在看不到的情况下改变实际权限(复现细节按维护者指示不公开)。 - 风险与回滚:只改界面(app-shell、i18n 文案);运行时与 spec 均未改动,运行时一侧只新增一个固定现有行为的测试。回滚即还原本 PR。可见变化:继承授权的对象行显示为灰色虚线框。 - 先看:`permission-slice.ts` 的 `objectRuleCell` / `objectRuleSeed`(显示与写入起点的唯一来源);`PermissionMatrixEditor.tsx` 的 `objectEditSeed` 各调用点与 `ObjectRuleBox`;`PermissionAdvancedFacets.tsx` 的 `companionRow`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/permission-slice.objectGrant-27010.test.ts`,预期 17 passed。 - 需要你做的:无,席位落地。 ## What changes The permission matrix's object rows show exactly what the runtime enforces for every grant shape. - **One reading, one source** (`ui/packages/app-shell/src/views/metadata-admin/permission-slice.ts`): - `objectRuleCell(objects, objectName, accessDefault)` is what an object row draws: the object entry the runtime applies, resolved the way `resolveObjectPermission` (`@objectstack/plugin-security`, ADR-0066 D2) resolves it, with the object's `access.default` read from the catalog the rows are listed from. `inherited` marks a row whose grant comes from outside its own entry. - `objectRuleSeed` is what an edit of a row starts from: the drawn entry, as a fresh copy. - `scopePermissionSet` keeps, beside the package's own rows, the one entry the rows need for display; `mergePermissionSlice` still takes everything outside the package's objects from the fresh base (pinned). - **The editor** (`PermissionMatrixEditor.tsx`): both renderings (the routed page, which is also the Setup catalog's editor, and the Studio Access Objects tab) draw each row through `objectRuleCell`, box by box in `ObjectRuleBox`. An inherited box wears the grey dashed look inherited field rows already use (now one shared class), with a tooltip and a legend line; the row's scope pickers are marked the same way. Every row edit (a box, a scope pick, a row shortcut) and every field-row edit under it starts from the drawn entry (`objectEditSeed`, `drawnObjectPerm`). "Only granted" and the granted-object count read the rows as drawn. - **Delegated-admin companions** (`PermissionAdvancedFacets.tsx`): the companion list and its one-click grant read a companion object's grant through the same `objectRuleCell`, with the posture the editor passes from its catalog (`objectAccessDefault`). - **Strings** (`@object-ui/i18n`, `studio`): `perm.object.inherited.legend` and `perm.object.inherited.tip`, en and zh. - **Runtime: no change.** The editor follows `resolveObjectPermission`. The runtime half is pinned by a new test, `packages/plugins/plugin-security/src/object-grant-wildcard.test.ts`, whose table is the editor pin's table. ## Tests - `permission-slice.objectGrant-27010.test.ts` (17): every row of the table against the runtime's verdicts, the posture default, the seed, and the slice and merge. - `PermissionMatrixEditor.objectGrantInherited-27010.test.tsx` (20): the rendered boxes, pickers, legend, filter and count on both renderings, the companion wiring, and the saved payload after a click, a scope pick, a row shortcut, a field click and a package-door autosave. - `PermissionAdvancedFacets.companionWildcard-27010.test.tsx` (4): the companion marks and the one-click grant. - `object-grant-wildcard.test.ts` in plugin-security (20): the same table through a real `ObjectQL` over SQLite with the real `SecurityPlugin` middleware, posture read from the registered object; refusals asserted on `code` + `status`, a written update read back from storage. - Ablation (one-time, not kept, at `60ed127c`): `objectRuleCell`'s inherited branch mutated through `scripts/ablation-replace.mjs` (anchor hit 1 → 0, blob `8ade369f` → `87539ee9`); 25 of the 41 new editor-side cases went red; restored to the HEAD blob with `git diff HEAD` empty. No rebuild was needed: the tests import the module by relative path. ## Acceptance notes - **Surveyed surfaces.** The Setup permission-set editor is this component and is covered. The other surfaces that draw a set's object grants were surveyed; the findings went to the coordinator in the private report. - **Not in this PR (product decision, reported to the coordinator).** The matrix has no control to view or edit the entry inherited rows draw from, nor one to return an edited row to inheriting. Neither is needed for this card's acceptance. - **File surface.** The claim named the object rows and `permission-slice.ts`. This PR also changes `PermissionAdvancedFacets.tsx` (same directory, same reading) and adds a test-only file in plugin-security, as the dispatch asked. - `@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 do. - `test:repo` (the vitest `repo` project) was not run locally: no package in this diff declares it, and CI's `Test Core` runs it. - `origin/main` moved four commits past the base during this work; none touches a file in this diff, so the branch was not merged. The merge queue tests the combined tree. - Docs drift (noted, not filed): `content/docs/permissions/administrator-guide.mdx` Step 5's object-grid bullet still lists Restore and Purge, both retired, and omits Export. ## Verification All readings at `d779ee6d` (this branch, base `a90163d4`), 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 (63 tasks). - `pnpm --filter @object-ui/app-shell run type-check` (`tsc --noEmit`, then `tsc -p tsconfig.test.json`, whose file list carries the three new test files): exit 0. - `pnpm --filter @object-ui/i18n run type-check`: exit 0. - `pnpm --filter @objectstack/plugin-security run typecheck` (with `check:test-typecheck`): exit 0. </details> <details><summary>Tests</summary> - `pnpm --dir ui exec vitest run --maxWorkers=2` over metadata-admin `PermissionMatrixEditor*`, `permission-slice*`, `PermissionAdvancedFacets*`, `previews/PermissionPreview*`, `previews/adminPreviews.i18n-10862-s2.test.tsx`, `PositionPermissionSetsSection.test.tsx`, `studio-design/StudioDesignSurface.accessTabs-12456.test.tsx` and `@object-ui/i18n`'s `studio-namespace.test.ts`: Test Files 51 passed (51), Tests 429 passed (429), exit 0. - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/object-grant-wildcard.test.ts`: Test Files 1 passed (1), Tests 20 passed (20), 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 66 families for the 10-path change set. All 66 ran locally. 64 exited 0 on the first run; `check:dual-build-cjs-loads` and `check:i18n` exited 3 (prerequisite not met: unbuilt `dist/`) and exited 0 once their prerequisite builds ran. `--ran`: "66 derived, 66 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`, `check:new-line-citations` (0 new citations) and `check:control-bytes`: exit 0 each. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01J8VVAkZns5rNnK2rtszzbQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2713377 commit 69df290

10 files changed

Lines changed: 1018 additions & 41 deletions
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+
'@object-ui/console': patch
4+
'@object-ui/i18n': patch
5+
---
6+
7+
fix(app-shell): the permission matrix's object rows show exactly what the runtime enforces for every grant shape
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
- **Object rows.** In the permission-set editor (Studio Access, and the permission sets under Setup), each object row shows the object grant the runtime applies to that object. A row that inherits its grant is drawn in grey with a dashed edge, the look inherited field rows already use, with a tooltip and a legend line saying where the grant comes from. Editing such a row writes an entry for the object that starts from what the row shows.
13+
- **Summaries.** "Only granted", the granted-object count and the delegated-admin companion list under Admin scope read the object grant as the rows show it.
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0066 D2] Which object entry a permission set applies to an object,
5+
* measured through a real `ObjectQL` over a real SQL driver, with the real
6+
* `SecurityPlugin` middleware in front of it — the object's `access.default`
7+
* read from the registered object, as the middleware reads it.
8+
*
9+
* `resolveObjectPermission` (permission-evaluator.ts) returns ONE entry per
10+
* (set, object) and never merges two:
11+
*
12+
* - the object's own entry, when the set has one (an empty entry included);
13+
* - else the set's `'*'` entry, for an object that is not `private`;
14+
* - for a `private` object, the `'*'` entry only when it carries
15+
* `viewAllRecords` / `modifyAllRecords`;
16+
* - else nothing.
17+
*
18+
* This is the runtime half of a pair. Studio's permission matrix draws an
19+
* object row from the same table (`objectRuleCell` in `@object-ui/app-shell`'s
20+
* metadata-admin `permission-slice.ts`, pinned by its
21+
* `permission-slice.objectGrant-27010.test.ts`, whose `RUNTIME` table is this
22+
* file's `VERDICT`): change the two together.
23+
*/
24+
25+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
26+
import { ObjectQL } from '@objectstack/objectql';
27+
import { SqlDriver } from '@objectstack/driver-sql';
28+
import { PermissionSetSchema } from '@objectstack/spec/security';
29+
30+
import { SecurityPlugin } from './security-plugin.js';
31+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
32+
import { installDefaultOrganization } from './default-organization.testkit.js';
33+
34+
const SYS = { context: { isSystem: true } } as never;
35+
const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!;
36+
37+
const PUBLIC_OBJECT = 'qa_og_public';
38+
const PRIVATE_OBJECT = 'qa_og_private';
39+
type Posture = 'public' | 'private';
40+
const OBJECT_FOR: Record<Posture, string> = { public: PUBLIC_OBJECT, private: PRIVATE_OBJECT };
41+
42+
const CRUD = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true };
43+
const SUPER = { ...CRUD, viewAllRecords: true, modifyAllRecords: true };
44+
45+
/** The sets of the table, named as the Studio pin names them. */
46+
const SETS: Record<string, Record<string, Record<string, boolean>>> = {
47+
wildcard: { '*': CRUD },
48+
superWildcard: { '*': SUPER },
49+
wildcardAndReadOnlyEntry: { '*': CRUD, [PUBLIC_OBJECT]: { allowRead: true }, [PRIVATE_OBJECT]: { allowRead: true } },
50+
wildcardAndEmptyEntry: { '*': CRUD, [PUBLIC_OBJECT]: {}, [PRIVATE_OBJECT]: {} },
51+
noWildcard: { qa_og_other: CRUD },
52+
};
53+
54+
/**
55+
* May a holder of this set ALONE read / edit a record of the object? Studio's
56+
* matrix draws exactly this (`RUNTIME` in `permission-slice.objectGrant-27010.test.ts`).
57+
*/
58+
const VERDICT: Record<string, Record<Posture, { read: boolean; edit: boolean }>> = {
59+
wildcard: { public: { read: true, edit: true }, private: { read: false, edit: false } },
60+
superWildcard: { public: { read: true, edit: true }, private: { read: true, edit: true } },
61+
wildcardAndReadOnlyEntry: { public: { read: true, edit: false }, private: { read: true, edit: false } },
62+
wildcardAndEmptyEntry: { public: { read: false, edit: false }, private: { read: false, edit: false } },
63+
noWildcard: { public: { read: false, edit: false }, private: { read: false, edit: false } },
64+
};
65+
66+
const setName = (shape: string) => `qa_og_${shape.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`)}`;
67+
const callerHolding = (shape: string) => ({
68+
userId: 'usr_og_member',
69+
positions: [],
70+
permissions: [setName(shape)],
71+
posture: 'MEMBER',
72+
});
73+
74+
interface Refusal { code?: string; status?: number }
75+
type Outcome = { kind: 'landed'; value?: unknown } | ({ kind: 'refused' } & Refusal);
76+
77+
function outcome(p: Promise<unknown>): Promise<Outcome> {
78+
return p.then(
79+
(value) => ({ kind: 'landed' as const, value }),
80+
(e: any) => ({ kind: 'refused' as const, code: e?.code, status: e?.statusCode ?? e?.status }),
81+
);
82+
}
83+
84+
const objectFields = {
85+
id: { name: 'id', type: 'text', primaryKey: true },
86+
name: { name: 'name', type: 'text' },
87+
};
88+
89+
let engine: ObjectQL;
90+
91+
beforeAll(async () => {
92+
engine = new ObjectQL();
93+
engine.registerDriver(
94+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never,
95+
true,
96+
);
97+
await engine.init();
98+
engine.registerApp({
99+
id: 'com.objectstack.qa.object-grant-wildcard',
100+
name: "Object grant: a set's own entry, else its '*' entry where it applies",
101+
version: '1.0.0',
102+
type: 'plugin',
103+
scope: 'system',
104+
objects: [
105+
{ name: PUBLIC_OBJECT, label: 'Public object', sharingModel: 'public_read_write', fields: objectFields },
106+
{
107+
name: PRIVATE_OBJECT,
108+
label: 'Private object',
109+
sharingModel: 'public_read_write',
110+
access: { default: 'private' },
111+
fields: objectFields,
112+
},
113+
],
114+
} as never);
115+
await engine.syncSchemas();
116+
await installDefaultOrganization(engine);
117+
for (const object of [PUBLIC_OBJECT, PRIVATE_OBJECT]) {
118+
await engine.insert(object, [{ id: 'r1', name: 'row' }], SYS);
119+
}
120+
121+
const sets = Object.entries(SETS).map(([shape, objects]) =>
122+
PermissionSetSchema.parse({ name: setName(shape), objects }),
123+
);
124+
const services: Record<string, unknown> = {
125+
manifest: { register: vi.fn() },
126+
objectql: engine,
127+
metadata: {
128+
get: async (_type: string, name: string) => engine.getSchema(name) ?? null,
129+
list: async () => [MEMBER_DEFAULT, ...sets],
130+
},
131+
};
132+
const ctx = {
133+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
134+
registerService: vi.fn(),
135+
hook: () => undefined,
136+
getService: (name: string) => {
137+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
138+
return services[name];
139+
},
140+
};
141+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
142+
await plugin.init(ctx as never);
143+
await plugin.start(ctx as never);
144+
});
145+
146+
afterAll(async () => {
147+
try { await engine.destroy(); } catch { /* noop */ }
148+
});
149+
150+
const stored = async (object: string) =>
151+
((await engine.findOne(object, { where: { id: 'r1' }, context: { isSystem: true } } as never)) ?? null) as
152+
| Record<string, unknown>
153+
| null;
154+
155+
describe("a set applies its own object entry, else its '*' entry where it applies (ADR-0066 D2)", () => {
156+
for (const [shape, byPosture] of Object.entries(VERDICT)) {
157+
for (const posture of ['public', 'private'] as const) {
158+
const object = OBJECT_FOR[posture];
159+
const want = byPosture[posture];
160+
161+
it(`${shape} × ${posture} object: a read is ${want.read ? 'served' : 'refused'}`, async () => {
162+
const got = await outcome(
163+
engine.findOne(object, { where: { id: 'r1' }, context: callerHolding(shape) } as never),
164+
);
165+
if (want.read) {
166+
expect(got).toMatchObject({ kind: 'landed', value: { id: 'r1' } });
167+
return;
168+
}
169+
expect(got).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 });
170+
});
171+
172+
it(`${shape} × ${posture} object: an update is ${want.edit ? 'written' : 'refused'}`, async () => {
173+
const value = `${shape}-${posture}`;
174+
const got = await outcome(
175+
engine.update(object, { name: value }, { where: { id: 'r1' }, context: callerHolding(shape) } as never),
176+
);
177+
if (want.edit) {
178+
expect(got).toMatchObject({ kind: 'landed' });
179+
expect((await stored(object))?.name).toBe(value);
180+
return;
181+
}
182+
expect(got).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 });
183+
// Refused means untouched.
184+
expect((await stored(object))?.name).not.toBe(value);
185+
});
186+
}
187+
}
188+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27010 — the delegated-admin companion grants read a companion
5+
* object's grant as the runtime resolves it: the set's own entry, else its
6+
* `'*'` entry where that applies to the object (`objectRuleCell`, the matrix's
7+
* reading). An object the `'*'` entry covers is marked granted on the bits that
8+
* entry carries, and the one-click grant starts a missing object's entry from
9+
* the drawn one, so it only ever turns flags on.
10+
*/
11+
12+
import * as React from 'react';
13+
import { afterEach, describe, expect, it } from 'vitest';
14+
import { cleanup, render, screen } from '@testing-library/react';
15+
import userEvent from '@testing-library/user-event';
16+
import { PermissionAdvancedFacets, type ObjectAccessDefaultOf } from './PermissionAdvancedFacets';
17+
18+
afterEach(cleanup);
19+
20+
const t = (k: string) => k;
21+
type Draft = Record<string, unknown>;
22+
let latest: Draft = {};
23+
24+
function Harness({ initial, accessDefaultOf }: { initial: Draft; accessDefaultOf?: ObjectAccessDefaultOf }) {
25+
const [draft, setDraft] = React.useState<Draft>(initial);
26+
React.useEffect(() => {
27+
latest = draft;
28+
}, [draft]);
29+
return (
30+
<PermissionAdvancedFacets
31+
draft={draft}
32+
setDraft={setDraft as never}
33+
writable
34+
allSetNames={[]}
35+
t={t}
36+
only="admin"
37+
objectAccessDefault={accessDefaultOf}
38+
/>
39+
);
40+
}
41+
42+
const ANCHORED = { businessUnit: 'bu_field_ops', manageAssignments: true };
43+
const CRUD = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true };
44+
const SUPER = { ...CRUD, viewAllRecords: true, modifyAllRecords: true };
45+
const OBJECT_COMPANIONS = ['sys_user', 'sys_business_unit', 'sys_business_unit_member', 'sys_user_position'];
46+
47+
const row = (id: string) => screen.getByTestId(`admin-companion-${id}`);
48+
49+
describe('companion grants read the `*` entry as the runtime does (objectstack#27010)', () => {
50+
it('a `*` entry carrying the flags marks each companion object granted', async () => {
51+
latest = {};
52+
render(<Harness initial={{ name: 'delegate', objects: { '*': SUPER }, adminScope: ANCHORED }} />);
53+
for (const id of OBJECT_COMPANIONS) expect(row(id).getAttribute('data-granted'), id).toBe('true');
54+
expect(row('setup.access').getAttribute('data-granted')).toBe('false');
55+
});
56+
57+
it('the one-click grant then adds only the capability and leaves the objects to the `*` entry', async () => {
58+
const user = userEvent.setup();
59+
render(<Harness initial={{ name: 'delegate', objects: { '*': SUPER }, adminScope: ANCHORED }} />);
60+
await user.click(screen.getByTestId('admin-companions-grant'));
61+
expect(latest.systemPermissions).toEqual(['setup.access']);
62+
expect(latest.objects).toEqual({ '*': SUPER });
63+
});
64+
65+
it('a read-only `*` entry: the CRUD companion starts from it and only turns flags on', async () => {
66+
const user = userEvent.setup();
67+
render(<Harness initial={{ name: 'delegate', objects: { '*': { allowRead: true, allowExport: true } }, adminScope: ANCHORED }} />);
68+
expect(row('sys_user').getAttribute('data-granted')).toBe('true');
69+
expect(row('sys_user_position').getAttribute('data-granted')).toBe('false');
70+
await user.click(screen.getByTestId('admin-companions-grant'));
71+
expect(latest.objects).toEqual({
72+
'*': { allowRead: true, allowExport: true },
73+
sys_user_position: { allowRead: true, allowExport: true, allowCreate: true, allowEdit: true, allowDelete: true },
74+
});
75+
});
76+
77+
it('a private companion object is not covered by a plain `*` entry', async () => {
78+
const user = userEvent.setup();
79+
const accessDefaultOf: ObjectAccessDefaultOf = (name) => (name === 'sys_user' ? 'private' : 'public');
80+
render(
81+
<Harness
82+
initial={{ name: 'delegate', objects: { '*': CRUD }, adminScope: ANCHORED }}
83+
accessDefaultOf={accessDefaultOf}
84+
/>,
85+
);
86+
expect(row('sys_user').getAttribute('data-granted')).toBe('false');
87+
expect(row('sys_business_unit').getAttribute('data-granted')).toBe('true');
88+
await user.click(screen.getByTestId('admin-companions-grant'));
89+
expect((latest.objects as Record<string, unknown>).sys_user).toEqual({ allowRead: true });
90+
});
91+
});

0 commit comments

Comments
 (0)