Skip to content

Commit 10e4a77

Browse files
fix(app-shell): a New similar copy in a base's .custom pair lists and counts its grants on the base's objects (#29718)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbVXAcmGAR1bhDPqdVjU9Kng)_ Fixes #29674 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Studio「权限」里,定制包(`.custom`)中的权限集现在会列出并统计它在基础包对象上的授权;「新建类似」复制出来的权限集不再显示「已授权 0 个对象」。 - 为什么改:管理员复制供应商权限集后看到一个空矩阵,会以为复制失败或重新手动授权,而服务端其实已保存了 8 个对象、3 个字段的授权。 - 风险与回滚:只改 Studio 前端的对象列表范围(定制包 = 本包 + 基础包),保存仍只写定制包的草稿,从不写基础包;普通包的权限集行为不变。回滚即还原本 PR。 - 先看:`ui/packages/app-shell/src/views/metadata-admin/permission-slice.ts`(`permissionObjectScopePackages`)、`PermissionMatrixEditor.tsx` 的对象列表读取、`StudioDesignSurface.tsx` 权限页的范围提示。 - 两分钟验证:`pnpm dev:showcase` 后对 `showcase_contributor` 调用 clone 接口,再打开 `/_console/studio/com.example.showcase/access?surface=permission%3A新名字`,应看到「已授权 8 个对象 · 3 个字段覆盖」,提示条显示「本包及 ObjectStack Showcase 的对象 · 保存为草稿」。 - 需要你做的:无,席位落地。 ## What changes - **Object scope of a pair's set.** `permissionObjectScopePackages(packageId)` (new, `permission-slice.ts`) answers `[pair, base]` for a paired `BASE.custom` package, by the spec's pairing rule (`customizationBasePackageId` from `@objectstack/spec/kernel`), and `[packageId]` for every other package. ADR-0146 D3: the pair receives the permission sets for its base, and it declares none of the base's objects. - **The package door's load** (`PermissionMatrixEditor.tsx`) lists the draft-overlaid objects of each scope package and keeps one row per object name. The slice, the counts and the save merge all read that list, so a pair's set now shows, counts and edits its grants on the base's objects. The save is unchanged: it writes the set's draft at its own package (the pair), and rows outside the scope are re-merged from a fresh read as before (ADR-0086 P0). - **How the editor learns the base:** derived from its own `packageId` with the spec's pairing rule. No new prop and no new read. - **The Studio Access note** (`StudioDesignSurface.tsx`, the access arm only): on an open set the pair adds, the chip reads "This package's and BASE_LABEL's objects · saved as draft" (zh 「本包及 BASE_LABEL 的对象 · 保存为草稿」), with a title that says why. Every other note is unchanged. Two new `@object-ui/i18n` studio keys, `engine.studio.access.bannerPair` and `engine.studio.access.bannerTitlePair`, in both locales the studio namespace has (`en`, `zh`). ## Tests - `StudioDesignSurface.accessPairCopy-29674.test.tsx` (new): the real `StudioDesignSurface`, `AccessPillar` and `PermissionMatrixEditPage` over the real metadata client and a fetch stub (base read-only, its pair writable). A copy in the pair that grants 8 base objects and 3 field overrides lists the 8 objects, draws their grants and reads "8 objects granted · 3 field overrides"; its note names the base; an edit to a base object's grant PUTs only `/meta/permission/COPY?package=PAIR` with every grant kept, and no write names the base. CONTROL: the base's own set lists the base's objects and reads no pair object list. - `PermissionMatrixEditor.pairScope-29674.test.tsx` (new): the helper (a pair, a base, an ordinary package, a pair of a pair); a pair's set lists the pair's and the base's objects once each and saves a base-object grant into the pair's draft. CONTROL: an ordinary package's set lists only its own objects, and its save keeps another package's object and field rows byte-identical. - Ablations, each through `scripts/ablation-replace.mjs` with an on-disk proof and a proven restore to HEAD: - the helper returns `[packageId]` again (scope to the pair's own objects): 6 of 9 cases red, the 3 controls green; - the note's pair branch forced off: 1 of 4 red (the note case). <details><summary>Test runs (fix at cbaf5f9 / 82fbfd0; re-run at the merged head 607c05c)</summary> - New files: Test Files 2 passed (2), Tests 9 passed (9). At 607c05c (after merging main): the new files plus 30 neighbouring suites, Test Files 32 passed (32), Tests 222 passed (222); app-shell and i18n type-check exit 0. - Neighbouring suites (every `PermissionMatrixEditor*`, `permission-slice*`, Studio access suites, `oneSurface-28572`, `zh-base-term`, plus all of `packages/i18n`): Test Files 154 passed, Tests 1837 passed, 13 skipped. - `pnpm --filter @object-ui/app-shell run type-check` and `pnpm --filter @object-ui/i18n run type-check`: exit 0. - `pnpm --dir ui run check:phantom-deps`, `check:spec-symbols`, `check:i18n-keys`, `check:i18n-drift`: exit 0. </details> ## Acceptance notes - Scope chip decision: on a pair's set it names the base by its display label; on the base's own sets, the OWD overview and the empty header it stays "This package's objects". - A rejected object-list read still lists nothing, as before this change (one `.catch` around both reads); no new degradation. ## Verification Live on `pnpm dev:showcase --fresh` (admin), the card's recipe: `POST /api/v1/security/permission-sets/showcase_contributor/clone` with `{name: qa_contrib_29674, label}` answered 200 in `com.example.showcase.custom`; `GET /api/v1/meta/permission/qa_contrib_29674?package=com.example.showcase.custom` read 8 objects and 3 fields. The Console ran from source (Vite) against that server. - Before (main's sources): header 「已授权 0 个对象 · 0 个字段覆盖」, matrix 「没有匹配的对象。」, chip 「仅本包对象 · 保存为草稿」. - After: header 「已授权 8 个对象 · 3 个字段覆盖」, the base's objects listed with their grants, chip 「本包及 ObjectStack Showcase 的对象 · 保存为草稿」 (en: "8 objects granted · 3 field overrides", "This package's and ObjectStack Showcase's objects · saved as draft"). - Edit: ticking Delete on `showcase_invoice` sent one PUT, `/api/v1/meta/permission/qa_contrib_29674?mode=draft&package=com.example.showcase.custom&surface=studio`; the pair's draft read back 8 objects, 3 fields, `showcase_invoice.allowDelete: true`; the base set `showcase_contributor` still reads `allowDelete: false`, and the base holds no drafts. - CONTROL: the base's own set `showcase_contributor` reads 8 objects · 3 field overrides, read-only, as before. Screenshots (project files `issue-29674/shots/`): `before-qa_contrib_29674-zh.png`, `after-qa_contrib_29674-zh.png`, `after-qa_contrib_29674-zh-edited.png`, `after-qa_contrib_29674-en.png`, `control-showcase_contributor-zh.png`. <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 35 derived families, all run locally, all exit 0; `--ran` reconciliation: "35 derived famil(ies) accounted for — 35 run, 0 NOT-MEASURED (a DERIVED zero)". `check:query-options-erasure` hit a 300 s per-command cap in the loop (exit 124) and passed rerun alone. `--tier`: no path-derived mandate. Changed lines 511, under the 3000 threshold. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jVCDukgxJSJ3tEvg5W)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f285338 commit 10e4a77

8 files changed

Lines changed: 508 additions & 3 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(app-shell): a permission set in a base's `.custom` pair lists, counts and edits its grants on the base's objects
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
A "New similar" copy of a read-only base's permission set lands in the base's paired `.custom` package and keeps the base set's grants, which are on the base's objects. The package door scoped the set's matrix to the objects its own package declares, and a pair declares none of the base's, so in Studio Access the copy read "0 objects granted · 0 field overrides" with an empty matrix, though the server holds every grant.
12+
13+
- A set saved in a paired `<base>.custom` package is now scoped to the base's objects as well as the pair's own (ADR-0146 D3: the pair holds the permission sets for its base). The matrix lists both, its counts match the server's read, and an edit to a base object's grant saves into the pair's draft, never the base. A set in any other package keeps its own package's objects only.
14+
- The Studio Access note on such a set names the base ("This package's and Showcase's objects · saved as draft") instead of "This package's objects". Two new `@object-ui/i18n` studio keys, `engine.studio.access.bannerPair` and `engine.studio.access.bannerTitlePair`, in `en` and `zh`.
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29674 (ADR-0146 D3) — the package door's object scope.
5+
*
6+
* A paired `<base>.custom` package holds the permission sets FOR its base, and
7+
* declares none of the base's objects, so a set saved there is scoped to the
8+
* base's objects as well as the pair's own (`permissionObjectScopePackages`).
9+
* Every other package is scoped to itself, as before (ADR-0086 P0).
10+
*
11+
* The matrix-level pins sit beside the Studio composition
12+
* (`StudioDesignSurface.accessPairCopy-29674.test.tsx`), on a fake client that
13+
* answers each package's object list.
14+
*/
15+
16+
import '@testing-library/jest-dom/vitest';
17+
import { afterEach, describe, expect, it, vi } from 'vitest';
18+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
19+
import { MemoryRouter } from 'react-router-dom';
20+
21+
import { permissionObjectScopePackages } from './permission-slice';
22+
23+
interface Server {
24+
set: Record<string, unknown>;
25+
saved: Array<Record<string, unknown>>;
26+
savedOpts: Array<Record<string, unknown> | undefined>;
27+
listed: Array<string | undefined>;
28+
}
29+
30+
/** Objects by the package that declares them. */
31+
const OBJECTS: Record<string, Array<Record<string, unknown>>> = {
32+
'com.acme.crm': [
33+
{ name: 'crm_account', label: 'Account' },
34+
{ name: 'crm_deal', label: 'Deal' },
35+
],
36+
'com.acme.crm.custom': [{ name: 'crm_custom_survey', label: 'Survey' }],
37+
'app.a': [{ name: 'a_account', label: 'A account' }],
38+
'app.b': [{ name: 'b_ledger', label: 'B ledger' }],
39+
};
40+
41+
function makeClient(server: Server): any {
42+
const client: any = {
43+
withPreviewDrafts: () => client,
44+
layered: async () => ({ effective: server.set, code: null, overlay: null, overlayScope: null }),
45+
getDraft: async () => null,
46+
list: async (type: string, opts?: { packageId?: string }) => {
47+
if (type !== 'object') return [];
48+
server.listed.push(opts?.packageId);
49+
return OBJECTS[opts?.packageId ?? ''] ?? [];
50+
},
51+
get: async () => null,
52+
save: async (_type: string, _name: string, payload: Record<string, unknown>, opts?: Record<string, unknown>) => {
53+
server.saved.push(payload);
54+
server.savedOpts.push(opts);
55+
server.set = payload;
56+
return payload;
57+
},
58+
};
59+
return client;
60+
}
61+
62+
let clientImpl: any;
63+
64+
vi.mock('./useMetadata', () => ({
65+
useMetadataClient: () => clientImpl,
66+
useMetadataTypes: () => ({
67+
loading: false,
68+
error: null,
69+
entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }],
70+
}),
71+
}));
72+
73+
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
74+
75+
import { PermissionMatrixEditPage } from './PermissionMatrixEditor';
76+
import { t } from './i18n';
77+
78+
afterEach(cleanup);
79+
80+
function serverWith(set: Record<string, unknown>): Server {
81+
return { set, saved: [], savedOpts: [], listed: [] };
82+
}
83+
84+
function renderMatrix(packageId: string) {
85+
return render(
86+
<MemoryRouter>
87+
<PermissionMatrixEditPage type="permission" name="the_set" packageId={packageId} />
88+
</MemoryRouter>,
89+
);
90+
}
91+
92+
const rowOf = (text: string) => screen.getByText(text).closest('tr')!;
93+
const readBox = (objectName: string) => screen.getByLabelText(`${objectName} ${t('perm.action.read', 'en')}`);
94+
95+
describe('permissionObjectScopePackages (objectstack#29674)', () => {
96+
it('scopes a pair to itself and its base, the pair first', () => {
97+
expect(permissionObjectScopePackages('com.acme.crm.custom')).toEqual(['com.acme.crm.custom', 'com.acme.crm']);
98+
});
99+
100+
it('scopes every other package to itself', () => {
101+
expect(permissionObjectScopePackages('com.acme.crm')).toEqual(['com.acme.crm']);
102+
expect(permissionObjectScopePackages('app.a')).toEqual(['app.a']);
103+
// A pair of a pair has no base by the spec's pairing rule.
104+
expect(permissionObjectScopePackages('com.acme.crm.custom.custom')).toEqual(['com.acme.crm.custom.custom']);
105+
});
106+
});
107+
108+
describe('PermissionMatrixEditPage — a pair’s set lists its base’s objects (objectstack#29674)', () => {
109+
const PAIR_SET = {
110+
name: 'the_set',
111+
label: 'Copy',
112+
objects: { crm_account: { allowRead: true }, crm_deal: { allowRead: true } },
113+
fields: { 'crm_deal.amount': { readable: true, editable: false } },
114+
};
115+
116+
it('lists the pair’s and the base’s objects, each once, and draws the grants it holds on the base’s', async () => {
117+
const server = serverWith(structuredClone(PAIR_SET));
118+
clientImpl = makeClient(server);
119+
renderMatrix('com.acme.crm.custom');
120+
121+
await screen.findByText('Account');
122+
expect(screen.getByText('Deal')).toBeInTheDocument();
123+
expect(screen.getByText('Survey')).toBeInTheDocument();
124+
expect(readBox('crm_account')).toBeChecked();
125+
expect(readBox('crm_deal')).toBeChecked();
126+
expect(readBox('crm_custom_survey')).not.toBeChecked();
127+
expect(new Set(server.listed)).toEqual(new Set(['com.acme.crm.custom', 'com.acme.crm']));
128+
});
129+
130+
it('a grant on a base object saves into the pair’s draft, with every row it held', async () => {
131+
const server = serverWith(structuredClone(PAIR_SET));
132+
clientImpl = makeClient(server);
133+
renderMatrix('com.acme.crm.custom');
134+
135+
await screen.findByText('Account');
136+
fireEvent.click(within(rowOf('Account')).getByLabelText(`crm_account ${t('perm.action.edit', 'en')}`));
137+
138+
await waitFor(() => expect(server.saved).toHaveLength(1), { timeout: 4000 });
139+
expect(server.savedOpts[0]).toMatchObject({ mode: 'draft', packageId: 'com.acme.crm.custom' });
140+
expect(server.saved[0]!.objects).toStrictEqual({
141+
crm_account: { allowRead: true, allowEdit: true },
142+
crm_deal: { allowRead: true },
143+
});
144+
expect(server.saved[0]!.fields).toStrictEqual(PAIR_SET.fields);
145+
});
146+
});
147+
148+
describe('CONTROL: an ordinary package’s set is scoped to its own objects (objectstack#29674)', () => {
149+
const SET = {
150+
name: 'the_set',
151+
label: 'A set',
152+
objects: { a_account: { allowRead: true }, b_ledger: { allowRead: true, allowEdit: true } },
153+
fields: { 'b_ledger.total': { readable: true, editable: false } },
154+
};
155+
156+
it('lists only its package’s objects and keeps another package’s rows byte-identical on save', async () => {
157+
const server = serverWith(structuredClone(SET));
158+
clientImpl = makeClient(server);
159+
renderMatrix('app.a');
160+
161+
await screen.findByText('A account');
162+
expect(screen.queryByText('B ledger')).toBeNull();
163+
expect(server.listed.every((p) => p === 'app.a')).toBe(true);
164+
165+
fireEvent.click(within(rowOf('A account')).getByLabelText(`a_account ${t('perm.action.edit', 'en')}`));
166+
await waitFor(() => expect(server.saved).toHaveLength(1), { timeout: 4000 });
167+
expect(server.savedOpts[0]).toMatchObject({ mode: 'draft', packageId: 'app.a' });
168+
expect(server.saved[0]!.objects).toStrictEqual({
169+
a_account: { allowRead: true, allowEdit: true },
170+
b_ledger: SET.objects.b_ledger,
171+
});
172+
expect(server.saved[0]!.fields).toStrictEqual(SET.fields);
173+
});
174+
});

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ import {
149149
type ObjectPermFlag,
150150
type FieldPerm,
151151
type PermissionSetDraft,
152+
permissionObjectScopePackages,
152153
} from './permission-slice.js';
153154
import type { ObjectAccessScope } from '@objectstack/spec/security';
154155

@@ -978,8 +979,14 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
978979
// first publish: each pending draft is served with a `_draft` mark
979980
// (the object picker's read, `useObjectCatalog`). The environment
980981
// door keeps the published list: only the package door authors drafts.
982+
// objectstack#29674 — a pair's set is scoped to its base's objects
983+
// too (`permissionObjectScopePackages`), each list read the same way.
981984
(packageId
982-
? client.withPreviewDrafts(true).list<any>('object', { packageId })
985+
? Promise.all(
986+
permissionObjectScopePackages(packageId).map((scopeId) =>
987+
client.withPreviewDrafts(true).list<any>('object', { packageId: scopeId }),
988+
),
989+
).then((lists) => lists.flat())
983990
: client.list<any>('object', {})
984991
).catch(() => []),
985992
draftReadForSave,
@@ -1038,7 +1045,9 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
10381045
...(item?._draft === true ? { draft: true } : {}),
10391046
};
10401047
})
1041-
.filter((o) => !!o.name)
1048+
// One row per object: a name two scope packages both answer keeps
1049+
// the first one's row (the set's own package lists first).
1050+
.filter((o, i, rows) => !!o.name && rows.findIndex((r) => r.name === o.name) === i)
10421051
.sort((a, b) => a.name.localeCompare(b.name));
10431052
setObjects(list);
10441053
const full: PermissionSetDraft = {

‎ui/packages/app-shell/src/views/metadata-admin/permission-slice.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import type { HierarchyScope } from '@objectstack/spec/contracts';
2222
import { objectPermissionGrants, type ObjectAccessScope } from '@objectstack/spec/security';
23+
import { customizationBasePackageId } from '@objectstack/spec/kernel';
2324

2425
export interface ObjectPerm {
2526
allowCreate?: boolean;
@@ -354,6 +355,21 @@ function asScopeSet(scope: Iterable<string>): Set<string> {
354355
return scope instanceof Set ? scope : new Set(scope);
355356
}
356357

358+
/**
359+
* objectstack#29674 (ADR-0146 D3) — the packages whose objects a set saved at
360+
* `packageId` is scoped to, in the order their rows are listed: the package
361+
* itself first. A paired `<base>.custom` package receives the permission sets
362+
* FOR its base ("New similar" copies of the base's sets, and sets created
363+
* there), and a pair declares none of the base's objects, so its sets are
364+
* scoped to the base's objects as well as its own. Scoped to the pair alone, a
365+
* copy granting 8 base objects listed none of them and read "0 objects
366+
* granted". Every other package is scoped to itself.
367+
*/
368+
export function permissionObjectScopePackages(packageId: string): string[] {
369+
const base = customizationBasePackageId(packageId);
370+
return base === null ? [packageId] : [packageId, base];
371+
}
372+
357373
/**
358374
* Narrow a permission set down to just the rows whose object is in `scope`.
359375
* Used to drive the matrix display so a package panel lists only its own

0 commit comments

Comments
 (0)