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
29 changes: 29 additions & 0 deletions .changeset/curated-capability-name-refused-at-write-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
---
'@objectstack/plugin-security': minor
---

Setup can no longer create — or rename a row to — a `sys_capability` whose `name` is in
the platform's curated set (`PLATFORM_CAPABILITY_NAMES`). **An authoring call that
previously answered 200 now refuses**: the admin-door data write (`insert`, and `update`
that renames TO a curated name) is rejected at the security middleware with
`403 PERMISSION_DENIED` and a message naming the colliding curated name. The affected
names are the curated registry's members, currently: `manage_users`, `manage_org_users`,
`manage_metadata`, `manage_platform_settings`, `setup.access`, `setup.write`,
`studio.access`, `manage_sharing`, `export_data` (maintainer ruling on #8552, applying
the "refuse a declaration the platform cannot honour" principle — such a row can never
be the curated capability, and it blocks the platform's own definition from ever
seeding).

What is NOT changed:

- creating capabilities with non-curated names stays open, in every deployment shape
(including the community NULL-organization-bucket shape with no org stamper);
- existing colliding rows are deliberately left to the operator (no adoption, no
provenance backfill — ruling options 2/3 are rejected): they can still be edited, and
renamed AWAY from the curated name (a payload repeating the row's own unchanged name
is not a rename and is not refused);
- the curated seeder's decline-and-warn behaviour on existing collisions stands; its
per-boot `blockedCurated` warning now also carries one operator-facing remediation
line (rename or remove the blocking row, then restart, and the platform definition
seeds);
- boot/system writes (`isSystem`) — the seeders and package publish — are unaffected.
Original file line numberDiff line numberDiff line change
Expand Up@@ -450,6 +450,11 @@ describe('[#8470] the curated half owns its row, not whichever row shares the na
managedBy === undefined ? 'a row carrying no managed_by value' : `managed_by='${managedBy}'`,
);
expect(warn.mock.calls[0][0]).not.toContain('does not own');
// [#8552] The ruling leaves an existing colliding row to the OPERATOR,
// so the warning that reports it carries the one line that says how to
// resolve it by hand.
expect(warn.mock.calls[0][0]).toContain('To resolve by hand');
expect(warn.mock.calls[0][0]).toContain('rename the blocking row to a name outside the curated set');
expect(warn.mock.calls[0][1]).toEqual({
name: 'manage_users',
blockingRowId: 'aaa_pre_existing',
Expand DownExpand Up@@ -487,6 +492,11 @@ describe('[#8470] the curated half owns its row, not whichever row shares the na
// …and it must NOT borrow either of the other two branches' sentences.
expect(warn.mock.calls[0][0]).not.toContain('carrying no managed_by value');
expect(warn.mock.calls[0][0]).not.toContain('left exactly as it is');
// [#8552] The remediation line instructs renaming THE BLOCKING ROW; with
// no row visible there is nothing to rename, so it must not print here —
// telling the operator to rename a row the read just failed to find would
// assert what was not seen.
expect(warn.mock.calls[0][0]).not.toContain('To resolve by hand');
expect(warn.mock.calls[0][1]).toEqual({
name: 'manage_users', blockingRowId: undefined, blockingManagedBy: null,
});
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -296,12 +296,24 @@ export async function bootstrapSystemCapabilities(
? 'a row carrying no managed_by value already holds the name, and was left exactly as it is'
: `a row with managed_by='${String(blocking.managed_by)}' already holds the name, and was ` +
'left exactly as it is';
// [#8552] The maintainer's ruling deliberately leaves an existing
// colliding row to the OPERATOR (option 1: no adoption, no backfill),
// so the warning that reports the collision owes them the one line
// that says how to resolve it by hand. Only when a blocking row was
// actually observed — telling an operator to rename a row the read
// just failed to find would be asserting what was not seen.
const remediation = blocking === undefined
? ''
: ' To resolve by hand: rename the blocking row to a name outside the curated set (or delete ' +
'it) — through Setup for an admin-authored row, or by editing and re-publishing the owning ' +
"package for a package-declared one — then restart; the seeder will then seed the platform's " +
'definition. (New Setup rows can no longer take a curated name — refused at the write door.)';
options.logger?.warn?.(
`[security] curated capability "${def.name}" has no platform row and could not be seeded. ` +
'In the platform (NULL-organization) bucket, where the declared unique key admits one row ' +
`per name: ${observation}. The platform definition is therefore missing from sys_capability ` +
'installation-wide. Grants and requiredPermissions referencing the name are unaffected — ' +
'they resolve by name, not by row.',
`they resolve by name, not by row.${remediation}`,
{ name: def.name, blockingRowId: blocking?.id, blockingManagedBy: blocking?.managed_by ?? null },
);
}
Expand Down
202 changes: 202 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2392,6 +2392,208 @@ describe('SecurityPlugin', () => {
).resolves.toBeDefined();
});
});

// ── #8552 / ADR-0066 D1 — curated capability-name refusal (create + rename) ──
// A `sys_capability` name in PLATFORM_CAPABILITY_NAMES is the platform's:
// Setup may neither CREATE a row with it nor RENAME a row TO it — refused at
// this write door with an error naming the colliding curated name, never
// answered 200. Existing colliding rows stay the operator's (ruling option
// 1): a payload repeating a row's own curated name is not a rename and
// passes, and renaming AWAY stays open. The load-bearing half of this suite
// is the POSITIVE controls — a refusal too broad passes every "it refuses"
// assertion, so non-curated authoring is pinned open in BOTH deployment
// shapes (org-scoped, and the community NULL-bucket shape with no tenant).
describe('curated capability-name write gate (#8552, sys_capability create/rename)', () => {
const adminSet: PermissionSet = {
name: 'admin_full_access', label: 'Admin',
objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, modifyAllRecords: true } },
} as any;
// Enterprise / org-scoped shape: the caller carries an active organization.
const orgCtx = { userId: 'admin1', tenantId: 'org-1', positions: [], permissions: ['admin_full_access'] };
// Community NULL-bucket shape: no `@objectstack/organizations` stamper, no
// tenantId — every Setup-authored row lands in the NULL-organization
// bucket. This is the default community deployment, not an edge case.
const communityCtx = { userId: 'admin1', positions: [], permissions: ['admin_full_access'] };

const runGate = async (opCtx: any, findOneImpl?: (q: any) => any) => {
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'admin_full_access' });
const harness = makeMiddlewareCtx({
permissionSets: [adminSet],
objectFields: ['id', 'name', 'label', 'managed_by', 'active'],
...(findOneImpl ? { findOneImpl } : {}),
});
await plugin.init(harness.ctx);
await plugin.start(harness.ctx);
return harness.run(opCtx);
};

// The refusal envelope, asserted whole (code AND status, plus the located
// name) — `rejects.toThrow()` alone would stay green on any refusal from
// any gate, which is exactly the vacuity this suite must not have.
const curatedRefusal = (name: string) => ({
name: 'PermissionDeniedError',
code: 'PERMISSION_DENIED',
statusCode: 403,
message: expect.stringContaining(`'${name}' is a platform-curated capability name`),
});

it('DENIES creating a capability with a curated name (org-scoped shape), naming the collision', async () => {
const opCtx: any = {
object: 'sys_capability', operation: 'insert',
data: { name: 'manage_users', label: 'Mine now' }, context: orgCtx,
};
await expect(runGate(opCtx)).rejects.toMatchObject(curatedRefusal('manage_users'));
});

it('DENIES creating a capability with a curated name (community NULL-bucket shape)', async () => {
const opCtx: any = {
object: 'sys_capability', operation: 'insert',
data: { name: 'setup.write', label: 'Setup Write' }, context: communityCtx,
};
await expect(runGate(opCtx)).rejects.toMatchObject(curatedRefusal('setup.write'));
});

it('DENIES an ARRAY insert when ANY element claims a curated name', async () => {
const opCtx: any = {
object: 'sys_capability', operation: 'insert',
data: [{ name: 'my_cap_a' }, { name: 'manage_sharing' }],
context: orgCtx,
};
await expect(runGate(opCtx)).rejects.toMatchObject(curatedRefusal('manage_sharing'));
});

it('DENIES RENAMING an admin-authored capability TO a curated name (by-id update)', async () => {
const opCtx: any = {
object: 'sys_capability', operation: 'update',
data: { id: 'cap_admin', name: 'manage_users' }, options: { where: { id: 'cap_admin' } },
context: orgCtx,
};
await expect(
runGate(opCtx, () => ({ id: 'cap_admin', name: 'my_cap', managed_by: 'admin' })),
).rejects.toMatchObject(curatedRefusal('manage_users'));
});

it('DENIES a rename-to-curated even for a principal-less context (gate is before the fall-open)', async () => {
const opCtx: any = {
object: 'sys_capability', operation: 'update',
data: { id: 'cap_admin', name: 'manage_users' }, options: { where: { id: 'cap_admin' } },
context: {},
};
await expect(
runGate(opCtx, () => ({ id: 'cap_admin', name: 'my_cap', managed_by: 'admin' })),
).rejects.toMatchObject(curatedRefusal('manage_users'));
});

it('FAILS CLOSED when the renamed row cannot be read back (absent pre-image refuses)', async () => {
const opCtx: any = {
object: 'sys_capability', operation: 'update',
data: { id: 'cap_gone', name: 'manage_users' }, options: { where: { id: 'cap_gone' } },
context: orgCtx,
};
// Every findOne answers null: no pre-image exists to prove the payload
// repeats the row's own name, so the write is refused.
await expect(runGate(opCtx, () => null)).rejects.toMatchObject(curatedRefusal('manage_users'));
});

it('DENIES a FILTER update stamping a curated name onto differently-named rows', async () => {
const opCtx: any = {
object: 'sys_capability', operation: 'update',
data: { name: 'manage_users', active: false }, options: { where: { active: true } },
context: orgCtx,
};
// The $ne probe (and only it) finds a row NOT already named
// manage_users inside the write's own filter; the system-row gate's
// managed-row probe finds nothing, so the refusal measured here is this
// gate's, not a neighbour's.
const impl = (q: any) => {
const and = q?.where?.$and;
if (Array.isArray(and) && and.some((c: any) => c && typeof c === 'object' && c.name && typeof c.name === 'object' && '$ne' in c.name)) {
return { id: 'cap_other', name: 'my_cap', managed_by: 'admin' };
}
return null;
};
await expect(runGate(opCtx, impl)).rejects.toMatchObject(curatedRefusal('manage_users'));
});

it('DENIES a WHOLE-TABLE update stamping a curated name (no filter to scope the rename)', async () => {
const opCtx: any = {
object: 'sys_capability', operation: 'update',
data: { name: 'manage_users' }, context: orgCtx,
};
await expect(runGate(opCtx, () => null)).rejects.toMatchObject(curatedRefusal('manage_users'));
});

// ── positive controls — the load-bearing half ──

it('ALLOWS creating a NON-curated capability (org-scoped shape)', async () => {
const opCtx: any = {
object: 'sys_capability', operation: 'insert',
data: { name: 'org_export_reports', label: 'Export Reports', managed_by: 'admin' },
context: orgCtx,
};
await expect(runGate(opCtx)).resolves.toBeDefined();
});

it('ALLOWS creating a NON-curated capability (community NULL-bucket shape)', async () => {
const opCtx: any = {
object: 'sys_capability', operation: 'insert',
data: { name: 'org_export_reports', label: 'Export Reports', managed_by: 'admin' },
context: communityCtx,
};
await expect(runGate(opCtx)).resolves.toBeDefined();
});

it('ALLOWS a full-record update of an EXISTING colliding admin row that repeats its own name (not a rename)', async () => {
const opCtx: any = {
object: 'sys_capability', operation: 'update',
data: { id: 'cap_collide', name: 'manage_users', label: 'Renamed label', active: false },
options: { where: { id: 'cap_collide' } },
context: communityCtx,
};
await expect(
runGate(opCtx, () => ({ id: 'cap_collide', name: 'manage_users', managed_by: 'admin' })),
).resolves.toBeDefined();
});

it('ALLOWS renaming an EXISTING colliding admin row AWAY from the curated name (the operator remediation)', async () => {
const opCtx: any = {
object: 'sys_capability', operation: 'update',
data: { id: 'cap_collide', name: 'org_manage_users' },
options: { where: { id: 'cap_collide' } },
context: communityCtx,
};
await expect(
runGate(opCtx, () => ({ id: 'cap_collide', name: 'manage_users', managed_by: 'admin' })),
).resolves.toBeDefined();
});

it('ALLOWS a FILTER update stamping a curated name when it provably renames nothing (probe finds no other-named row)', async () => {
const opCtx: any = {
object: 'sys_capability', operation: 'update',
data: { name: 'manage_users', active: true }, options: { where: { name: 'manage_users' } },
context: communityCtx,
};
await expect(runGate(opCtx, () => null)).resolves.toBeDefined();
});

it('lets system/boot writes through (isSystem bypass) — the curated seeder itself is unaffected', async () => {
const opCtx: any = {
object: 'sys_capability', operation: 'insert',
data: { name: 'manage_users', label: 'Manage Users', managed_by: 'platform' },
context: { isSystem: true },
};
await expect(runGate(opCtx)).resolves.toBeDefined();
});

it('does NOT reach sys_position — a position may share a curated capability STRING (scope control)', async () => {
const opCtx: any = {
object: 'sys_position', operation: 'insert',
data: { name: 'manage_users', label: 'A position, not a capability' },
context: orgCtx,
};
await expect(runGate(opCtx)).resolves.toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
describe('PermissionEvaluator', () => {
Expand Down
Loading
Loading