Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/rbac-objects-bulk-primitive.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
});
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
});
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
});
Loading