Skip to content

Commit 5ecbbcd

Browse files
committed
fix(plugin-security): a data-door edit of a package-bound permission set saves into its own row's package
The write-through's update leg saved the merged body with no package, and a sys_metadata row is keyed by its package: for a set whose only row is bound to a writable runtime package, the save minted a second, package-less row. The leg now reads the edited row's binding from the metadata door's single-item read (the row's package_id, stated as _packageId) and passes it as packageId. A set with no stored row, or a package-less one, saves as before; a code-shipped set is still refused by the lock first. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6907611 commit 5ecbbcd

1 file changed

Lines changed: 46 additions & 2 deletions

File tree

‎packages/plugins/plugin-security/src/permission-set-projection.ts‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,47 @@ export function createPermissionSetWriteThrough(
10911091
}
10921092
};
10931093

1094+
/**
1095+
* [#21861] The `saveMetaItem` argument that writes an update back into the
1096+
* stored row it edits: `{ packageId }` when that row is bound to a package,
1097+
* nothing when it is not.
1098+
*
1099+
* A `sys_metadata` row is keyed `(org, type, name, package_id)`, and a save
1100+
* that names no package targets the package-less row. So an update leg that
1101+
* named none, for a set whose only row is bound to a writable runtime
1102+
* package (`PUT /meta/permission/:name?package=`), minted a second,
1103+
* package-less row carrying the edit beside the untouched package-bound one:
1104+
* two active rows for one name.
1105+
*
1106+
* The row is the one the update merges its patch into — the `overlay` layer
1107+
* of `envelope`, which {@link effectiveBodyForRow} takes as the base — and its
1108+
* binding is read from the metadata door's own single-item read, which serves
1109+
* that row by the same served-row resolution the layered read uses and states
1110+
* the row's `package_id` on it as `_packageId`. ⛔ Never from the patch, the
1111+
* projected record (the projector writes no binding onto an admin row), or a
1112+
* registry item: those are copies, and the row is the fact.
1113+
*
1114+
* No `overlay` layer means no stored row, so there is nothing to fork and the
1115+
* save stays package-less, exactly as before. Every target reaching this read
1116+
* has already passed the lock (verdict `org`), so a code-shipped set never
1117+
* gets here. A read that fails is not caught: guessing the binding would
1118+
* choose which row the save lands in.
1119+
*/
1120+
const storedRowPackageArg = async (
1121+
protocol: any,
1122+
name: string,
1123+
envelope: unknown,
1124+
): Promise<{ packageId?: string }> => {
1125+
const overlay = (envelope as { overlay?: unknown } | null | undefined)?.overlay;
1126+
if (overlay === null || overlay === undefined) return {};
1127+
// A protocol with no single-item read (minimal embeddings, unit-test stubs)
1128+
// keys no row by package either.
1129+
if (typeof protocol.getMetaItem !== 'function') return {};
1130+
const served = await protocol.getMetaItem({ type: 'permission', name });
1131+
const packageId = served?.item?._packageId;
1132+
return typeof packageId === 'string' && packageId !== '' ? { packageId } : {};
1133+
};
1134+
10941135
const projectAndFetch = async (protocol: any, name: string): Promise<any> => {
10951136
// The awaited projector inside saveMetaItem/deleteMetaItem normally did
10961137
// this already — re-running is an idempotent upsert, and covers the
@@ -1368,10 +1409,13 @@ export function createPermissionSetWriteThrough(
13681409
const rowState = pickRowStateColumns(patch);
13691410
const results: any[] = [];
13701411
for (const row of targets) {
1371-
const base = await effectiveBodyForRow(protocol, ql, row, layeredByName.get(String(row.name)));
1412+
const envelope = layeredByName.get(String(row.name));
1413+
const base = await effectiveBodyForRow(protocol, ql, row, envelope);
13721414
const body = mergeRowPatchIntoBody(base, patch);
13731415
body.name = row.name;
1374-
await protocol.saveMetaItem({ type: 'permission', name: row.name, item: body, ...actorArg });
1416+
// [#21861] Into the row the base came from — see `storedRowPackageArg`.
1417+
const packageArg = await storedRowPackageArg(protocol, String(row.name), envelope);
1418+
await protocol.saveMetaItem({ type: 'permission', name: row.name, item: body, ...packageArg, ...actorArg });
13751419
// Row state rides along on the same patch but lands on the record, not
13761420
// in the definition (the projector above never touches these columns).
13771421
if (rowState) await tryUpdate(ql, 'sys_permission_set', { id: row.id, ...rowState });

0 commit comments

Comments
 (0)