Skip to content

Commit 8fa738f

Browse files
fix(metadata-protocol): duplicating a package copies a permission set that grants on an optional plugin's object not installed here (#30414)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg3tbthgMt3StL2erC7575Em)_ domain:server#2 · os-bill · session_01Utp6CYSw4nSVJDr6XpcWLJ Fixes #30319 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 复制包(`duplicatePackage`)时,复制出的权限集把源权限集在库中存储的授权对象名作为 `inheritedGrantObjects` 交给保存门,与 #30255 的权限集克隆门同一条规则。没装 plugin-audit 的部署里,授权 `sys_comment` 的权限集现在能随包一起复制;非托管导入按席位裁定 A 保持拒收,未改。 - **为什么改:** 修 #30310 时只改了克隆门,复制包这扇门仍把这类权限集放进 `failed[]`,整次复制回 `success: false`,用户复制一个正常的包却丢了权限集。 - **风险与回滚:** 只放行源行本来就持有的授权键;被命名空间改写过的键(源包自己的对象改名为副本的对象)不算继承,仍照常判;键只从库中的源行取,不读请求。非托管导入的包是请求体,不是存储行,所以仍逐条判(裁定 A)。回滚即撤 PR。 - **先看:** `packages/metadata-protocol/src/protocol-package-commits.ts` 的复制门 `saveMetaItem` 调用(`inheritedGrantObjects` 一行)与文件头的 `storedGrantObjectKeys`;再看 `protocol.copy-doors-inherited-grants.test.ts` 的第二个用例(改名后的授权仍拒)。 - **两分钟验证:** `pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.copy-doors-inherited-grants.test.ts`,预期 4 个用例全过。 - **需要你做的:** 无,席位落地。 ## What changes - **`duplicatePackage`** (`protocol-package-commits.ts`): each copied `permission` item is saved with `inheritedGrantObjects` = the keys of the SOURCE row's `objects` map as stored (`storedGrantObjectKeys(body)`, the raw parsed row, before conversion and rewrite). The save door's unknown-object refusal (#28990) already skips inherited keys since PR #30310 (`protocol-save.ts`, `unknownGrantObjectSaveRefusal`), so a set granting on an optional plugin's object this deployment does not install (`sys_comment` / `sys_activity`, `@objectstack/plugin-audit`) is copied instead of landing in `failed[]`. - The keys come from the stored row only; `duplicatePackage`'s request has no such field, exactly as the clone door's (triage 6651966154: "server-derived from the SOURCE set as stored, never read from request input"). - A key the copy's namespace rewrite renames (`leave_ticket` → `leave_copy_ticket`) is not the source's key, so it is judged as before: when the copied object is not there, the set is still refused, naming that grant alone. - `storedGrantObjectKeys` restates the clone door's `inheritedGrantObjectsOf` (two lines): that helper lives in `@objectstack/runtime`, which depends on this package, so it cannot be imported here. - **`importUnmanagedPackage`**: unchanged in behaviour, with a comment at its save call. By the seat's ruling on #30319 (option A), its package is the request's body, not a stored row, so its permission sets are new writes and every grant is judged; deriving inherited keys from the body would read request input and turn the refusal off for this door. - One changeset, `patch` for `@objectstack/metadata-protocol` (`Metadata-protocol: none`). No published schema moves; a save that was refused now succeeds (`Clause-②: no`). ## Measured first (on `main` at `870ebce3`, before the fix) Harness: the stateful `sys_metadata` stub of the new test file, registry registering no object (no plugin-audit), a source package `com.example.leave` holding object `leave_ticket` and set `leave_staff` granting `leave_ticket` + `sys_comment`. <details><summary>Duplicate door — refused (200, success: false, the set in failed[])</summary> ``` "success": false, "copiedCount": 1, "failedCount": 1, "copied": [{ "type": "object", "name": "leave_copy_ticket" }], "failed": [{ "type": "permission", "name": "leave_staff", "error": "permission/leave_copy_staff failed spec validation: objects.sys_comment: Permission 'leave_copy_staff' grants on object 'sys_comment' which is not defined in objects: no package registers an object of that name and none is saved here, so the grant applies to nothing. …" }] ``` </details> <details><summary>Unmanaged import — refused (422 INVALID_METADATA, rolled back whole)</summary> ``` 422 INVALID_METADATA The unmanaged install of 'com.example.crm' was rolled back: permission 'crm_rep' could not be written — permission/crm_rep failed spec validation: objects.sys_comment: … the new base 'com.example.crm-copy' was removed, so nothing of the package remains. issues: [{"path":"objects.sys_comment", "code":"custom", …}] sys_metadata rows after: [] ``` </details> ## Tests `packages/metadata-protocol/src/protocol.copy-doors-inherited-grants.test.ts`, 4 cases: 1. Duplicate: a set granting on `sys_comment` (absent) and the source's own object is copied; the copy grants `leave_copy_ticket` + `sys_comment`. 2. Duplicate: a grant the rewrite renames onto an object that did not copy (`leave_copy_ticket`, its source row unparseable) is still refused — the exact `failed[]` entry, one finding at `objects.leave_copy_ticket`, `sys_comment` not judged; nothing stored. 3. Duplicate: a grant the source holds outside its namespace on an absent object (`other_gone`, not renamed) is the source's and is copied. 4. Unmanaged import: a set granting on `sys_comment` is refused `422 INVALID_METADATA` at `objects.sys_comment` (code, status, path, first sentence) and the import is rolled back (no row, no base) — pinned as intended by ruling A. Ablations (each through `scripts/ablation-replace.mjs`, mutation proven on disk, restored to the `HEAD` blob with `git diff HEAD` empty; the test imports `./protocol.js` from source, so no `dist/` is involved): | Mutation | Expected | Observed | |---|---|---| | A. drop the `inheritedGrantObjects` argument at the duplicate door | 1, 3 red | 1, 2, 3 red (2 gains the `sys_comment` finding) | | B. derive inherited keys from the REWRITTEN copy instead of the stored row | 2 red | 2 red (renamed grant passes), 1/3/4 green | | C. pass the import body's own keys as `inheritedGrantObjects` at the import door | 4 red | 4 red (import resolves, no 422) | A first attempt at C was a no-op refused by the tool (replacement contained the anchor; anchor count 1 → 1); re-run with a distinct replacement. ## Verification All on this branch; the code under test is identical from `83a51faa` to the head (later commits: changeset wording, the engine-double ledger row, a merge of `main`). - **New pins:** `pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.copy-doors-inherited-grants.test.ts src/protocol.unknown-grant-object-refusal.test.ts` on `c718acd3` (after merging `main` at `7d7babb1`): 23/23. - **Package suite:** `pnpm --filter @objectstack/metadata-protocol test` on `c718acd3`: Test Files 315 passed, 4 skipped; Tests 22410 passed, 26 skipped. `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0 (`--listFiles` includes the new test file). - **Full dogfood** (`pnpm --filter @objectstack/dogfood test` under the verify lock, closure built first, no shard): Test Files 419 passed, 3 skipped (422); Tests 3492 passed, 36 skipped (3528). Lock VERDICT `command-exit 0`, held 29m40s. - **Derived gates** (`dispatch-gates.mjs --commands`, 67 families on `4463c9f7`): 66 exit 0, 1 NOT MEASURED — `check:dual-build-cjs-loads` exit 3, PREREQUISITE NOT MET (studio, client-react and five plugin/service packages have no `dist/` in this worktree); left to CI. `--ran` reconciliation: "67 derived famil(ies) accounted for — 66 run, 1 NOT-MEASURED". `check:engine-double-contract` asked for the new test file's doubles to be recorded: `--write` added one seam row set, committed. After the merge, `node scripts/check-engine-double-contract.mjs` exit 0. - **`pnpm lint`:** exit 0 on `4463c9f7`. Main's known red (`check-liveness` `query/searchFields`, #30367 / PR #30368) is not touched by this diff. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the dispatch contract's Chinese summary). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CYSw4nSVJDr6XpcWLJ --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CYSw4nSVJDr6XpcWLJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fdd0641 commit 8fa738f

4 files changed

Lines changed: 308 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): duplicating a package copies a permission set that grants on an optional plugin's object not installed here, as the permission-set clone door does
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Now.** `duplicatePackage` saves a copied permission set with the grant keys of its SOURCE row as stored passed as `inheritedGrantObjects`, so the save door's unknown-object refusal does not judge them (#30255's rule for the clone door). A platform set granting on `sys_comment` / `sys_activity` on a deployment without `@objectstack/plugin-audit` is copied; before, it was left in `failed[]` with the 422 finding and the duplicate answered `success: false`.
11+
- **Still refused.** A grant the copy's rewrite renames onto the copy's own object (`SOURCE_NS_x` → `TARGET_NS_x`) is not the source's key and is judged as before: when that object did not copy, the set lands in `failed[]` naming that grant alone. The keys come from the stored row, never from the request.
12+
- **Unchanged, by ruling.** `importUnmanagedPackage` still refuses such a set `422 INVALID_METADATA` and rolls the import back: the package is the request's body, not a stored row, so its permission sets are new writes and every grant is judged.

‎packages/metadata-protocol/src/protocol-package-commits.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,18 @@ import { isNonCanonicalStoredType } from './protocol-stored-overlay-types.js';
6969
import type { RollbackMetaItemRequest } from './protocol-door-requests.js';
7070
import type { ProtocolHost } from './protocol-host.js';
7171

72+
/**
73+
* [#30319] The grant keys of a stored permission set's `objects` map: what a
74+
* copy of that row holds because the source does (the save request's
75+
* `inheritedGrantObjects`). The same derivation as the permission-set clone
76+
* door's `inheritedGrantObjectsOf` (`@objectstack/runtime`, which depends on
77+
* this package, so it is restated, not imported).
78+
*/
79+
function storedGrantObjectKeys(stored: unknown): string[] {
80+
const objects = stored && typeof stored === 'object' ? (stored as { objects?: unknown }).objects : undefined;
81+
return objects && typeof objects === 'object' && !Array.isArray(objects) ? Object.keys(objects) : [];
82+
}
83+
7284
export class ProtocolPackageCommitsFacet {
7385
/** The body of `ObjectStackProtocolImplementation#revertStoredPackage`; the method's docblock there is its contract. */
7486
static async revertStoredPackage(host: Pick<ProtocolHost, 'discardDraftInItsScope' | 'ensureOverlayIndex' | 'findEnvironmentRows' | 'getOverlayRepo' | 'refuseOrganizationScopedWrite' | 'rethrowUnlessMetadataStoreUnprovisioned'>, request: {
@@ -779,6 +791,15 @@ export class ProtocolPackageCommitsFacet {
779791
// duplicate-AGAIN workflow, and its `?force=true` default
780792
// would name a parameter this door does not accept.
781793
writeFace: 'package-duplicate',
794+
// [#30319] A copied permission set's grants are the SOURCE
795+
// row's, so the unknown-object refusal does not judge the
796+
// keys that row holds as stored — the clone door's rule
797+
// (#30255) for this door's copy. A platform set may grant on
798+
// an optional plugin's object this deployment does not
799+
// install (`sys_comment`, `@objectstack/plugin-audit`).
800+
// Derived from the stored row, never from the request; a key
801+
// the rewrite above renamed is not the source's, and is judged.
802+
...(singular === 'permission' ? { inheritedGrantObjects: storedGrantObjectKeys(body) } : {}),
782803
...(request.actor ? { actor: request.actor } : {}),
783804
});
784805
copied.push({ type: row.type, name: newName });
@@ -939,6 +960,9 @@ export class ProtocolPackageCommitsFacet {
939960
packageId: targetPackageId,
940961
// Server-stated, as on every face: no body reaches this field.
941962
writeFace: 'package-unmanaged-import',
963+
// [#30319] No `inheritedGrantObjects`, deliberately: the
964+
// package is the request's body, not a stored row, so its
965+
// permission sets are new writes and every grant is judged.
942966
...(actor ? { actor } : {}),
943967
});
944968
copied.push({ type: item.type, name: item.name });
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#30319] The two doors that copy a permission set into a new package, on a
5+
* deployment without an optional plugin whose object a platform set grants on
6+
* (`sys_comment`, which `@objectstack/plugin-audit` registers).
7+
*
8+
* Measured on `origin/main` (870ebce3) before this file, on the harness below:
9+
* - `duplicatePackage` answered `success: false`, the object copied and the
10+
* set in `failed[]` with the unknown-object refusal at
11+
* `objects.sys_comment` — the same 422 the permission-set clone door had
12+
* (#30255, fixed by PR #30310 through `inheritedGrantObjects`);
13+
* - `importUnmanagedPackage` answered `422 INVALID_METADATA` at
14+
* `objects.sys_comment` and rolled the whole import back.
15+
*
16+
* Pinned here:
17+
* - the DUPLICATE door copies a stored set's grants the source row holds:
18+
* the copy is saved, granting on `sys_comment`. Keys come from the stored
19+
* row, never the request. A key the copy's rewrite renames (the source's
20+
* own namespace) is not the source's, so a renamed grant on an object that
21+
* does not exist is still refused — and only that one;
22+
* - the UNMANAGED IMPORT keeps refusing, by ruling (seat 2 on #30319, option
23+
* A): the package is the request's body, not a stored row, so its sets are
24+
* new writes and every grant is judged (triage 6651966154: the inherited
25+
* keys are derived from the source set as stored, never from request input).
26+
*
27+
* Harness: a stateful `sys_metadata` stub and a registry that keeps packages
28+
* and namespaces, after `package-unmanaged-install.test.ts`. The registry
29+
* registers no object, so an object is known only when it is stored here.
30+
*/
31+
// Pays the lint root's first load at collection, never inside a clocked case.
32+
import './lint-root-preload.testkit.js';
33+
import { describe, expect, it } from 'vitest';
34+
import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
35+
import { ObjectStackProtocolImplementation } from './protocol.js';
36+
37+
type Row = Record<string, any>;
38+
39+
function matches(row: Row, where: Record<string, unknown> = {}): boolean {
40+
for (const [key, want] of Object.entries(where)) {
41+
if (want === undefined) continue;
42+
if (key === '$or') {
43+
if (!(want as Array<Record<string, unknown>>).some((c) => matches(row, c))) return false;
44+
continue;
45+
}
46+
if (key.startsWith('$')) throw new Error(`stub engine: unsupported operator ${key}`);
47+
const have = row[key] ?? null;
48+
if (want !== null && typeof want === 'object' && !Array.isArray(want)) {
49+
const ops = Object.keys(want);
50+
if (ops.length !== 1 || ops[0] !== '$in') throw new Error(`stub engine: unsupported operator ${ops.join(',')}`);
51+
if (!(want as { $in: unknown[] }).$in.includes(have)) return false;
52+
continue;
53+
}
54+
if (have !== want) return false;
55+
}
56+
return true;
57+
}
58+
59+
function makeProtocol(seed: Row[] = [], seedPackages: any[] = []) {
60+
const tables = new Map<string, Row[]>([['sys_metadata', seed.map((r) => ({ ...r }))]]);
61+
const table = (name: string) => {
62+
if (!tables.has(name)) tables.set(name, []);
63+
return tables.get(name)!;
64+
};
65+
let nextId = 0;
66+
const packages = new Map<string, any>(seedPackages.map((m) => [m.id, { manifest: m, status: 'installed', enabled: true }]));
67+
const namespaces = new Map<string, Set<string>>();
68+
for (const m of seedPackages) if (m.namespace) namespaces.set(m.namespace, new Set([m.id]));
69+
const engine: any = {
70+
async syncObjectSchema() { return true; },
71+
async dropObjectSchema() { return true; },
72+
async find(t: string, q?: { where?: Record<string, unknown>; limit?: number }) {
73+
const rows = table(t).filter((r) => matches(r, q?.where));
74+
return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows;
75+
},
76+
async findOne(t: string, q: { where: Record<string, unknown> }) {
77+
assertEngineFindOnePredicate(t, q);
78+
return table(t).find((r) => matches(r, q.where)) ?? null;
79+
},
80+
async insert(t: string, data: Row) {
81+
nextId += 1;
82+
const row = { id: `r_${nextId}`, ...data };
83+
table(t).push(row);
84+
return { ...row };
85+
},
86+
async update(t: string, data: Row, q?: Record<string, unknown>) {
87+
assertEngineUpdateDispatch(data, q);
88+
const where = (q as { where?: Record<string, unknown> } | undefined)?.where;
89+
const hit = table(t).filter((r) => matches(r, where));
90+
for (const r of hit) Object.assign(r, data);
91+
return { id: hit[0]?.id ?? null };
92+
},
93+
async delete(t: string, q?: Record<string, unknown>) {
94+
assertEngineDeleteDispatch(q);
95+
const where = (q as { where?: Record<string, unknown> } | undefined)?.where;
96+
const keep = table(t).filter((r) => !matches(r, where));
97+
const deleted = table(t).length - keep.length;
98+
tables.set(t, keep);
99+
return { deleted };
100+
},
101+
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
102+
return cb(undefined, { owned: true });
103+
},
104+
registry: {
105+
registerItem: () => {},
106+
registerObject: () => {},
107+
unregisterItem: () => {},
108+
listItems: () => [],
109+
getItem: () => undefined,
110+
getAllObjects: () => [],
111+
// No plugin-audit: `sys_comment` is registered by nothing here.
112+
getObject: () => undefined,
113+
getArtifactItem: () => undefined,
114+
isPackageDisabled: () => false,
115+
getPackage: (id: string) => packages.get(id),
116+
installPackage: (manifest: any) => {
117+
const pkg = { manifest, status: 'installed', enabled: true };
118+
packages.set(manifest.id, pkg);
119+
if (manifest.namespace) {
120+
if (!namespaces.has(manifest.namespace)) namespaces.set(manifest.namespace, new Set());
121+
namespaces.get(manifest.namespace)!.add(manifest.id);
122+
}
123+
return pkg;
124+
},
125+
uninstallPackage: (id: string) => {
126+
const pkg = packages.get(id);
127+
packages.delete(id);
128+
if (pkg?.manifest?.namespace) namespaces.get(pkg.manifest.namespace)?.delete(id);
129+
},
130+
unregisterNamespace: () => {},
131+
getNamespaceOwners: (ns: string) => [...(namespaces.get(ns) ?? [])],
132+
},
133+
};
134+
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any;
135+
return { protocol, table, packages };
136+
}
137+
138+
const SOURCE = 'com.example.leave';
139+
const TARGET = 'com.example.leave-copy';
140+
const SOURCE_MANIFEST = { id: SOURCE, name: 'Leave', type: 'app', namespace: 'leave', version: '1.0.0' };
141+
142+
const ticket = {
143+
name: 'leave_ticket', label: 'Ticket', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } },
144+
};
145+
/** A schema-valid permission-set body granting read on each named object. */
146+
const set = (name: string, objectNames: string[]) => ({
147+
name,
148+
label: 'Leave staff',
149+
objects: Object.fromEntries(objectNames.map((o) => [o, { allowRead: true }])),
150+
});
151+
/** A row of the source package as it sits at rest. */
152+
const sourceRow = (type: string, body: { name: string }) => ({
153+
type, name: body.name, organization_id: null, package_id: SOURCE, state: 'active', metadata: JSON.stringify(body),
154+
});
155+
156+
/** The finding's first sentence, spelled out (`unknownGrantObjectSaveRefusal`). */
157+
const unknownFinding = (setName: string, objectName: string) =>
158+
`Permission '${setName}' grants on object '${objectName}' which is not defined in objects: no package registers `
159+
+ 'an object of that name and none is saved here, so the grant applies to nothing.';
160+
161+
/** The stored grant keys of one permission set of the target package. */
162+
const copiedGrants = (rows: Row[], name: string) =>
163+
rows.filter((r) => r.type === 'permission' && r.name === name && r.package_id === TARGET)
164+
.map((r) => Object.keys(JSON.parse(r.metadata).objects ?? {}));
165+
166+
describe('[#30319] duplicatePackage — a copied permission set keeps the grants its source row holds', () => {
167+
it('a set granting on an optional plugin\'s object not installed here is copied, the grant with it', async () => {
168+
const { protocol, table } = makeProtocol(
169+
[sourceRow('object', ticket), sourceRow('permission', set('leave_staff', ['leave_ticket', 'sys_comment']))],
170+
[SOURCE_MANIFEST],
171+
);
172+
173+
const res = await protocol.duplicatePackage({ sourcePackageId: SOURCE, targetPackageId: TARGET });
174+
175+
expect(res.failed).toEqual([]);
176+
expect(res.success).toBe(true);
177+
expect(res.copied).toEqual([
178+
{ type: 'object', name: 'leave_copy_ticket' },
179+
{ type: 'permission', name: 'leave_copy_staff' },
180+
]);
181+
// The source's own object is rewritten to the copy's; the platform grant is kept.
182+
expect(copiedGrants(table('sys_metadata'), 'leave_copy_staff')).toEqual([['leave_copy_ticket', 'sys_comment']]);
183+
});
184+
185+
it('a grant the copy renames onto an object that does not exist is still refused — that grant alone', async () => {
186+
// The copy's rewrite renames a grant on the source's own object to the
187+
// copy's (`leave_ticket` → `leave_copy_ticket`): a key the source never
188+
// held. Here that object's row does not copy (unparseable), so the
189+
// renamed grant names nothing and is judged; `sys_comment` is not.
190+
const { protocol, table } = makeProtocol(
191+
[
192+
{ ...sourceRow('object', ticket), metadata: '{not json' },
193+
sourceRow('permission', set('leave_staff', ['leave_ticket', 'sys_comment'])),
194+
],
195+
[SOURCE_MANIFEST],
196+
);
197+
198+
const res = await protocol.duplicatePackage({ sourcePackageId: SOURCE, targetPackageId: TARGET });
199+
200+
expect(res.success).toBe(false);
201+
expect(res.copied).toEqual([]);
202+
expect(res.failed).toEqual([
203+
{ type: 'object', name: 'leave_ticket', error: 'unparseable metadata' },
204+
{
205+
type: 'permission',
206+
name: 'leave_staff',
207+
error: 'permission/leave_copy_staff failed spec validation: objects.leave_copy_ticket: '
208+
+ `${unknownFinding('leave_copy_staff', 'leave_copy_ticket')} `
209+
+ "Correct the name to the object's full name (e.g. 'crm_lead', not 'lead'), create the object first, or remove the grant.",
210+
},
211+
]);
212+
expect(copiedGrants(table('sys_metadata'), 'leave_copy_staff')).toEqual([]);
213+
});
214+
215+
it('a grant the source holds on an object outside its namespace that does not exist is the source\'s, and is copied', async () => {
216+
// Not renamed (no object row of the source carries it), so it is the
217+
// source's grant as stored — the clone door's rule for a copy.
218+
const { protocol, table } = makeProtocol(
219+
[sourceRow('object', ticket), sourceRow('permission', set('leave_staff', ['leave_ticket', 'other_gone']))],
220+
[SOURCE_MANIFEST],
221+
);
222+
223+
const res = await protocol.duplicatePackage({ sourcePackageId: SOURCE, targetPackageId: TARGET });
224+
225+
expect(res.failed).toEqual([]);
226+
expect(copiedGrants(table('sys_metadata'), 'leave_copy_staff')).toEqual([['leave_copy_ticket', 'other_gone']]);
227+
});
228+
});
229+
230+
describe('[#30319] importUnmanagedPackage — the package is the request\'s body, so every grant is judged', () => {
231+
it('a set granting on an object not installed here is refused 422 at `objects.<name>`, and the import is rolled back', async () => {
232+
// By ruling (seat 2 on #30319, option A): no `inheritedGrantObjects`
233+
// here. Deriving them from the body would be reading request input,
234+
// which turns the unknown-object refusal off for this door.
235+
const { protocol, table, packages } = makeProtocol();
236+
237+
let err: any;
238+
try {
239+
await protocol.importUnmanagedPackage({
240+
manifest: {
241+
id: 'com.example.crm', namespace: 'crm', version: '4.0.0', type: 'app', name: 'CRM',
242+
objects: [{ name: 'crm_lead', label: 'Lead', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }],
243+
permissions: [set('crm_rep', ['crm_lead', 'sys_comment'])],
244+
},
245+
});
246+
} catch (e) {
247+
err = e;
248+
}
249+
250+
expect({ code: err?.code, status: err?.status }, String(err?.message)).toEqual({ code: 'INVALID_METADATA', status: 422 });
251+
expect(err.issues.map((i: any) => i.path)).toEqual(['objects.sys_comment']);
252+
expect(String(err.issues[0].message).startsWith(unknownFinding('crm_rep', 'sys_comment')), err.issues[0].message).toBe(true);
253+
// Rolled back whole: no row, no base.
254+
expect(table('sys_metadata')).toEqual([]);
255+
expect(packages.has('com.example.crm-copy')).toBe(false);
256+
});
257+
});

‎scripts/engine-double-contract.pinned.json‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,21 @@
611611
"verb": "update",
612612
"pinned": 1
613613
},
614+
{
615+
"file": "packages/metadata-protocol/src/protocol.copy-doors-inherited-grants.test.ts",
616+
"verb": "delete",
617+
"pinned": 1
618+
},
619+
{
620+
"file": "packages/metadata-protocol/src/protocol.copy-doors-inherited-grants.test.ts",
621+
"verb": "findOne",
622+
"pinned": 1
623+
},
624+
{
625+
"file": "packages/metadata-protocol/src/protocol.copy-doors-inherited-grants.test.ts",
626+
"verb": "update",
627+
"pinned": 1
628+
},
614629
{
615630
"file": "packages/metadata-protocol/src/protocol.credential-channel.test.ts",
616631
"verb": "delete",

0 commit comments

Comments
 (0)