Skip to content

Commit 99db937

Browse files
committed
fix(security): project only spec-declared keys into permission-set metadata, and make a failed backfill loud (#4669)
`sys_permission_set` carries an `active` STORAGE COLUMN — the on/off switch the Setup list views filter on and the two lifecycle actions toggle. ADR-0094 D4's boot backfill turned a whole row into a metadata body, so `active` went with it; #4001 then sealed `PermissionSetSchema` `.strict()`, and every backfill began failing with `[invalid_metadata] … Unrecognized key(s) on this permission set: 'active'`. The failure was caught into one `warn` and no counter moved, so a 100%-failing projection path stayed green for a release. `active` is row state, not a declaration — its entire consumer surface (column, highlightFields, list-view filters, the actions' `bodyExtra`) is the record's runtime switch, never a capability boundary an author declared. So the fix is on the PROJECTION side; `packages/spec` is untouched. - `permissionSetBodyFromRow()` / `mergeRowPatchIntoBody()` now pass through a whitelist DERIVED from `PermissionSetSchema.shape` — not a transcribed string list, which would silently drop the next key the spec grows (this defect, one layer over). Storage columns never enter a body, and a body STORED before #4001 (data at rest can still carry `active`) is filtered at the same choke point, so its data-door edits stop 422-ing. - The activate/deactivate actions keep working: a patch that touches only row state is not a definition write and passes through to the driver, so the column write keeps its ordinary engine semantics and no bogus "customization" overlay is minted on a packaged set. The projector no longer reads `active` from a body either — a stale body can no longer re-activate a set an admin just switched off. - A real backfill failure is now loud per AGENTS.md "Degradation log levels" (#4632): `error` level with the consequence and the fix, said once at the first failure, plus a new `ProjectionReconcileOutcome.backfillFailed` counter so the degradation lands in the RESULT and not only in a log line. Tests: the mock protocol now validates with the REAL `PermissionSetSchema`, exactly as `saveMetaItem` does — re-introducing the defect fails 6 of them with the issue's verbatim error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
1 parent dcb1dad commit 99db937

3 files changed

Lines changed: 490 additions & 23 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
fix(security): 让 permission-set 投影只写 spec 认的键,并把静默失败的 backfill 变响亮 (#4669)
6+
7+
ADR-0094 D4 的 permission-set backfill 在 #4001 之后 **100% 失败**`sys_permission_set`
8+
每一行都有 `active` 存储列,`permissionSetBodyFromRow()` 把整行转成 metadata body 时把它
9+
一起带上,而 #4001 已经把 `PermissionSetSchema` 封成 `.strict()` —— 于是每一次
10+
`saveMetaItem` 都抛 `[invalid_metadata] … Unrecognized key(s) on this permission set:
11+
'active'`。失败被 `catch` 成一条 `warn`、计数器不加一,所以测试全绿、没有任何自动信号:
12+
一个整条停摆的投影路径就这样过了一个发布周期。
13+
14+
**归属判定:`active` 是行状态,不是声明。** 它的全部消费面 —— 表列、`highlightFields`
15+
Setup 列表视图的过滤器、两个启停动作的 `bodyExtra: { active: … }` —— 都是记录的运行时开关,
16+
不是作者声明的能力边界。所以修法是在**投影侧挑键**,而不是把状态提升进 spec
17+
`packages/spec/**` 零改动)。
18+
19+
- `permissionSetBodyFromRow()` / `mergeRowPatchIntoBody()` 现在都经过一个**
20+
`PermissionSetSchema.shape` 派生**的键白名单(不是手抄的字符串数组 —— 手抄的话 spec 加键
21+
时这里又会静默漏,正是本 bug 的翻版)。存储列(`active`、时间戳、`managed_by` /
22+
`package_id` / `customized`)一律不进 metadata body;`#4001` 之前**已经落库**、body 里
23+
仍带着 `active` 的历史 overlay 行,也在同一个闸口被滤掉,因此它们的数据门编辑不再报 422。
24+
- 两个启停动作行为不变:只含行状态的 PATCH 不再被改写成 metadata 写入,而是原样交给驱动
25+
执行列写入(保留 history / `updated_at` / FLS 等正常语义),并且不会再给一个包自带的
26+
permission set 平白造出一条“customization” overlay。投影通道则不再从 body 读 `active` ——
27+
一次投影不会再用陈旧 body 把管理员刚停用的 set 重新打开。
28+
- backfill 真失败时按 AGENTS.md「Degradation log levels」(#4632) 变响亮:`error` 级、
29+
文案写明后果(记录照常列出、看起来一切正常,但定义不在 metadata 里,重新 provision 不会
30+
重建它)与修复动作,并新增 `ProjectionReconcileOutcome.backfillFailed` 计数,让降级出现在
31+
结果里而不只在日志里。

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

Lines changed: 245 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@
99
*/
1010

1111
import { describe, it, expect } from 'vitest';
12+
import { PermissionSetSchema } from '@objectstack/spec/security';
1213
import {
1314
permissionSetRowFields,
1415
permissionSetBodyFromRow,
16+
permissionSpecBodyKeys,
17+
pickRowStateColumns,
1518
mergeRowPatchIntoBody,
1619
recordDiffersFromBody,
1720
upsertEnvPermissionSet,
@@ -81,6 +84,21 @@ function makeProtocol(ql: any, declared: Record<string, any> = {}) {
8184
projector = fn;
8285
},
8386
async saveMetaItem(req: { type: string; name: string; item: any; actor?: string }) {
87+
// [#4669] The REAL `PermissionSetSchema`, exactly as `saveMetaItem` runs
88+
// it (metadata-protocol/src/protocol.ts → `resolveOverlaySchema`), same
89+
// `[invalid_metadata]` 422 envelope. Without this the mock accepts any
90+
// object and the suite stays green while every real backfill fails —
91+
// which is how a 100%-failing projection shipped.
92+
const parsed = PermissionSetSchema.safeParse(req.item);
93+
if (!parsed.success) {
94+
const summary = parsed.error.issues
95+
.map((i: any) => `${i.path.join('.') || '<root>'}: ${i.message}`)
96+
.join('; ');
97+
const err: any = new Error(`[invalid_metadata] permission/${req.name} failed spec validation: ${summary}`);
98+
err.code = 'INVALID_METADATA';
99+
err.status = 422;
100+
throw err;
101+
}
84102
const existing = overlayFor(req.name);
85103
if (existing) existing.metadata = JSON.stringify(req.item);
86104
else {
@@ -157,12 +175,83 @@ describe('permissionSetBodyFromRow / permissionSetRowFields (round-trip)', () =>
157175
expect(body.rowLevelSecurity[0].using).toBe('org == current_user.org');
158176
expect(body.tabPermissions).toEqual({ crm_leads: 'visible' });
159177
expect(body.adminScope.businessUnit).toBe('Sales');
160-
expect(body.active).toBe(true);
161178
// and projecting the rebuilt body changes nothing
162179
expect(recordDiffersFromBody(row, body)).toBe(false);
163180
});
164181
});
165182

183+
// ── The definition ⊆ spec contract (#4669) ─────────────────────────────────
184+
//
185+
// `sys_permission_set` carries columns the DEFINITION does not (`active`, the
186+
// timestamps, the provenance trio). Feeding them to `saveMetaItem` is what
187+
// #4001's `.strict()` schema rejects, and what took the ADR-0094 D4 backfill
188+
// to a 100% failure rate.
189+
190+
describe('row→body projection keeps ONLY spec-declared keys (#4669)', () => {
191+
const legacyRow = () => ({
192+
id: 'ps_1',
193+
name: 'organization_admin',
194+
...permissionSetRowFields(envBody()),
195+
// every storage column a real row carries…
196+
active: true,
197+
managed_by: 'admin',
198+
package_id: null,
199+
customized: false,
200+
created_at: '2026-01-01T00:00:00Z',
201+
updated_at: '2026-02-02T00:00:00Z',
202+
// …plus a column this code has never heard of
203+
some_future_column: 'whatever',
204+
});
205+
206+
it('the whitelist is DERIVED from the spec schema, not transcribed', () => {
207+
const keys = permissionSpecBodyKeys();
208+
// identical to PermissionSetSchema's own shape — the single source
209+
expect([...keys].sort()).toEqual(Object.keys((PermissionSetSchema as any).shape).sort());
210+
expect(keys.has('objects')).toBe(true);
211+
expect(keys.has('systemPermissions')).toBe(true);
212+
expect(keys.has('adminScope')).toBe(true);
213+
// `active` is a TABLE column, never a spec key — that is the whole bug
214+
expect(keys.has('active')).toBe(false);
215+
});
216+
217+
it('drops `active` and every other storage column from the projected body', () => {
218+
const body = permissionSetBodyFromRow(legacyRow());
219+
for (const col of ['active', 'managed_by', 'package_id', 'customized', 'created_at', 'updated_at', 'id', 'some_future_column']) {
220+
expect(body, `storage column '${col}' must not enter the metadata body`).not.toHaveProperty(col);
221+
}
222+
// the definition itself survives intact
223+
expect(body.objects).toEqual(envBody().objects);
224+
expect(body.systemPermissions).toEqual(envBody().systemPermissions);
225+
});
226+
227+
it('every key the projection emits is one the spec ACCEPTS (parsed by the real schema)', () => {
228+
const parsed = PermissionSetSchema.safeParse(permissionSetBodyFromRow(legacyRow()));
229+
expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true);
230+
// …and the reverse guard: a spec RENAME must fail here rather than silently
231+
// dropping the value at runtime.
232+
const keys = permissionSpecBodyKeys();
233+
for (const key of Object.keys(permissionSetBodyFromRow(legacyRow()))) {
234+
expect(keys.has(key), `body key '${key}' is not declared by PermissionSetSchema`).toBe(true);
235+
}
236+
});
237+
238+
it('filters a body STORED before #4001 (data at rest can still carry `active`)', () => {
239+
// a legacy sys_metadata overlay written while the schema still stripped it
240+
const legacyStored = { ...envBody(), active: false, _packageId: 'com.x' };
241+
const merged = mergeRowPatchIntoBody(legacyStored, { label: 'Renamed' });
242+
expect(merged).not.toHaveProperty('active');
243+
expect(merged).not.toHaveProperty('_packageId');
244+
expect(PermissionSetSchema.safeParse(merged).success).toBe(true);
245+
});
246+
247+
it('pickRowStateColumns isolates the record-state columns (normalized)', () => {
248+
expect(pickRowStateColumns({ active: 'false', label: 'x' })).toEqual({ active: false });
249+
expect(pickRowStateColumns({ active: true })).toEqual({ active: true });
250+
expect(pickRowStateColumns({ label: 'x' })).toBeNull();
251+
expect(pickRowStateColumns(null)).toBeNull();
252+
});
253+
});
254+
166255
describe('upsertEnvPermissionSet (ADR-0094 — record is a pure projection)', () => {
167256
it('CREATES a missing record (managed_by admin) — Studio-authored sets appear in Setup', async () => {
168257
const ql = makeQl();
@@ -176,16 +265,28 @@ describe('upsertEnvPermissionSet (ADR-0094 — record is a pure projection)', ()
176265
expect(JSON.parse(row.object_permissions)).toEqual(envBody().objects);
177266
});
178267

179-
it('projects all facets (and active) onto an existing env-authored row', async () => {
268+
it('projects all facets onto an existing env-authored row', async () => {
180269
const ql = makeQl();
181270
ql.permRows.push({ id: 'ps_env', name: 'organization_admin', managed_by: 'user', system_permissions: '[]', active: true });
182-
const r = await upsertEnvPermissionSet(ql, envBody({ active: false }));
271+
const r = await upsertEnvPermissionSet(ql, envBody());
183272
expect(r.updated).toBe(1);
184273
const row = ql.permRows[0];
185274
expect(row.id).toBe('ps_env'); // id stable — junction FKs stay valid
186275
expect(JSON.parse(row.system_permissions)).toEqual(['setup.access', 'manage_org_users']);
187276
expect(JSON.parse(row.admin_scope).businessUnit).toBe('Sales');
188-
expect(row.active).toBe(false);
277+
});
278+
279+
it('[#4669] NEVER re-flips `active` from a body — it is row state, not definition', async () => {
280+
// A body carrying `active` can only be legacy data at rest (pre-#4001) or a
281+
// caller mistake. Projecting it would silently undo an admin's
282+
// deactivate — the record's switch is the record's own.
283+
const ql = makeQl();
284+
ql.permRows.push({ id: 'ps_env', name: 'organization_admin', managed_by: 'user', system_permissions: '[]', active: false });
285+
await upsertEnvPermissionSet(ql, { ...envBody(), active: true } as any);
286+
expect(ql.permRows[0].active, 'a stale body must not re-activate a deactivated set').toBe(false);
287+
// …and a record the projector CREATES starts active (column default).
288+
await upsertEnvPermissionSet(ql, envBody({ name: 'fresh_set' }));
289+
expect(ql.permRows.find((r: any) => r.name === 'fresh_set')?.active).toBe(true);
189290
});
190291

191292
it('projects onto a legacy row with ABSENT provenance (platform default)', async () => {
@@ -426,15 +527,76 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () =>
426527
// metadata is the store that changed…
427528
const overlay = JSON.parse(ql.metaRows[0].metadata);
428529
expect(overlay.systemPermissions).toEqual(['setup.access']);
429-
expect(overlay.active).toBe(false);
430530
expect(overlay.objects).toEqual(envBody().objects); // unmentioned facets preserved
531+
// [#4669] …but `active` rode along as a COLUMN, never as a body key: the
532+
// definition stays spec-clean while the record's switch still flips.
533+
expect(overlay).not.toHaveProperty('active');
431534
// …and the record followed via projection
432535
expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['setup.access']);
433536
expect(ql.permRows[0].active).toBe(false);
434537
expect(ql.permRows[0].id).toBe(rowId);
435538
expect(opCtx.result?.id).toBe(rowId);
436539
});
437540

541+
it('[#4669] the activate/deactivate ACTIONS write the column and nothing else', async () => {
542+
// `sys-permission-set.object.ts` ships two `type:'api'` actions that PATCH
543+
// /data/sys_permission_set/{id} with `bodyExtra: { active: true|false }`.
544+
// A row-state-only patch is not a definition write: it passes through to
545+
// the driver, mints no overlay, and touches the metadata store not at all.
546+
const ql = makeQl();
547+
const protocol = makeProtocol(ql);
548+
registerPermissionSetProjection(protocol, { ql });
549+
await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody() });
550+
const rowId = ql.permRows[0].id;
551+
const savesBefore = protocol.saves.length;
552+
const metaRowsBefore = JSON.stringify(ql.metaRows);
553+
const mw = makeMiddleware(ql, protocol);
554+
555+
for (const active of [false, true]) {
556+
const nextCalled = await run(mw, {
557+
object: 'sys_permission_set', operation: 'update', context: userCtx,
558+
data: { id: rowId, active },
559+
});
560+
expect(nextCalled, 'the driver performs the column write, with its ordinary semantics').toBe(true);
561+
}
562+
expect(protocol.saves.length, 'no metadata write for a pure row-state patch').toBe(savesBefore);
563+
expect(JSON.stringify(ql.metaRows)).toBe(metaRowsBefore);
564+
});
565+
566+
it('[#4669] deactivating a PACKAGE-owned set mints no customization overlay', async () => {
567+
const ql = makeQl();
568+
const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] });
569+
(ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) };
570+
const protocol = makeProtocol(ql, { crm_rep: declaredBody });
571+
registerPermissionSetProjection(protocol, { ql });
572+
ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]', active: true });
573+
const mw = makeMiddleware(ql, protocol);
574+
const nextCalled = await run(mw, {
575+
object: 'sys_permission_set', operation: 'update', context: userCtx, data: { id: 'ps_pkg', active: false },
576+
});
577+
expect(nextCalled).toBe(true);
578+
expect(ql.metaRows.length, 'switching a packaged set off is not a customization of it').toBe(0);
579+
expect(ql.permRows[0].customized).toBeUndefined();
580+
});
581+
582+
it('[#4669] INSERT honours an explicit `active` on the record (Clone action sends one)', async () => {
583+
const ql = makeQl();
584+
const protocol = makeProtocol(ql);
585+
registerPermissionSetProjection(protocol, { ql });
586+
const mw = makeMiddleware(ql, protocol);
587+
const opCtx: any = {
588+
object: 'sys_permission_set', operation: 'insert', context: userCtx,
589+
data: {
590+
name: 'support_agent', label: 'Support Agent', active: false,
591+
object_permissions: JSON.stringify({ ticket: { allowRead: true } }),
592+
},
593+
};
594+
await run(mw, opCtx);
595+
expect(protocol.saves[0].item, 'the definition never carries row state').not.toHaveProperty('active');
596+
expect(ql.permRows[0].active).toBe(false);
597+
expect(opCtx.result?.active).toBe(false);
598+
});
599+
438600
it('UPDATE that renames is rejected (the name is the metadata identity)', async () => {
439601
const ql = makeQl();
440602
const protocol = makeProtocol(ql);
@@ -570,6 +732,7 @@ describe('reconcilePermissionSetProjection', () => {
570732
});
571733
const out = await reconcilePermissionSetProjection(protocol, { ql });
572734
expect(out.backfilledIntoMetadata).toBe(1);
735+
expect(out.backfillFailed).toBe(0);
573736
expect(ql.metaRows.length).toBe(1);
574737
const body = JSON.parse(ql.metaRows[0].metadata);
575738
expect(body.objects).toEqual({ ticket: { allowRead: true } });
@@ -578,6 +741,83 @@ describe('reconcilePermissionSetProjection', () => {
578741
expect(out2.backfilledIntoMetadata).toBe(0);
579742
});
580743

744+
it('[#4669] a row carrying the `active` STORAGE COLUMN backfills instead of failing spec validation', async () => {
745+
// The reported symptom: every `sys_permission_set` row has an `active`
746+
// column, `permissionSetBodyFromRow` handed it to `saveMetaItem`, and
747+
// #4001's `.strict()` schema rejected all of them — a 100%-failing
748+
// backfill behind one `warn`, with `backfilledIntoMetadata` stuck at 0.
749+
const ql = makeQl();
750+
const protocol = makeProtocol(ql); // validates with the real PermissionSetSchema
751+
ql.permRows.push({
752+
id: 'ps_d8', name: 'd8_qc_user', managed_by: 'admin',
753+
active: true, customized: false, package_id: null,
754+
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-02T00:00:00Z',
755+
label: 'D8 QC User', ...permissionSetRowFields(envBody({ name: 'd8_qc_user' })),
756+
});
757+
const logs: Array<{ level: string; msg: string }> = [];
758+
const logger = {
759+
info: (m: string) => logs.push({ level: 'info', msg: m }),
760+
warn: (m: string) => logs.push({ level: 'warn', msg: m }),
761+
error: (m: string) => logs.push({ level: 'error', msg: m }),
762+
};
763+
const out = await reconcilePermissionSetProjection(protocol, { ql, logger });
764+
expect(out.backfilledIntoMetadata).toBe(1);
765+
expect(out.backfillFailed).toBe(0);
766+
expect(logs.some((l) => /backfill into metadata failed|FAILED/i.test(l.msg))).toBe(false);
767+
const stored = JSON.parse(ql.metaRows[0].metadata);
768+
expect(stored).not.toHaveProperty('active');
769+
expect(stored.name).toBe('d8_qc_user');
770+
});
771+
772+
it('[#4669/#4632] a REAL backfill failure is loud: error level, counted, consequence + fix', async () => {
773+
const ql = makeQl();
774+
const protocol = makeProtocol(ql);
775+
// Not a key problem — the stored facet JSON itself is off-contract, so no
776+
// amount of key-filtering saves it. This is the case that MUST shout.
777+
ql.permRows.push({
778+
id: 'ps_bad', name: 'broken_set', managed_by: 'admin', active: true,
779+
label: 'Broken Set', object_permissions: JSON.stringify({ ticket: { allowRead: 'yes-please' } }),
780+
});
781+
ql.permRows.push({
782+
id: 'ps_bad2', name: 'broken_set_2', managed_by: 'admin', active: true,
783+
label: 'Broken Set 2', object_permissions: JSON.stringify({ ticket: { nonsense: true } }),
784+
});
785+
// `error` follows the platform Logger contract: (message, error?, meta?).
786+
const logs: Array<{ level: string; msg: string; meta?: any; cause?: Error }> = [];
787+
const logger = {
788+
info: (m: string, meta?: any) => logs.push({ level: 'info', msg: m, meta }),
789+
warn: (m: string, meta?: any) => logs.push({ level: 'warn', msg: m, meta }),
790+
error: (m: string, cause?: Error, meta?: any) => logs.push({ level: 'error', msg: m, cause, meta }),
791+
};
792+
const out = await reconcilePermissionSetProjection(protocol, { ql, logger });
793+
794+
// counted in the RESULT — not only in a log line nobody reads
795+
expect(out.backfillFailed).toBe(2);
796+
expect(out.backfilledIntoMetadata).toBe(0);
797+
expect(ql.metaRows.length).toBe(0);
798+
799+
// level: error, never warn/info for a durability degradation
800+
const errors = logs.filter((l) => l.level === 'error');
801+
expect(errors.length).toBeGreaterThan(0);
802+
expect(logs.some((l) => l.level === 'warn' && /backfill/i.test(l.msg))).toBe(false);
803+
// said ONCE, at the first failure — not once per failed write
804+
const firstFailure = errors[0]!;
805+
expect(errors.filter((l) => /backfill into metadata FAILED/.test(l.msg)).length).toBe(1);
806+
// the consequence…
807+
expect(firstFailure.msg).toMatch(/Nothing will look broken/);
808+
expect(firstFailure.msg).toMatch(/re-provision/);
809+
// …and the fix
810+
expect(firstFailure.msg).toMatch(/Fix:/);
811+
expect(firstFailure.meta?.name).toBe('broken_set');
812+
813+
// the summary carries the failure too — an `info` "reconciled" line over a
814+
// failed backfill is the reassuring half-truth the rule exists to remove
815+
const summary = errors.at(-1)!;
816+
expect(summary.msg).toMatch(/2 FAILED backfill/);
817+
expect(summary.meta?.failedNames).toEqual(['broken_set', 'broken_set_2']);
818+
expect(logs.some((l) => l.level === 'info' && /reconciled/.test(l.msg))).toBe(false);
819+
});
820+
581821
it('heals a record that drifted from an EXISTING metadata definition (metadata wins)', async () => {
582822
const ql = makeQl();
583823
const declared = { member_default: envBody({ name: 'member_default', systemPermissions: ['declared.baseline'] }) };

0 commit comments

Comments
 (0)