Skip to content

Commit 5af1179

Browse files
committed
runtime,metadata-protocol: the catalog activation door refuses the audience anchors; their sealed refusal names no switch
Claude-Session: https://claude.ai/code/session_015LutkhFn8wzgD5J86PtpYN Co-authored-by: Claude <noreply@anthropic.com>
1 parent 44bb1e1 commit 5af1179

9 files changed

Lines changed: 117 additions & 11 deletions

File tree

‎.changeset/15204-s2c-catalog-activation-door.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ The authorization resolver reads whether a position or a permission set is switc
1212
- **Body:** `{ enabled?: boolean }`. `enabled` defaults to `true`. Unknown keys and a non-boolean `enabled` are refused `400 VALIDATION_FAILED`, the same body reader `POST /actions/_activation/:object/:action` uses.
1313
- **Effect:** one `sys_metadata_activation` row through the engine, carrying the definition's package. Re-enabling updates that row. Switching a position off stops every grant through it for every holder in the deployment. Switching a permission set off stops it granting by every path. No definition and no catalog row is written.
1414
- **Authority:** the same as the flow and action activation doors. The caller needs `manage_metadata`. Under a `group` or `isolated` tenancy posture the caller must also be the platform operator (ADR-0126 §5). Refusals are `403 PERMISSION_DENIED`, and nothing is written.
15-
- **Refusals:** a name that does not resolve in the security catalog is `404`. No catalog bound, or a catalog that cannot be read, is `503 SERVICE_UNAVAILABLE`. A composition without the ledger object is `501`. Switching `admin_full_access` off is refused `403 PERMISSION_DENIED` by the last-admin guard's ledger hook.
15+
- **Refusals:** the audience anchors `position/everyone` and `position/guest` are refused `400` in either direction, before anything is read or written, because one row would switch the authenticated or anonymous baseline off for every principal. A name that does not resolve in the security catalog is `404`. No catalog bound, or a catalog that cannot be read, is `503 SERVICE_UNAVAILABLE`. A composition without the ledger object is `501`. Switching `admin_full_access` off is refused `403 PERMISSION_DENIED` by the last-admin guard's ledger hook.
1616

1717
The route is not in the JS SDK. Its caller is the Setup console's Deactivate on the position and permission-set pages, which calls the platform API directly.

‎.changeset/15204-s2c-position-regime-row.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ Saving over a position a managed package ships is still refused `403 NOT_OVERRID
88

99
> Switch it off (POST /api/v1/security/_activation/position/:name, body {enabled: false}; operator-only where one install serves several organizations).
1010
11-
The refused writes, the error code and the status are unchanged. A position has no clone door, so none is named. Removing a position's overlay is still not refused (a position merges its overlay at read).
11+
The audience anchors `everyone` and `guest` keep the generic sentence, because the activation route refuses them. The refused writes, the error code and the status are unchanged. A position has no clone door, so none is named. Removing a position's overlay is still not refused (a position merges its overlay at read).

‎content/docs/concepts/metadata-lifecycle.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,9 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/
139139
| `flow` | Clone it under a new name (`POST /api/v1/automation/:name/clone`, body `{name, label}`), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. |
140140
| `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. |
141141
| `permission` | Clone it under a new name: the "Clone" action on the permission set, or `POST /api/v1/data/sys_permission_set` with a new name. |
142-
| `position` | Switch it off (`POST /api/v1/security/_activation/position/:name`, body `{enabled: false}`). No clone is offered. |
142+
| `position` | Switch it off (`POST /api/v1/security/_activation/position/:name`, body `{enabled: false}`). No clone is offered. The audience anchors `everyone` and `guest` are not switchable, so their refusal names no switch. |
143143

144-
A permission set also has a switch, outside the refusal: `POST /api/v1/security/_activation/permission/:name`, body `{enabled: false}`, switches it off for the deployment. The refusal does not name it yet.
144+
A permission set also has a switch, outside the refusal: `POST /api/v1/security/_activation/permission/:name`, body `{enabled: false}`, switches it off for the deployment. The refusal does not name it.
145145

146146
The switches are operator-only where one install serves several organizations. The refusal names the same path whichever layer answers it: `403 NOT_OVERRIDABLE` for a write that names no base, and `403 ITEM_LOCKED` for one that names the read-only package. Which writes are refused, and with which code, is unchanged.
147147

‎content/docs/permissions/authorization.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,9 @@ grant off, `true` back on). The caller needs `manage_metadata`; under a `group`
416416
or `isolated` tenancy posture the caller must also be the platform operator,
417417
because the row reaches every organization (ADR-0126 §5). The name must resolve
418418
in the security catalog, or the route answers `404`. It is the same authority
419-
as the flow and action activation switches.
419+
as the flow and action activation switches. The two audience anchors,
420+
`everyone` and `guest`, are refused with `400` in either direction: one row
421+
would switch the authenticated or anonymous baseline off for every principal.
420422

421423
- A deactivated **permission set** contributes nothing: not its name, not its
422424
`system_permissions`, not its `tab_permissions`. As with an expired grant, a

‎packages/metadata-protocol/src/packaged-base-regime.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@
6262
* security catalog's activation door. ⛔ No clone: a position has no clone
6363
* door. Without this row a packaged position's refusal read the generic
6464
* managed seal ("edit the source artifact and redeploy") and never named the
65-
* switch that exists.
65+
* switch that exists. The two audience anchors (`everyone`, `guest`) are
66+
* the row's `notFor`: the door refuses them, so they keep the generic seal.
6667
*
6768
* Every switch route is gated by the ONE §5 write-authority gate
6869
* (`packages/runtime/src/domains/activation-gate.ts`), so each carries the same
@@ -138,6 +139,7 @@
138139
*/
139140

140141
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
142+
import { AUDIENCE_ANCHOR_POSITIONS } from '@objectstack/spec/identity';
141143

142144
/**
143145
* An ADR-0126 customization regime a packaged-base refusal speaks for — only the
@@ -162,7 +164,17 @@ export type PackagedBaseRegimeCRoutes =
162164
* type's sanctioned routes, or an origin-gated type's owning source.
163165
*/
164166
export type PackagedBaseRegimeRow =
165-
| { readonly regime: 'C'; readonly routes: PackagedBaseRegimeCRoutes }
167+
| {
168+
readonly regime: 'C';
169+
readonly routes: PackagedBaseRegimeCRoutes;
170+
/**
171+
* Names the row does NOT speak for, each with why. A name listed here
172+
* reads the generic managed seal ({@link managedItemSealedSentence}'s
173+
* own sentence) instead of the row's routes, because those routes refuse
174+
* it — a refusal must never name a path that refuses the caller too.
175+
*/
176+
readonly notFor?: Readonly<Record<string, string>>;
177+
}
166178
| {
167179
readonly regime: 'origin-gated';
168180
/** The type's noun, opening the sentence the way the type's own admin door opens it. */
@@ -211,6 +223,13 @@ export const PACKAGED_BASE_REGIME: Readonly<Record<string, PackagedBaseRegimeRow
211223
switchOff: 'POST /api/v1/security/_activation/position/:name, body {enabled: false}; '
212224
+ 'operator-only where one install serves several organizations',
213225
},
226+
// The activation door refuses the two audience anchors (ADR-0090 D5/D9),
227+
// so their refusal must not name it: switching one off would remove the
228+
// authenticated or anonymous baseline of every principal in one write.
229+
notFor: Object.fromEntries(AUDIENCE_ANCHOR_POSITIONS.map((anchor) => [
230+
anchor,
231+
'an audience anchor: the activation door refuses it, so the switch is not its remedy',
232+
])),
214233
},
215234
datasource: {
216235
regime: 'origin-gated',
@@ -332,6 +351,9 @@ export function packagedBaseRegimeSentence(
332351
const singular = PLURAL_TO_SINGULAR[type] ?? type;
333352
const row = packagedBaseRegimeRow(singular);
334353
if (row === undefined) return undefined;
354+
if (row.regime === 'C' && row.notFor !== undefined && Object.prototype.hasOwnProperty.call(row.notFor, name)) {
355+
return undefined;
356+
}
335357
const lock = row.regime === 'origin-gated'
336358
? `${row.noun} '${name}' is code-defined and cannot be `
337359
+ (operation === 'delete' ? 'removed' : 'edited') + ' at runtime: it is read-only. '

‎packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ const ARTIFACTS = new Map<string, Map<string, unknown>>([
4545
['page', new Map([['pkg_page', shipped('pkg_page')]])],
4646
['action', new Map([['pkg_action', shipped('pkg_action')]])],
4747
['permission', new Map([['pkg_perm', shipped('pkg_perm')]])],
48-
['position', new Map([['pkg_position', shipped('pkg_position')]])],
48+
['position', new Map([
49+
['pkg_position', shipped('pkg_position')],
50+
['everyone', shipped('everyone')],
51+
['guest', shipped('guest')],
52+
])],
4953
]);
5054

5155
function protocolOn(environmentId: string | undefined): ObjectStackProtocolImplementation {
@@ -402,6 +406,21 @@ describe('packagedBaseRefusal — each Regime C type names its OWN sanctioned pa
402406
expect(refusalOnHostConfig('permission', 'pkg_perm', 'delete')).toBeNull();
403407
});
404408

409+
it('`position` — an audience anchor keeps the generic managed seal: the switch refuses it, so it is not named', () => {
410+
// The activation door refuses `everyone` and `guest` (the row's `notFor`).
411+
for (const anchor of ['everyone', 'guest']) {
412+
const r = refusal('position', anchor, 'save');
413+
expect(shape(r), anchor).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
414+
expect(r.message, anchor).toBe(
415+
`Metadata item 'position/${anchor}' is provided by a managed package and is sealed against in-place edits: `
416+
+ 'its type takes no environment overlay (allowOrgOverride=false), and OS_METADATA_WRITABLE does not open '
417+
+ 'a managed item. Edit the source artifact and redeploy. '
418+
+ 'See docs/adr/0131-total-organization-ownership-no-null-organization-id.md.',
419+
);
420+
expect(r.message, anchor).not.toContain('_activation');
421+
}
422+
});
423+
405424
it('`position` removal is the same #6960 carve-out — its row adds no refusal where there was none', () => {
406425
// `position` merges its overlay at read (`supportsOverlay: true`), like `permission`.
407426
expect(refusal('position', 'pkg_position', 'delete')).toBeNull();

‎packages/runtime/src/domains/catalog-activation-door.test.ts‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@ const SET = 'sales_access';
3131
const DIRECT_SET = 'report_reader';
3232

3333
const CATALOG: TestSecurityCatalog = {
34-
positions: [{ name: POSITION, permissionSets: [SET], _packageId: 'crm' }],
34+
positions: [
35+
{ name: POSITION, permissionSets: [SET], _packageId: 'crm' },
36+
// The audience anchors are DECLARED here so a refusal of them cannot be
37+
// a 404 in disguise: the lookup would find them.
38+
{ name: 'everyone', permissionSets: [] },
39+
{ name: 'guest', permissionSets: [] },
40+
],
3541
permissions: [
3642
{ name: SET, systemPermissions: ['cap_sales'] },
3743
{ name: DIRECT_SET, systemPermissions: ['cap_reports'] },
@@ -273,6 +279,41 @@ describe('the catalog activation door — contract', () => {
273279
expect(h.writes).toEqual([]);
274280
});
275281

282+
for (const anchor of ['everyone', 'guest']) {
283+
for (const enabled of [false, true]) {
284+
it(`refuses the audience anchor \`${anchor}\` with enabled: ${enabled} — 400, nothing written`, async () => {
285+
const h = boot('single');
286+
287+
const res = await flip(h, TENANT_ADMIN(), `/_activation/position/${anchor}`, { enabled });
288+
289+
expect(statusOf(res)).toBe(400);
290+
expect(messageOf(res)).toContain(`'${anchor}' is an audience anchor`);
291+
expect(messageOf(res)).toContain('not switchable through this door');
292+
expect(h.writes).toEqual([]);
293+
expect(h.tables[LEDGER]).toEqual([]);
294+
});
295+
}
296+
297+
it(`the authority gates still answer first for \`${anchor}\` — a walled tenant admin gets 403, not the anchor 400`, async () => {
298+
const h = boot('isolated');
299+
300+
const res = await flip(h, TENANT_ADMIN(), `/_activation/position/${anchor}`);
301+
302+
expect(statusOf(res)).toBe(403);
303+
expect(codeOf(res)).toBe('PERMISSION_DENIED');
304+
expect(h.writes).toEqual([]);
305+
});
306+
}
307+
308+
it('an anchor NAME under `permission` is not an anchor — it is looked up like any set (404 here)', async () => {
309+
const h = boot('single');
310+
311+
const res = await flip(h, TENANT_ADMIN(), '/_activation/permission/everyone');
312+
313+
expect(statusOf(res)).toBe(404);
314+
expect(h.writes).toEqual([]);
315+
});
316+
276317
it('refuses a one-letter-off body key rather than inverting it into "enable"', async () => {
277318
const h = boot('single');
278319

‎packages/runtime/src/domains/catalog-activation.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
* `single`, the platform operator under a wall). ⛔ No new authority.
4545
*/
4646

47+
import { AUDIENCE_ANCHOR_POSITIONS, GUEST_POSITION } from '@objectstack/spec/identity';
4748
import {
4849
METADATA_ACTIVATION_TABLE,
4950
ObjectStoreMetadataActivationStore,
@@ -118,7 +119,8 @@ function ledgerUnregistered(ql: any): boolean {
118119
* names the subject the PATH asked for; a path whose type is not a catalog
119120
* type is refused with the position wording, which says nothing a refused
120121
* caller could not read off the route ledger.
121-
* 2. **Shape**: `['_activation', type, name]`, `type` a catalog type.
122+
* 2. **Shape**: `['_activation', type, name]`, `type` a catalog type, and
123+
* not an audience anchor (`everyone` / `guest`), which this door refuses.
122124
* 3. **Body**: {@link readActivationBody}.
123125
* 4. **Declaration**, through the catalog reader the security plugin binds to
124126
* the engine — the one `resolveUserAuthzGrants` reads, so this door and
@@ -158,6 +160,26 @@ export async function handleCatalogActivationWrite(
158160
const type: CatalogActivationType = requestedType;
159161
const name = parts[2];
160162

163+
// ── 2b. the audience anchors ────────────────────────────────────────────
164+
// [ADR-0090 D5/D9] `everyone` is the authenticated baseline every signed-in
165+
// member holds implicitly, and `guest` the anonymous one. One row here
166+
// would switch either off for every principal in the deployment at once.
167+
// Stage 1 kept the anchors sealed against every caller-reachable write, and
168+
// this door keeps that: refused in either direction, before the body is
169+
// read and before any lookup or write, with the same 400 as a type that has
170+
// no switch — a well-formed target this door does not switch.
171+
if (type === 'position' && (AUDIENCE_ANCHOR_POSITIONS as readonly string[]).includes(name)) {
172+
return {
173+
handled: true,
174+
response: deps.error(
175+
`Position '${name}' is an audience anchor: it carries the ${name === GUEST_POSITION ? 'anonymous' : 'authenticated'} ` +
176+
`baseline of every principal in the deployment, so it is not switchable through this door (ADR-0090 D5/D9). ` +
177+
`Change what it grants through the permission sets bound to it instead.`,
178+
400,
179+
),
180+
};
181+
}
182+
161183
// ── 3. body ─────────────────────────────────────────────────────────────
162184
const bodyReading = readActivationBody(deps, body);
163185
if (bodyReading.refusal) return bodyReading.refusal;

‎packages/runtime/src/route-ledger.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
353353
+ 'the action door. The same two authority tiers as `POST /automation/:name/toggle` and `POST /actions/_activation/:object/:action`: '
354354
+ '`manage_metadata`, then the ADR-0126 §5 posture rule requiring the platform operator in `group`/`isolated` — one shared '
355355
+ 'implementation in `domains/activation-gate.ts`. The name must resolve in the security catalog the resolver reads (404 '
356-
+ 'otherwise, 503 when no catalog is bound); switching `admin_full_access` off is refused 403 by the last-admin guard\'s '
356+
+ 'otherwise, 503 when no catalog is bound), and the audience anchors `everyone` / `guest` are refused 400 in either direction; switching `admin_full_access` off is refused 403 by the last-admin guard\'s '
357357
+ 'ledger hook. The ONE `/security` route the dispatcher mounts on the wire (`registerSecurityActivationRoutes`); the rest '
358358
+ 'of `/security` is the REST server\'s. NOT JS-SDK surface, for the action door\'s reason: its caller is the Setup console '
359359
+ '(Deactivate on the position and permission-set pages), which calls the platform API directly; adding a client method '

0 commit comments

Comments
 (0)