diff --git a/.changeset/rbac-objects-bulk-primitive.md b/.changeset/rbac-objects-bulk-primitive.md new file mode 100644 index 0000000000..497d0d7365 --- /dev/null +++ b/.changeset/rbac-objects-bulk-primitive.md @@ -0,0 +1,36 @@ +--- +"@objectstack/plugin-security": patch +"@objectstack/plugin-approvals": patch +"@objectstack/metadata-core": patch +--- + +fix(security,approvals,metadata-core): restore batch routes on the eight objects the #3391 P1 companion fix missed (#3026) + +The #3391 P1 contract made the bulk gate `bulk ∧ derived(child)`: a batch +request is admitted only when the object grants the `bulk` **primitive** and the +batched child operation is itself allowed. Before that, the `*Many` routes +checked only the child verb, so a boilerplate CRUD-five whitelist +(`['get','list','create','update','delete']`) batched fine. + +The companion fix — adding the `bulk` primitive wherever an explicit whitelist +survived — was applied only inside `platform-objects`. Eight objects carrying +the same boilerplate live in other packages and kept the gap, so `/batch`, +`createMany`, `updateMany` and `deleteMany` answered `405 +OBJECT_API_METHOD_NOT_ALLOWED` on objects whose single-record create/update/ +delete were wide open. `data-objectstack` rethrows that 405 without falling back +to per-row writes, which surfaced as a hard error on multi-select delete in the +Setup grids. + +Objects reclaimed (whitelist now `['get','list','create','update','delete','bulk']`): +`sys_capability`, `sys_permission_set`, `sys_position`, +`sys_position_permission_set`, `sys_user_permission_set`, `sys_user_position` +(plugin-security); `sys_approval_delegation` (plugin-approvals); +`sys_view_definition` (metadata-core). + +No new authority is granted: `bulk` only permits batching verbs each object +already exposes one record at a time, and every batched row still passes the +same row- and field-level permission checks. The whitelists stay explicit rather +than being deleted — seven of the eight are `managedBy`, and +`reconcileManagedApiMethods` (ADR-0103 D3) early-returns on a non-array +`apiMethods`, so dropping the line would silently disable the managed-write +backstop. diff --git a/packages/metadata-core/src/objects/sys-view-definition.object.test.ts b/packages/metadata-core/src/objects/sys-view-definition.object.test.ts new file mode 100644 index 0000000000..d0a0b5dfc3 --- /dev/null +++ b/packages/metadata-core/src/objects/sys-view-definition.object.test.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #3026 follow-up — `sys_view_definition` must expose the BATCH shape of the + * write verbs it already grants. + * + * Since the #3391 P1 contract made the bulk gate `bulk ∧ derived(child)`, a + * boilerplate CRUD-five whitelist (`get,list,create,update,delete`) denies + * `/batch`, `createMany`, `updateMany` and `deleteMany` while leaving the same + * verbs open one record at a time. The companion fix that added the `bulk` + * primitive to explicitly-whitelisted objects covered `platform-objects` only, + * so this one — the sole object carrying a whitelist in `metadata-core` — kept + * the gap. + * + * Unlike the RBAC objects reclaimed alongside it, this object has no + * `managedBy`, so the ADR-0103 D3 reconciliation never applies and deleting the + * whitelist would be behaviourally equivalent. It stays explicit on purpose: a + * metadata-plane object that is ALSO reachable through the generic data API is + * worth naming its exposed surface rather than inheriting whatever the + * primitive set grows into. + */ + +import { describe, expect, it } from 'vitest'; +import { resolveEffectiveApiMethods, isApiOperationAllowed } from '@objectstack/spec/data'; +import { SysViewDefinitionObject } from './sys-view-definition.object.js'; + +describe('sys_view_definition — batch exposure (#3026 / #3391 P1 companion)', () => { + it('grants the bulk primitive alongside its single-record write verbs', () => { + expect(SysViewDefinitionObject.enable?.apiMethods).toContain('bulk'); + for (const verb of ['get', 'list', 'create', 'update', 'delete'] as const) { + expect(SysViewDefinitionObject.enable?.apiMethods, `must keep ${verb}`).toContain(verb); + } + }); + + it('admits createMany / updateMany / deleteMany and /batch', () => { + const eff = resolveEffectiveApiMethods(SysViewDefinitionObject.enable); + expect(eff.mode).toBe('restricted'); + for (const child of ['create', 'update', 'delete'] as const) { + expect(isApiOperationAllowed(eff, 'bulk', { bulkChild: child }), `batch ${child}`).toBe(true); + } + }); +}); diff --git a/packages/metadata-core/src/objects/sys-view-definition.object.ts b/packages/metadata-core/src/objects/sys-view-definition.object.ts index 8884da0387..1c11b89022 100644 --- a/packages/metadata-core/src/objects/sys-view-definition.object.ts +++ b/packages/metadata-core/src/objects/sys-view-definition.object.ts @@ -138,6 +138,8 @@ export const SysViewDefinitionObject = ObjectSchema.create({ trackHistory: true, searchable: false, apiEnabled: true, - apiMethods: ['get', 'list', 'create', 'update', 'delete'], + // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child` + // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], }, }); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.test.ts new file mode 100644 index 0000000000..9a120bcdef --- /dev/null +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.test.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #3026 follow-up — `sys_approval_delegation` must expose the BATCH shape of + * the write verbs it already grants. + * + * The object is `managedBy: 'system'` but opens generic writes deliberately + * (`userActions: { create, edit, delete }` — an out-of-office rule is authored + * by its own user through the plain data endpoint), so the ADR-0103 D3 + * reconciliation strips nothing and its boilerplate CRUD-five whitelist reaches + * the REST gate as authored. Since the #3391 P1 contract made bulk + * `bulk ∧ derived(child)`, that whitelist — which never named the `bulk` + * primitive — 405s every batch route while the single-record verbs stay open. + */ + +import { describe, expect, it } from 'vitest'; +import { resolveEffectiveApiMethods, isApiOperationAllowed } from '@objectstack/spec/data'; +import { SysApprovalDelegation } from './sys-approval-delegation.object'; + +describe('sys_approval_delegation — batch exposure (#3026 / #3391 P1 companion)', () => { + it('grants the bulk primitive alongside its single-record write verbs', () => { + expect(SysApprovalDelegation.enable?.apiMethods).toContain('bulk'); + for (const verb of ['get', 'list', 'create', 'update', 'delete'] as const) { + expect(SysApprovalDelegation.enable?.apiMethods, `must keep ${verb}`).toContain(verb); + } + }); + + it('admits createMany / updateMany / deleteMany and /batch', () => { + const eff = resolveEffectiveApiMethods(SysApprovalDelegation.enable); + expect(eff.mode).toBe('restricted'); + for (const child of ['create', 'update', 'delete'] as const) { + expect(isApiOperationAllowed(eff, 'bulk', { bulkChild: child }), `batch ${child}`).toBe(true); + } + }); + + it('keeps the whitelist explicit so the ADR-0103 D3 backstop still runs', () => { + // `reconcileManagedApiMethods` early-returns on a non-array `apiMethods`. + // For a `managedBy` object, deleting the whitelist would silently disable + // managed-write stripping — it is not an equivalent refactor. + expect(Array.isArray(SysApprovalDelegation.enable?.apiMethods)).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts index e5671e4ad7..cae11ff270 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts @@ -135,6 +135,8 @@ export const SysApprovalDelegation = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, - apiMethods: ['get', 'list', 'create', 'update', 'delete'], + // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child` + // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], }, }); diff --git a/packages/plugins/plugin-security/src/objects/sys-capability.object.ts b/packages/plugins/plugin-security/src/objects/sys-capability.object.ts index 2eb54369a3..b7593d60b8 100644 --- a/packages/plugins/plugin-security/src/objects/sys-capability.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-capability.object.ts @@ -214,6 +214,8 @@ export const SysCapability = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, - apiMethods: ['get', 'list', 'create', 'update', 'delete'], + // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child` + // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], }, }); diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts index 35d5ca78ec..cff65a4348 100644 --- a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts @@ -299,6 +299,8 @@ export const SysPermissionSet = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, - apiMethods: ['get', 'list', 'create', 'update', 'delete'], + // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child` + // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], }, }); diff --git a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts index 7acdc0168d..ff06a622de 100644 --- a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts @@ -77,6 +77,8 @@ export const SysPositionPermissionSet = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, - apiMethods: ['get', 'list', 'create', 'update', 'delete'], + // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child` + // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], }, }); diff --git a/packages/plugins/plugin-security/src/objects/sys-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-position.object.ts index ec03235db8..bbfed749ff 100644 --- a/packages/plugins/plugin-security/src/objects/sys-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-position.object.ts @@ -275,6 +275,8 @@ export const SysPosition = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, - apiMethods: ['get', 'list', 'create', 'update', 'delete'], + // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child` + // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], }, }); diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index ba59732836..03278faa55 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -135,6 +135,8 @@ export const SysUserPermissionSet = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, - apiMethods: ['get', 'list', 'create', 'update', 'delete'], + // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child` + // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], }, }); diff --git a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index 3b3de009fe..cb6e130ac9 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -156,6 +156,8 @@ export const SysUserPosition = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, - apiMethods: ['get', 'list', 'create', 'update', 'delete'], + // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child` + // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], }, }); diff --git a/packages/plugins/plugin-security/src/rbac-objects-bulk-exposure.test.ts b/packages/plugins/plugin-security/src/rbac-objects-bulk-exposure.test.ts new file mode 100644 index 0000000000..891f3d143d --- /dev/null +++ b/packages/plugins/plugin-security/src/rbac-objects-bulk-exposure.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #3026 follow-up — the RBAC config objects must expose the BATCH shape of the + * write verbs they already grant. + * + * The #3391 P1 contract made the bulk gate `bulk ∧ derived(child)`: a batch + * request is admitted only when the object grants the `bulk` PRIMITIVE *and* + * the batched child operation is itself allowed. Before that, the `*Many` + * routes only checked the child verb, so a boilerplate CRUD-five whitelist + * (`get,list,create,update,delete`) batched fine. The companion fix — adding + * the `bulk` primitive to every object carrying an explicit whitelist — was + * applied only to the `platform-objects` package; these six RBAC objects live + * in `plugin-security` and were missed, leaving `/batch`, `createMany`, + * `updateMany` and `deleteMany` answering 405 on objects whose single-record + * create/update/delete are wide open. `data-objectstack` rethrows that 405 + * without falling back to per-row writes, so multi-select delete in the Setup + * grids surfaced it as a hard error. + * + * These pin the batch shape for each object. `bulk` is a legitimate PERMANENT + * primitive declaration (#3543 kept it out of the legacy-verb reclaim), and an + * explicit whitelist is deliberately retained here rather than deleted: all six + * are `managedBy` (`config` or `system`), and `reconcileManagedApiMethods` + * (ADR-0103 D3) early-returns on a non-array `apiMethods` — dropping the + * whitelist would take the managed-write backstop with it. + */ + +import { describe, expect, it } from 'vitest'; +import { resolveEffectiveApiMethods, isApiOperationAllowed } from '@objectstack/spec/data'; +import { SysCapability } from './objects/sys-capability.object'; +import { SysPermissionSet } from './objects/sys-permission-set.object'; +import { SysPosition } from './objects/sys-position.object'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object'; +import { SysUserPosition } from './objects/sys-user-position.object'; + +const RBAC_OBJECTS = [ + ['sys_capability', SysCapability], + ['sys_permission_set', SysPermissionSet], + ['sys_position', SysPosition], + ['sys_position_permission_set', SysPositionPermissionSet], + ['sys_user_permission_set', SysUserPermissionSet], + ['sys_user_position', SysUserPosition], +] as const; + +describe('RBAC config objects — batch exposure (#3026 / #3391 P1 companion)', () => { + for (const [name, obj] of RBAC_OBJECTS) { + describe(name, () => { + it('grants the bulk primitive alongside its single-record write verbs', () => { + // The gap this closes: CRUD-five without `bulk` denies every batch + // route while permitting the very same verbs one record at a time. + expect(obj.enable?.apiMethods).toContain('bulk'); + for (const verb of ['get', 'list', 'create', 'update', 'delete'] as const) { + expect(obj.enable?.apiMethods, `${name} must keep ${verb}`).toContain(verb); + } + }); + + it('admits createMany / updateMany / deleteMany and /batch', () => { + const eff = resolveEffectiveApiMethods(obj.enable); + expect(eff.mode).toBe('restricted'); + for (const child of ['create', 'update', 'delete'] as const) { + expect( + isApiOperationAllowed(eff, 'bulk', { bulkChild: child }), + `${name} batch ${child}`, + ).toBe(true); + } + }); + + it('keeps the whitelist explicit so the ADR-0103 D3 backstop still runs', () => { + // `reconcileManagedApiMethods` bails on a non-array `apiMethods`, so an + // absent whitelist silently disables managed-write stripping for these + // objects. Deleting the line is NOT an equivalent refactor here. + expect(Array.isArray(obj.enable?.apiMethods)).toBe(true); + }); + }); + } +});