Skip to content

Commit b9dbc3d

Browse files
feat(plugin-security): under a walled posture, deployment-level acceptance of package defaults is host configuration (acceptedDefaultPermissionSets, ADR-0131 D5) (#23884)
Part of #23385 Clause-②: yes The code half of #23385. Under a walled posture, a host can declare the deployment-level acceptance of package defaults as configuration: `SecurityPluginOptions.acceptedDefaultPermissionSets`, under ADR-0131 D5. The walled refusal now names that option instead of "create the Default Organization". The card's third item, the ADR-0131 amendment line, is #23883 (Tier H, the maintainer lands it). #23385 remains open until that PR lands, and this PR does not close it. Authority: objectstack-ai/cloud#2872 ruling A′ (`6160165869`, maintainer 「同意」), item 3, as triage quoted and verified it in `6205580248`. ## What changed `packages/plugins/plugin-security`: - **`SecurityPluginOptions.acceptedDefaultPermissionSets?: readonly string[]`** is new. It is read only under a walled posture, using the module's existing `postureEnforcesWall` predicate on the posture the plugin already resolves in `start()`. There is no second reading of the posture. When it is declared, possibly as `[]`, three things follow: - `readAcceptedEveryoneSets` derives `everyone` from the declared names. Each name goes through the same per-set checks an accepted row goes through: the catalog serves it under a package, it declares `isDefault: true`, and `describeAnchorForbiddenBits` passes (ADR-0090 D7). A name that fails is withheld with its reason, at `error` for a forbidden bit, as for a row. - `syncAudienceBindingSuggestions` reads and writes nothing, so the boot has no throw and the two `(non-fatal)` warn sites stay silent. `reconcileAudienceBindingSuggestions` still counts existing rows once, now as "not read" and naming the option. - The list, accept and dismiss doors answer `409 SUGGESTION_STATE` naming the option. The deployment authority still answers first, so a non-operator gets 403. - **Walled, undeclared:** behaviour is unchanged. With no organization to own the record, the refusal now reads "…Under a walled posture the deployment-level acceptance is host configuration (ADR-0131 D5), never a row a reserved organization owns (ADR-0131 §1.5). Remedy: declare … `acceptedDefaultPermissionSets` …". - **`single`:** unchanged, including `NO_RECORD_OWNER_MESSAGE`'s text. Every pre-existing call passes no `DeploymentAcceptance`, which reads as the record. - **Refused loudly.** A malformed value (not a list of non-empty names) is refused at construction (`TypeError`). A declaration under `single` is refused at `start()`, the first moment the posture is known. `single` would never read it, so it would only look enforced. - Exported widening, all optional or additive: a trailing `deployment?: DeploymentAcceptance` on `readAcceptedEveryoneSets`, `syncAudienceBindingSuggestions` and `reconcileAudienceBindingSuggestions`; `SuggestionDeps.acceptedDefaultPermissionSets?`; the `DeploymentAcceptance` type; and `WithheldAcceptance.source`. - Changeset: one `minor` for `@objectstack/plugin-security`, `Clause-②: yes (widening)`. Not touched: no NULL-`organization_id` row, no new table, no `packages/spec` file, not `builtin-positions.ts`. ## Which cases read the declared list: measured, then decided `resolveDefaultOrganizationId` returns the `default` slug, else the only organization, else nothing. Today, on a walled deployment: | organizations | today | undeclared (this PR) | declared (this PR) | |---|---|---|---| | a `default` slug exists | record owned by it | unchanged | the declaration; record not read | | exactly one, no `default` | record owned by that tenant | unchanged | the declaration; record not read | | two or more, no `default` | 409 / boot warn, "create the Default Organization" | 409 / boot warn, names the option | the declaration; no warn | **Call: a declaration under a wall is the only source, whatever the organization count.** The four-axis reason: - ① **Need:** cloud's walled control plane falls in row 3 (the audit cloud#2872 `6159344518`: 48 and 12 organizations, no `default`). Reading the list only in row 3 would leave a host's declaration ignored the day a `default`-slug organization appears. - ② **Long term:** D5 says platform standing under a wall is configuration, and §1.5 rejects an organization owning deployment-level rows. Under a wall, then, a declaration should replace the record, not sit beside it. Two sources would need a merge rule that nothing in the record governs. - ③ **AI-proof:** "declared ⇒ enforced, always" leaves no posture × organization-count matrix to get wrong. A declaration nothing reads (under `single`) or a malformed one is refused at boot or construction, never skipped. - ④ **Scope:** no new state. A walled host that declares nothing keeps today's behaviour byte for byte, apart from the message. ## Tests (at `70b3cb0927`) - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2` over the whole package: **187 files passed, 3725 tests passed, 45 skipped**. `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. - New pins, one per acceptance line of the card, in the `[ADR-0131 D5] the walled posture …` block of `suggested-audience-bindings.test.ts` (8 tests) and in `walled-accepted-defaults.boot.test.ts` (3 tests, booting a real `ObjectKernel`): - walled, no `default`, declared `[]`: the reconcile resolves (no throw, so the plugin's warn sites log nothing), the captured warns are `[]`, `everyone` is the baseline, and accept, dismiss and list answer `SUGGESTION_STATE`/409 with a message whose first sentence is pinned and which names the option; - a declared set is granted through `everyone` (absent before), with no row written; - a declared high-privilege set is withheld `forbidden: true` and never granted; an unknown, non-`isDefault` or package-less name is withheld with its reason; - declared where a `default` organization holds an accepted row: that row is not read; - walled, undeclared: 409 with the new message (first sentence pinned, names the option and §1.5, no "create the Default Organization"), identical at the accept door; - `single`, both undeclared and with a declaration passed to the module: the message keeps "Remedy: create the Default Organization (slug 'default')"; - the option's shape refusals, and the `single`-posture boot refusal with its controls (`single` undeclared boots; walled declared boots). - The `single`-posture suggestion tests already in the file are byte-for-byte unchanged and green. The new block is appended. **Ablation** (`node scripts/ablation-replace.mjs`; the tests import `src/` relatively, so no `dist/` leg; both restores verified blob == HEAD and `git diff HEAD` empty): 1. `hostDeclaredAcceptance` returning `undefined` (the option's read removed): **5 red.** They are declared `[]` (no warn, everyone = baseline, the door refusal); the declared set granted; the forbidden set withheld; unserved names withheld; and the record not read when declared. The other 47 stayed green. That includes the undeclared-walled message, the `single` pins and the shape refusals, which do not depend on the read. 2. `noRecordOwnerMessage` always returning `NO_RECORD_OWNER_MESSAGE`: **1 red**, the walled-undeclared message pin. ## Gates (local, at `70b3cb0927`) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 families from the 6 changed paths (625 changed lines). All 67 ran, with exit codes captured before any pipe, and all exited 0. `--ran` reconciliation: "67 derived famil(ies) accounted for — 67 run, 0 NOT-MEASURED (a DERIVED zero — all 67 recorded an exit code and none of them is 3)." On the first pass, three families exited 3 (prerequisite not met) and one hit my 300 s wrapper. Each was then re-run to a verdict: - `check-plugin-teardown-shape --self-test` needed an unshallow clone; - `check:type-check-debt` ran to exit 0 under the lock in 114 s, and it built every package; - `check:dual-build-cjs-loads` and `check:i18n` exited 0 after that build. ## Acceptance notes - The posture reading reused: `resolveTenancyPosture()` (`packages/types/src/env.ts:147`) is the deployment's env reading, which plugin-auth's `tenancy` service is built from. The security plugin already reads that service once in `start()` (`this.tenancyPosture`). This PR reads that resolved value through `postureEnforcesWall` (`packages/spec/src/security/tenancy-posture.ts:53`), the predicate `assertDeploymentAuthority` in this module already uses. It does not call `resolveTenancyPosture()` again, which would be a second reading beside the service. - When declared, the **list** door also refuses with 409. The card names only the acceptance door. With no record, a list would either be empty and hide the declared `isDefault` sets, or show prompts no door can act on. - `WithheldAcceptance.source` is a new required field on an exported, output-only type. Only this package constructs the type. - Observation, not filed: under a wall with exactly one organization, today's record is owned by that tenant. Once a second organization exists, the record's rows count as "not read" (reported by the existing warn) and its acceptances stop granting. A host that declares the option is unaffected. - The `cloud` adoption (dropping the boot-log warn check ruling A′ added to cloud#2709) is cloud's, at a later pin. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015jUkR2UVa8FFGk3rWDX49C --- _Generated by [Claude Code](https://claude.ai/code/session_015jUkR2UVa8FFGk3rWDX49C)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent abb6df0 commit b9dbc3d

6 files changed

Lines changed: 582 additions & 43 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
feat(plugin-security): under a walled posture, the deployment-level acceptance of package defaults can be host configuration (`acceptedDefaultPermissionSets`, ADR-0131 D5) (#23385)
6+
7+
Clause-②: yes (widening)
8+
9+
**What was wrong.** On a walled (`group` / `isolated`) deployment with several organizations and none of slug `default`, the audience-binding suggestion record has no organization to own it. Every boot logged `[security] audience-binding suggestion sync failed (non-fatal)`, and the accept door answered `409 SUGGESTION_STATE` with the remedy "create the Default Organization (slug 'default')". That is a reserved organization for deployment-level rows, the shape ADR-0131 §1.5 rejects.
10+
11+
**What is new.** `SecurityPluginOptions.acceptedDefaultPermissionSets?: readonly string[]` is read only under a walled posture. When it is declared, possibly as `[]`:
12+
13+
- the `everyone` anchor derives from the baseline plus the declared sets. Each name passes the same checks an accepted suggestion does: the security catalog serves it under some package, it declares `isDefault: true`, and it carries no bit the `everyone` anchor forbids (ADR-0090 D7). A name that fails a check grants nothing and is reported, at `error` for a forbidden bit;
14+
- the `sys_audience_binding_suggestion` record is neither read nor written, so the boot no longer warns. Existing rows are counted once and reported, not deleted;
15+
- the record's list, accept and dismiss doors answer `409 SUGGESTION_STATE` naming the option.
16+
17+
```ts
18+
new SecurityPlugin({ acceptedDefaultPermissionSets: [] }); // nothing beyond the baseline
19+
new SecurityPlugin({ acceptedDefaultPermissionSets: ['crm_readonly'] }); // every member also holds crm_readonly
20+
```
21+
22+
A walled deployment that declares nothing keeps today's behaviour. When it has no organization to own the record, the refusal now names `acceptedDefaultPermissionSets` instead of "create the Default Organization".
23+
24+
**Refused loudly.** A value that is not a list of non-empty names is refused at construction. A declaration under the `single` posture is refused at `start()`: there the record owned by the Default Organization is the acceptance. The `single` posture is otherwise unchanged, including its refusal text.
25+
26+
**Exported for callers of the module's functions.** `readAcceptedEveryoneSets`, `syncAudienceBindingSuggestions` and `reconcileAudienceBindingSuggestions` take an optional trailing `DeploymentAcceptance` (`{ posture, acceptedDefaultPermissionSets }`), and `SuggestionDeps` gains `acceptedDefaultPermissionSets`. Omitted, they read the record as before. `WithheldAcceptance` gains `source: 'record' | 'host-configuration'`.

‎packages/plugins/plugin-security/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export type {
103103
SuggestionSyncOutcome,
104104
SuggestionReconcileOutcome,
105105
WithheldAcceptance,
106+
DeploymentAcceptance,
106107
} from './suggested-audience-bindings.js';
107108
// [maintainer ruling 2026-08-24 — 「同意 第一步(创业阶段,Salesforce 式)」]
108109
// Lock the base, clone to customize: the write-door provenance rule and its

‎packages/plugins/plugin-security/src/security-plugin.ts‎

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ import {
6565
confirmAudienceBindingSuggestion,
6666
dismissAudienceBindingSuggestion,
6767
readAcceptedEveryoneSets,
68+
readAcceptedDefaultPermissionSetsOption,
69+
refuseAcceptedDefaultsWithoutWall,
70+
type DeploymentAcceptance,
6871
type SuggestionDeps,
6972
type SuggestionListFilter,
7073
} from './suggested-audience-bindings.js';
@@ -907,6 +910,34 @@ export interface SecurityPluginOptions {
907910
* @default 'member_default'
908911
*/
909912
fallbackPermissionSet?: string | null;
913+
/**
914+
* [ADR-0131 D5] The package defaults this deployment accepts for the
915+
* `everyone` anchor, declared as host configuration — read ONLY under a
916+
* walled posture (`group` / `isolated`, `postureEnforcesWall`).
917+
*
918+
* Under a wall, platform standing is configuration, and no reserved
919+
* organization owns deployment-level rows (ADR-0131 §1.5). So a walled host
920+
* names here the `isDefault: true` permission sets every signed-in member
921+
* holds beside the baseline. Declared, this list IS the deployment-level
922+
* acceptance: the `everyone` definition derives from the baseline plus these
923+
* sets, the `sys_audience_binding_suggestion` record is neither read nor
924+
* written, and its list / accept / dismiss doors answer 409
925+
* `SUGGESTION_STATE` naming this option. `[]` declares that nothing beyond
926+
* the baseline is accepted.
927+
*
928+
* Each name passes the same checks an accepted suggestion does, on every
929+
* derivation: the security catalog serves it under some package, it declares
930+
* `isDefault: true`, and it carries no bit the `everyone` anchor forbids
931+
* (ADR-0090 D7). A name failing one is not granted and is reported — at
932+
* `error` for a forbidden bit.
933+
*
934+
* Omitted (the default), the acceptance is the record, owned by the Default
935+
* Organization, as before. Declared under the `single` posture it is refused
936+
* at `start()`: there the record is the acceptance (ADR-0131 D5's
937+
* Default-Organization row), and a declaration nothing reads would only
938+
* look enforced.
939+
*/
940+
acceptedDefaultPermissionSets?: readonly string[];
910941
}
911942

912943
/**
@@ -1166,6 +1197,8 @@ export class SecurityPlugin implements Plugin {
11661197
private fieldMasker = new FieldMasker();
11671198
private readonly bootstrapPermissionSets: PermissionSet[];
11681199
private readonly fallbackPermissionSet: string | null;
1200+
/** [ADR-0131 D5] {@link SecurityPluginOptions.acceptedDefaultPermissionSets}, frozen. */
1201+
private readonly acceptedDefaultPermissionSets: readonly string[] | undefined;
11691202
/**
11701203
* [#7555, ADR-0090 D5] The HUMAN baseline, as the list of set names it is:
11711204
* {@link fallbackPermissionSet} COMPOSED WITH the platform baseline
@@ -1479,6 +1512,15 @@ export class SecurityPlugin implements Plugin {
14791512
// [#7555] …and the baseline that actually resolves is that name COMPOSED
14801513
// with the platform's own, never one displacing the other (ADR-0090 D5).
14811514
this.baselinePermissionSets = composeHumanBaselinePermissionSets(this.fallbackPermissionSet);
1515+
this.acceptedDefaultPermissionSets = readAcceptedDefaultPermissionSetsOption(options.acceptedDefaultPermissionSets);
1516+
}
1517+
1518+
/**
1519+
* [ADR-0131 D5] Where the deployment-level acceptance lives: the posture this
1520+
* plugin resolved, and the host's declaration (read only under a wall).
1521+
*/
1522+
private deploymentAcceptance(): DeploymentAcceptance {
1523+
return { posture: this.tenancyPosture, acceptedDefaultPermissionSets: this.acceptedDefaultPermissionSets };
14821524
}
14831525

14841526
async init(ctx: PluginContext): Promise<void> {
@@ -1895,6 +1937,10 @@ export class SecurityPlugin implements Plugin {
18951937
} catch {
18961938
/* engine absent — nothing to widen */
18971939
}
1940+
// [ADR-0131 D5] The host's accepted package defaults are read only under a
1941+
// wall; declared under `single` they are refused here, the first moment the
1942+
// posture is known, rather than left looking enforced.
1943+
refuseAcceptedDefaultsWithoutWall(this.tenancyPosture, this.acceptedDefaultPermissionSets);
18981944
// [ADR-0105 D11] Optional app/plugin membership resolver for the §7.3.1
18991945
// `IN (current_user.<key>)` sets. Absent is the norm — probe once.
19001946
try {
@@ -2012,6 +2058,7 @@ export class SecurityPlugin implements Plugin {
20122058
metadata,
20132059
posture: () => this.tenancyPosture,
20142060
rederiveEveryone: () => this.declareEveryone(ctx.logger),
2061+
acceptedDefaultPermissionSets: this.acceptedDefaultPermissionSets,
20152062
logger: ctx.logger,
20162063
};
20172064
// Typed against the published contract so the registered surface cannot
@@ -4375,7 +4422,7 @@ export class SecurityPlugin implements Plugin {
43754422
if (evt?.type !== 'permission' || evt.state === 'draft') return;
43764423
void (async () => {
43774424
try {
4378-
await reconcileAudienceBindingSuggestions(ql, ctx.logger);
4425+
await reconcileAudienceBindingSuggestions(ql, ctx.logger, this.deploymentAcceptance());
43794426
} catch (e) {
43804427
ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message });
43814428
}
@@ -4471,7 +4518,7 @@ export class SecurityPlugin implements Plugin {
44714518
// `manage_metadata` holder's decision — never auto-bound. ONE
44724519
// deployment-level pass, owned by the Default Organization (#22801 → A).
44734520
try {
4474-
await reconcileAudienceBindingSuggestions(ql, ctx.logger);
4521+
await reconcileAudienceBindingSuggestions(ql, ctx.logger, this.deploymentAcceptance());
44754522
} catch (e) {
44764523
ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message });
44774524
}
@@ -4670,13 +4717,16 @@ export class SecurityPlugin implements Plugin {
46704717
logger.warn('[security] the everyone anchor does not name a baseline set — high-privilege bits', { set, offending });
46714718
});
46724719
try {
4673-
const derived = await readAcceptedEveryoneSets(ql, this.metadata);
4720+
const derived = await readAcceptedEveryoneSets(ql, this.metadata, this.deploymentAcceptance());
46744721
for (const name of derived.accepted) if (!sets.includes(name)) sets.push(name);
46754722
for (const w of derived.withheld) {
4676-
const line =
4677-
`[security] the everyone anchor does not carry the accepted set '${w.set}' (package '${w.packageId}'): ` +
4678-
`${w.reason}. Its acceptance stays recorded and grants nothing until the set is served again as ` +
4679-
'accepted; a manage_metadata holder can revoke it in Setup.';
4723+
const line = w.source === 'host-configuration'
4724+
? `[security] the everyone anchor does not carry the set '${w.set}' the security plugin's ` +
4725+
`\`acceptedDefaultPermissionSets\` option declares: ${w.reason}. It grants nothing while that holds; ` +
4726+
'remove the name from the option, or serve the set again as an isDefault set some package declares.'
4727+
: `[security] the everyone anchor does not carry the accepted set '${w.set}' (package '${w.packageId}'): ` +
4728+
`${w.reason}. Its acceptance stays recorded and grants nothing until the set is served again as ` +
4729+
'accepted; a manage_metadata holder can revoke it in Setup.';
46804730
if (w.forbidden) logger.error(line, undefined, { set: w.set, package: w.packageId });
46814731
else logger.warn(line, { set: w.set, package: w.packageId });
46824732
}

0 commit comments

Comments
 (0)