Skip to content

Commit db48ad5

Browse files
authored
fix(security,approvals,metadata-core): 补上派生契约漏掉的 8 个对象的 bulk 原语 (#3026) (#3745)
#3391 P1 把 bulk 门禁改成 `bulk ∧ derived(child)`,配套的「补 bulk 原语」只在 platform-objects 包内做了。plugin-security / plugin-approvals / metadata-core 里同样样板白名单的 8 个对象没被扫到,导致 /batch 与 createMany/updateMany/deleteMany 返回 405,而单条写完全开放。 这 8 个都不是刻意收紧:6 个 managedBy:'config'/'system' 带 userActions 全开,D3 调和一个动词都不剥;sys_view_definition 无 managedBy。bulk 不新增权限,只是已开放动词的批量形态,每行仍过同一套行级/字段级检查。 保留显式白名单而非整行删除:reconcileManagedApiMethods 在 apiMethods 非数组时早退,删掉会关掉 7 个 managedBy 对象的 managed-write 兜底。 测试先红后绿,三包 994 条测试全过。
1 parent 4727eb8 commit db48ad5

12 files changed

Lines changed: 221 additions & 8 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
"@objectstack/plugin-approvals": patch
4+
"@objectstack/metadata-core": patch
5+
---
6+
7+
fix(security,approvals,metadata-core): restore batch routes on the eight objects the #3391 P1 companion fix missed (#3026)
8+
9+
The #3391 P1 contract made the bulk gate `bulk ∧ derived(child)`: a batch
10+
request is admitted only when the object grants the `bulk` **primitive** and the
11+
batched child operation is itself allowed. Before that, the `*Many` routes
12+
checked only the child verb, so a boilerplate CRUD-five whitelist
13+
(`['get','list','create','update','delete']`) batched fine.
14+
15+
The companion fix — adding the `bulk` primitive wherever an explicit whitelist
16+
survived — was applied only inside `platform-objects`. Eight objects carrying
17+
the same boilerplate live in other packages and kept the gap, so `/batch`,
18+
`createMany`, `updateMany` and `deleteMany` answered `405
19+
OBJECT_API_METHOD_NOT_ALLOWED` on objects whose single-record create/update/
20+
delete were wide open. `data-objectstack` rethrows that 405 without falling back
21+
to per-row writes, which surfaced as a hard error on multi-select delete in the
22+
Setup grids.
23+
24+
Objects reclaimed (whitelist now `['get','list','create','update','delete','bulk']`):
25+
`sys_capability`, `sys_permission_set`, `sys_position`,
26+
`sys_position_permission_set`, `sys_user_permission_set`, `sys_user_position`
27+
(plugin-security); `sys_approval_delegation` (plugin-approvals);
28+
`sys_view_definition` (metadata-core).
29+
30+
No new authority is granted: `bulk` only permits batching verbs each object
31+
already exposes one record at a time, and every batched row still passes the
32+
same row- and field-level permission checks. The whitelists stay explicit rather
33+
than being deleted — seven of the eight are `managedBy`, and
34+
`reconcileManagedApiMethods` (ADR-0103 D3) early-returns on a non-array
35+
`apiMethods`, so dropping the line would silently disable the managed-write
36+
backstop.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #3026 follow-up — `sys_view_definition` must expose the BATCH shape of the
5+
* write verbs it already grants.
6+
*
7+
* Since the #3391 P1 contract made the bulk gate `bulk ∧ derived(child)`, a
8+
* boilerplate CRUD-five whitelist (`get,list,create,update,delete`) denies
9+
* `/batch`, `createMany`, `updateMany` and `deleteMany` while leaving the same
10+
* verbs open one record at a time. The companion fix that added the `bulk`
11+
* primitive to explicitly-whitelisted objects covered `platform-objects` only,
12+
* so this one — the sole object carrying a whitelist in `metadata-core` — kept
13+
* the gap.
14+
*
15+
* Unlike the RBAC objects reclaimed alongside it, this object has no
16+
* `managedBy`, so the ADR-0103 D3 reconciliation never applies and deleting the
17+
* whitelist would be behaviourally equivalent. It stays explicit on purpose: a
18+
* metadata-plane object that is ALSO reachable through the generic data API is
19+
* worth naming its exposed surface rather than inheriting whatever the
20+
* primitive set grows into.
21+
*/
22+
23+
import { describe, expect, it } from 'vitest';
24+
import { resolveEffectiveApiMethods, isApiOperationAllowed } from '@objectstack/spec/data';
25+
import { SysViewDefinitionObject } from './sys-view-definition.object.js';
26+
27+
describe('sys_view_definition — batch exposure (#3026 / #3391 P1 companion)', () => {
28+
it('grants the bulk primitive alongside its single-record write verbs', () => {
29+
expect(SysViewDefinitionObject.enable?.apiMethods).toContain('bulk');
30+
for (const verb of ['get', 'list', 'create', 'update', 'delete'] as const) {
31+
expect(SysViewDefinitionObject.enable?.apiMethods, `must keep ${verb}`).toContain(verb);
32+
}
33+
});
34+
35+
it('admits createMany / updateMany / deleteMany and /batch', () => {
36+
const eff = resolveEffectiveApiMethods(SysViewDefinitionObject.enable);
37+
expect(eff.mode).toBe('restricted');
38+
for (const child of ['create', 'update', 'delete'] as const) {
39+
expect(isApiOperationAllowed(eff, 'bulk', { bulkChild: child }), `batch ${child}`).toBe(true);
40+
}
41+
});
42+
});

packages/metadata-core/src/objects/sys-view-definition.object.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ export const SysViewDefinitionObject = ObjectSchema.create({
138138
trackHistory: true,
139139
searchable: false,
140140
apiEnabled: true,
141-
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
141+
// `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
142+
// (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
143+
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'],
142144
},
143145
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #3026 follow-up — `sys_approval_delegation` must expose the BATCH shape of
5+
* the write verbs it already grants.
6+
*
7+
* The object is `managedBy: 'system'` but opens generic writes deliberately
8+
* (`userActions: { create, edit, delete }` — an out-of-office rule is authored
9+
* by its own user through the plain data endpoint), so the ADR-0103 D3
10+
* reconciliation strips nothing and its boilerplate CRUD-five whitelist reaches
11+
* the REST gate as authored. Since the #3391 P1 contract made bulk
12+
* `bulk ∧ derived(child)`, that whitelist — which never named the `bulk`
13+
* primitive — 405s every batch route while the single-record verbs stay open.
14+
*/
15+
16+
import { describe, expect, it } from 'vitest';
17+
import { resolveEffectiveApiMethods, isApiOperationAllowed } from '@objectstack/spec/data';
18+
import { SysApprovalDelegation } from './sys-approval-delegation.object';
19+
20+
describe('sys_approval_delegation — batch exposure (#3026 / #3391 P1 companion)', () => {
21+
it('grants the bulk primitive alongside its single-record write verbs', () => {
22+
expect(SysApprovalDelegation.enable?.apiMethods).toContain('bulk');
23+
for (const verb of ['get', 'list', 'create', 'update', 'delete'] as const) {
24+
expect(SysApprovalDelegation.enable?.apiMethods, `must keep ${verb}`).toContain(verb);
25+
}
26+
});
27+
28+
it('admits createMany / updateMany / deleteMany and /batch', () => {
29+
const eff = resolveEffectiveApiMethods(SysApprovalDelegation.enable);
30+
expect(eff.mode).toBe('restricted');
31+
for (const child of ['create', 'update', 'delete'] as const) {
32+
expect(isApiOperationAllowed(eff, 'bulk', { bulkChild: child }), `batch ${child}`).toBe(true);
33+
}
34+
});
35+
36+
it('keeps the whitelist explicit so the ADR-0103 D3 backstop still runs', () => {
37+
// `reconcileManagedApiMethods` early-returns on a non-array `apiMethods`.
38+
// For a `managedBy` object, deleting the whitelist would silently disable
39+
// managed-write stripping — it is not an equivalent refactor.
40+
expect(Array.isArray(SysApprovalDelegation.enable?.apiMethods)).toBe(true);
41+
});
42+
});

packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ export const SysApprovalDelegation = ObjectSchema.create({
135135
trackHistory: true,
136136
searchable: true,
137137
apiEnabled: true,
138-
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
138+
// `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
139+
// (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
140+
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'],
139141
},
140142
});

packages/plugins/plugin-security/src/objects/sys-capability.object.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,8 @@ export const SysCapability = ObjectSchema.create({
214214
trackHistory: true,
215215
searchable: true,
216216
apiEnabled: true,
217-
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
217+
// `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
218+
// (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
219+
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'],
218220
},
219221
});

packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,8 @@ export const SysPermissionSet = ObjectSchema.create({
299299
trackHistory: true,
300300
searchable: true,
301301
apiEnabled: true,
302-
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
302+
// `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
303+
// (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
304+
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'],
303305
},
304306
});

packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ export const SysPositionPermissionSet = ObjectSchema.create({
7777
trackHistory: true,
7878
searchable: true,
7979
apiEnabled: true,
80-
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
80+
// `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
81+
// (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
82+
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'],
8183
},
8284
});

packages/plugins/plugin-security/src/objects/sys-position.object.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,8 @@ export const SysPosition = ObjectSchema.create({
275275
trackHistory: true,
276276
searchable: true,
277277
apiEnabled: true,
278-
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
278+
// `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
279+
// (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
280+
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'],
279281
},
280282
});

packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ export const SysUserPermissionSet = ObjectSchema.create({
135135
trackHistory: true,
136136
searchable: true,
137137
apiEnabled: true,
138-
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
138+
// `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
139+
// (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
140+
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'],
139141
},
140142
});

0 commit comments

Comments
 (0)