Skip to content

Commit d7b3963

Browse files
os-zhuangclaude
andauthored
feat(spec,plugin-security): export the kernel platform-admin capability declaration from @objectstack/spec (#11663 Choice 6A, leg L1) (#12612)
Part of #11965 - @objectstack/spec exports ADMIN_FULL_ACCESS_CAPABILITIES (objects wildcard + systemPermissions) beside ADMIN_FULL_ACCESS in identity/eval-user.zod.ts; the #3544/#8681 export-axis rulings travel with the declaration. - plugin-security's admin_full_access declaration imports that list, so exactly one copy exists. Behaviour-neutral: pinned deep-equal against the exact pre-move inline literal in default-permission-sets.test.ts. - api-surface / export-origins baselines regenerated (2 added, 0 breaking); authorable-surface unmoved. Claude-Session: https://claude.ai/code/session_012xGvxcwPRTJfA7RfjXEYA4 Co-authored-by: Claude <noreply@anthropic.com>
1 parent f90e820 commit d7b3963

10 files changed

Lines changed: 192 additions & 44 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/plugin-security': patch
4+
---
5+
6+
Export the kernel platform-admin capability declaration from `@objectstack/spec` (`ADMIN_FULL_ACCESS_CAPABILITIES`) and import it in plugin-security's `admin_full_access` permission-set declaration, so exactly one copy of the capability list exists (#11663 Choice 6A, leg L1). Behaviour-neutral: the declared capability set is byte-for-byte unchanged, pinned by test.

packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
import { describe, it, expect } from 'vitest';
66
import * as PlatformObjects from '@objectstack/platform-objects';
7+
import { PermissionSetSchema } from '@objectstack/spec/security';
8+
import { ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec';
79
import { defaultPermissionSets, BETTER_AUTH_MANAGED_OBJECTS } from './default-permission-sets.js';
810
import { MANAGED_DENY_TARGET_SETS } from '../managed-object-write-denies.js';
911

@@ -310,3 +312,54 @@ describe('sys_comment delete is moderation-shaped, not ownership-shaped (#8839)'
310312
}
311313
});
312314
});
315+
316+
/**
317+
* [#11965 / #11663 Choice 6A] platform-admin re-anchor, L1 behaviour-neutrality
318+
* pin.
319+
*
320+
* `admin_full_access`'s capability CONTENT moved to `@objectstack/spec`
321+
* (`ADMIN_FULL_ACCESS_CAPABILITIES`) and is IMPORTED here — one list, one copy.
322+
* L1 is ruled behaviour-neutral, so the parsed declaration must be deep-equal
323+
* to what the previously-inline literal produced. The literal below is the
324+
* exact pre-#11965 inline value (comments elided); if this pin fails, the spec
325+
* export changed the declared capability set — that is a capability change
326+
* riding on a refactor card, and it must not land silently.
327+
*/
328+
describe('admin_full_access imports the kernel capability declaration unchanged (#11965)', () => {
329+
it('parsed declaration deep-equals the pre-#11965 inline literal', () => {
330+
const preMove = PermissionSetSchema.parse({
331+
name: 'admin_full_access',
332+
label: 'Administrator — Full Access',
333+
objects: {
334+
'*': {
335+
allowRead: true,
336+
allowCreate: true,
337+
allowEdit: true,
338+
allowDelete: true,
339+
viewAllRecords: true,
340+
modifyAllRecords: true,
341+
// [#8681] no `allowExport` — deliberate, see the spec declaration.
342+
},
343+
},
344+
systemPermissions: [
345+
'manage_users',
346+
'manage_metadata',
347+
'manage_platform_settings',
348+
'manage_sharing',
349+
'setup.access',
350+
'setup.write',
351+
'studio.access',
352+
],
353+
});
354+
expect(setByName('admin_full_access')).toEqual(preMove);
355+
});
356+
357+
it('the imported spec constant is the declaration content — no local fork', () => {
358+
const admin = setByName('admin_full_access');
359+
// Same values, sourced from the one spec-exported copy.
360+
expect(admin.objects).toEqual(
361+
PermissionSetSchema.parse({ name: 'admin_full_access', ...ADMIN_FULL_ACCESS_CAPABILITIES }).objects,
362+
);
363+
expect(admin.systemPermissions).toEqual(ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions);
364+
});
365+
});

packages/plugins/plugin-security/src/objects/default-permission-sets.ts

Lines changed: 12 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security';
44
import {
5+
ADMIN_FULL_ACCESS,
6+
ADMIN_FULL_ACCESS_CAPABILITIES,
57
ORGANIZATION_ADMIN,
68
ORGANIZATION_ADMIN_NO_BYPASS,
79
BUILTIN_IDENTITY_ORG_ADMIN,
@@ -117,51 +119,17 @@ const denyWritesOnManagedObjects = (): Record<string, {
117119
*/
118120
const baseDefaultPermissionSets: PermissionSet[] = [
119121
PermissionSetSchema.parse({
120-
name: 'admin_full_access',
122+
name: ADMIN_FULL_ACCESS,
121123
label: 'Administrator — Full Access',
122-
objects: {
123-
'*': {
124-
allowRead: true,
125-
allowCreate: true,
126-
allowEdit: true,
127-
allowDelete: true,
128-
viewAllRecords: true,
129-
modifyAllRecords: true,
130-
// [#3544] Export is an OPT-IN grant and is deliberately NOT implied by
131-
// the super-user bits — "may see all data" and "may take a bulk copy of
132-
// it" are separable on purpose (SAP S_GUI 61 / segregation of duties).
133-
//
134-
// [#8681] NO `allowExport` HERE, and it is not an oversight. This set
135-
// shipped `allowExport: true` on the wildcard through 17.0.0 GA, which
136-
// made the export axis undeniable for anyone holding it: an app could
137-
// declare an object exportable by nobody and the platform exported it
138-
// anyway, with no supported opt-out (editing a code-package set answers
139-
// `403 [not_overridable]`, and the admin holds no app-authored set to
140-
// put the per-object `false` into). Measured on GA, hotcrm#1152: an org
141-
// owner exported three objects no app set grants export on, 200 with
142-
// full rows. Maintainer ruling (2026-08-15) removes the grant — the
143-
// export axis's half of #5491, which removed `member_default`'s CRUD
144-
// wildcard for the identical "a wildcard nobody can get under" reason.
145-
//
146-
// ⛔ Do not restore it, and do not restore a NARROWER wildcard either —
147-
// "which platform objects should ship an explicit export grant" is an
148-
// OPEN question the ruling deliberately left to a separate decision, and
149-
// any `'*'` export grant here re-opens the hole for every object the
150-
// platform does not know about. Where admin export is intended, grant
151-
// `allowExport` per object in an APP permission set.
152-
},
153-
},
154-
systemPermissions: [
155-
'manage_users',
156-
'manage_metadata',
157-
'manage_platform_settings',
158-
// [ADR-0111 D9] Sharing administration — gates the sharing-rule surface
159-
// and (in the DEPTH extension) non-owner share management.
160-
'manage_sharing',
161-
'setup.access',
162-
'setup.write',
163-
'studio.access',
164-
],
124+
// [#11965 / #11663 Choice 6A] The capability CONTENT (object wildcard +
125+
// `systemPermissions`) is the kernel platform-admin declaration exported
126+
// by `@objectstack/spec` — one list, imported here and read by core's
127+
// platform-admin derivation, so exactly one copy exists. The export-axis
128+
// rulings travel with the declaration (see `ADMIN_FULL_ACCESS_CAPABILITIES`
129+
// in `spec/src/identity/eval-user.zod.ts`: #3544 export is opt-in; #8681
130+
// ⛔ no `allowExport` on the wildcard, and do not restore a narrower one).
131+
// Behaviour-neutrality is pinned by `default-permission-sets.test.ts`.
132+
...ADMIN_FULL_ACCESS_CAPABILITIES,
165133
}),
166134
// ── Organization Administrator ──────────────────────────────────────
167135
//

packages/spec/api-surface/identity.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"entry": "./identity",
44
"exports": [
55
"ADMIN_FULL_ACCESS (const)",
6+
"ADMIN_FULL_ACCESS_CAPABILITIES (const)",
67
"AUDIENCE_ANCHOR_POSITIONS (const)",
78
"AUTH_CONSTANTS (const)",
89
"AUTH_ERROR_CODES (const)",

packages/spec/api-surface/root.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"entry": ".",
44
"exports": [
55
"ADMIN_FULL_ACCESS (const)",
6+
"ADMIN_FULL_ACCESS_CAPABILITIES (const)",
67
"ALL_CONVERSIONS (const)",
78
"ASSEMBLED_VIEW_ITEMS_KEY (const)",
89
"AUDIENCE_ANCHOR_POSITIONS (const)",

packages/spec/export-origins/identity.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"entry": "./identity",
44
"exports": {
55
"ADMIN_FULL_ACCESS": "src/identity/eval-user.zod.ts#ADMIN_FULL_ACCESS (const)",
6+
"ADMIN_FULL_ACCESS_CAPABILITIES": "src/identity/eval-user.zod.ts#ADMIN_FULL_ACCESS_CAPABILITIES (const)",
67
"AUDIENCE_ANCHOR_POSITIONS": "src/identity/position.zod.ts#AUDIENCE_ANCHOR_POSITIONS (const)",
78
"AUTH_CONSTANTS": "src/identity/protocol.ts#AUTH_CONSTANTS (const)",
89
"AUTH_ERROR_CODES": "src/identity/protocol.ts#AUTH_ERROR_CODES (const)",

packages/spec/export-origins/root.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"entry": ".",
44
"exports": {
55
"ADMIN_FULL_ACCESS": "src/identity/eval-user.zod.ts#ADMIN_FULL_ACCESS (const)",
6+
"ADMIN_FULL_ACCESS_CAPABILITIES": "src/identity/eval-user.zod.ts#ADMIN_FULL_ACCESS_CAPABILITIES (const)",
67
"ALL_CONVERSIONS": "src/conversions/registry.ts#ALL_CONVERSIONS (const)",
78
"ASSEMBLED_VIEW_ITEMS_KEY": "src/ui/assembled-views.zod.ts#ASSEMBLED_VIEW_ITEMS_KEY (const)",
89
"AUDIENCE_ANCHOR_POSITIONS": "src/identity/position.zod.ts#AUDIENCE_ANCHOR_POSITIONS (const)",

packages/spec/src/identity/eval-user.zod.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { z } from 'zod';
44
import { lazySchema } from '../shared/lazy-schema';
5+
import type { PermissionSet } from '../security/permission.zod';
56

67
/**
78
* EvalUser — the one user-context contract (ADR-0068 D1).
@@ -59,6 +60,72 @@ export type BuiltinIdentityName = (typeof BUILTIN_IDENTITY_NAMES)[number];
5960
*/
6061
export const ADMIN_FULL_ACCESS = 'admin_full_access';
6162

63+
/**
64+
* [#11663 Choice 6A] The kernel platform-admin CAPABILITY DECLARATION — the
65+
* capability content (object grants + `systemPermissions`) of the
66+
* {@link ADMIN_FULL_ACCESS} permission set, declared ONCE here in the contract
67+
* package so exactly one copy exists:
68+
*
69+
* - `@objectstack/plugin-security`'s `admin_full_access` declaration
70+
* (`objects/default-permission-sets.ts`) spreads this object into its
71+
* `PermissionSetSchema.parse({ name, label, ... })` entry — the metadata
72+
* declaration that wins at enforcement time.
73+
* - `@objectstack/core`'s platform-admin derivation (the re-anchor's L2 leg)
74+
* reads the same list to fill `grants.systemPermissions`, so the derived
75+
* envelope and the declared set can never drift apart.
76+
*
77+
* Shape note: these are the two capability-bearing fields of the authored
78+
* permission-set contract (`PermissionSetSchema`); `name`/`label` remain with
79+
* the declaring package. Behaviour-neutral by construction — the values are
80+
* byte-for-byte the ones previously inlined in plugin-security, pinned by
81+
* `objects/default-permission-sets.test.ts` there.
82+
*/
83+
export const ADMIN_FULL_ACCESS_CAPABILITIES: Pick<PermissionSet, 'objects' | 'systemPermissions'> = {
84+
objects: {
85+
'*': {
86+
allowRead: true,
87+
allowCreate: true,
88+
allowEdit: true,
89+
allowDelete: true,
90+
viewAllRecords: true,
91+
modifyAllRecords: true,
92+
// [#3544] Export is an OPT-IN grant and is deliberately NOT implied by
93+
// the super-user bits — "may see all data" and "may take a bulk copy of
94+
// it" are separable on purpose (SAP S_GUI 61 / segregation of duties).
95+
//
96+
// [#8681] NO `allowExport` HERE, and it is not an oversight. This set
97+
// shipped `allowExport: true` on the wildcard through 17.0.0 GA, which
98+
// made the export axis undeniable for anyone holding it: an app could
99+
// declare an object exportable by nobody and the platform exported it
100+
// anyway, with no supported opt-out (editing a code-package set answers
101+
// `403 [not_overridable]`, and the admin holds no app-authored set to
102+
// put the per-object `false` into). Measured on GA, hotcrm#1152: an org
103+
// owner exported three objects no app set grants export on, 200 with
104+
// full rows. Maintainer ruling (2026-08-15) removes the grant — the
105+
// export axis's half of #5491, which removed `member_default`'s CRUD
106+
// wildcard for the identical "a wildcard nobody can get under" reason.
107+
//
108+
// ⛔ Do not restore it, and do not restore a NARROWER wildcard either —
109+
// "which platform objects should ship an explicit export grant" is an
110+
// OPEN question the ruling deliberately left to a separate decision, and
111+
// any `'*'` export grant here re-opens the hole for every object the
112+
// platform does not know about. Where admin export is intended, grant
113+
// `allowExport` per object in an APP permission set.
114+
},
115+
},
116+
systemPermissions: [
117+
'manage_users',
118+
'manage_metadata',
119+
'manage_platform_settings',
120+
// [ADR-0111 D9] Sharing administration — gates the sharing-rule surface
121+
// and (in the DEPTH extension) non-owner share management.
122+
'manage_sharing',
123+
'setup.access',
124+
'setup.write',
125+
'studio.access',
126+
],
127+
};
128+
62129
/**
63130
* Permission-set name whose grant is the source of truth for the `TENANT_ADMIN`
64131
* posture rung (ADR-0095 D3). Auto-granted (org-scoped) to every `sys_member`
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
// [#11965 / #11663 Choice 6A] platform-admin re-anchor L1 — the kernel
3+
// platform-admin capability declaration is exported from the contract package.
4+
5+
import { describe, it, expect } from 'vitest';
6+
import { ADMIN_FULL_ACCESS, ADMIN_FULL_ACCESS_CAPABILITIES } from './eval-user.zod';
7+
import { PermissionSetSchema } from '../security/permission.zod';
8+
import { PLATFORM_CAPABILITY_NAMES } from '../security/capabilities';
9+
10+
describe('ADMIN_FULL_ACCESS_CAPABILITIES (#11965, Choice 6A)', () => {
11+
it('carries exactly the two capability-bearing fields — name/label stay with the declaring package', () => {
12+
// The export is the capability CONTENT, not a permission set. `name` /
13+
// `label` (or any other authored field) creeping in here would make the
14+
// spec copy a second declaration instead of the single shared list.
15+
expect(Object.keys(ADMIN_FULL_ACCESS_CAPABILITIES).sort()).toEqual([
16+
'objects',
17+
'systemPermissions',
18+
]);
19+
});
20+
21+
it('composes into a valid strict permission-set declaration under the canonical name', () => {
22+
// Exactly how plugin-security consumes it: spread into the authored shape.
23+
const parsed = PermissionSetSchema.parse({
24+
name: ADMIN_FULL_ACCESS,
25+
label: 'Administrator — Full Access',
26+
...ADMIN_FULL_ACCESS_CAPABILITIES,
27+
});
28+
expect(parsed.name).toBe('admin_full_access');
29+
// The PLATFORM_ADMIN posture rung derives from these bits (ADR-0095 D3).
30+
expect(parsed.objects['*'].viewAllRecords).toBe(true);
31+
expect(parsed.objects['*'].modifyAllRecords).toBe(true);
32+
});
33+
34+
it('the wildcard grants NO export — #8681 ruling pinned at the declaration\'s new home', () => {
35+
// [#3544/#8681] export is an OPT-IN axis, deliberately absent from the
36+
// super-user wildcard (maintainer ruling 2026-08-15). Moving the
37+
// declaration into spec must not resurrect it.
38+
expect('allowExport' in ADMIN_FULL_ACCESS_CAPABILITIES.objects!['*']).toBe(false);
39+
const parsed = PermissionSetSchema.parse({ name: ADMIN_FULL_ACCESS, ...ADMIN_FULL_ACCESS_CAPABILITIES });
40+
expect(parsed.objects['*'].allowExport).not.toBe(true);
41+
});
42+
43+
it('every granted system permission is a declared built-in capability (ADR-0066 registry)', () => {
44+
const unknown = (ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions ?? []).filter(
45+
(name) => !PLATFORM_CAPABILITY_NAMES.has(name),
46+
);
47+
expect(unknown).toEqual([]);
48+
});
49+
});

packages/spec/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ export {
277277
BUILTIN_IDENTITY_ORG_ADMIN,
278278
BUILTIN_IDENTITY_ORG_MEMBER,
279279
ADMIN_FULL_ACCESS,
280+
ADMIN_FULL_ACCESS_CAPABILITIES,
280281
ORGANIZATION_ADMIN,
281282
ORGANIZATION_ADMIN_NO_BYPASS,
282283
ORGANIZATION_ADMIN_GRANTS,

0 commit comments

Comments
 (0)