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
26 changes: 26 additions & 0 deletions .changeset/23385-walled-accepted-defaults.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@objectstack/plugin-security': minor
---

feat(plugin-security): under a walled posture, the deployment-level acceptance of package defaults can be host configuration (`acceptedDefaultPermissionSets`, ADR-0131 D5) (#23385)

Clause-②: yes (widening)

**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.

**What is new.** `SecurityPluginOptions.acceptedDefaultPermissionSets?: readonly string[]` is read only under a walled posture. When it is declared, possibly as `[]`:

- 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;
- 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;
- the record's list, accept and dismiss doors answer `409 SUGGESTION_STATE` naming the option.

```ts
new SecurityPlugin({ acceptedDefaultPermissionSets: [] }); // nothing beyond the baseline
new SecurityPlugin({ acceptedDefaultPermissionSets: ['crm_readonly'] }); // every member also holds crm_readonly
```

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".

**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.

**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'`.
1 change: 1 addition & 0 deletions packages/plugins/plugin-security/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export type {
SuggestionSyncOutcome,
SuggestionReconcileOutcome,
WithheldAcceptance,
DeploymentAcceptance,
} from './suggested-audience-bindings.js';
// [maintainer ruling 2026-08-24 — 「同意 第一步(创业阶段,Salesforce 式)」]
// Lock the base, clone to customize: the write-door provenance rule and its
Expand Down
64 changes: 57 additions & 7 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ import {
confirmAudienceBindingSuggestion,
dismissAudienceBindingSuggestion,
readAcceptedEveryoneSets,
readAcceptedDefaultPermissionSetsOption,
refuseAcceptedDefaultsWithoutWall,
type DeploymentAcceptance,
type SuggestionDeps,
type SuggestionListFilter,
} from './suggested-audience-bindings.js';
Expand Down Expand Up @@ -907,6 +910,34 @@ export interface SecurityPluginOptions {
* @default 'member_default'
*/
fallbackPermissionSet?: string | null;
/**
* [ADR-0131 D5] The package defaults this deployment accepts for the
* `everyone` anchor, declared as host configuration — read ONLY under a
* walled posture (`group` / `isolated`, `postureEnforcesWall`).
*
* Under a wall, platform standing is configuration, and no reserved
* organization owns deployment-level rows (ADR-0131 §1.5). So a walled host
* names here the `isDefault: true` permission sets every signed-in member
* holds beside the baseline. Declared, this list IS the deployment-level
* acceptance: the `everyone` definition derives from the baseline plus these
* sets, the `sys_audience_binding_suggestion` record is neither read nor
* written, and its list / accept / dismiss doors answer 409
* `SUGGESTION_STATE` naming this option. `[]` declares that nothing beyond
* the baseline is accepted.
*
* Each name passes the same checks an accepted suggestion does, on every
* derivation: 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 failing one is not granted and is reported — at
* `error` for a forbidden bit.
*
* Omitted (the default), the acceptance is the record, owned by the Default
* Organization, as before. Declared under the `single` posture it is refused
* at `start()`: there the record is the acceptance (ADR-0131 D5's
* Default-Organization row), and a declaration nothing reads would only
* look enforced.
*/
acceptedDefaultPermissionSets?: readonly string[];
}

/**
Expand Down Expand Up @@ -1166,6 +1197,8 @@ export class SecurityPlugin implements Plugin {
private fieldMasker = new FieldMasker();
private readonly bootstrapPermissionSets: PermissionSet[];
private readonly fallbackPermissionSet: string | null;
/** [ADR-0131 D5] {@link SecurityPluginOptions.acceptedDefaultPermissionSets}, frozen. */
private readonly acceptedDefaultPermissionSets: readonly string[] | undefined;
/**
* [#7555, ADR-0090 D5] The HUMAN baseline, as the list of set names it is:
* {@link fallbackPermissionSet} COMPOSED WITH the platform baseline
Expand Down Expand Up @@ -1479,6 +1512,15 @@ export class SecurityPlugin implements Plugin {
// [#7555] …and the baseline that actually resolves is that name COMPOSED
// with the platform's own, never one displacing the other (ADR-0090 D5).
this.baselinePermissionSets = composeHumanBaselinePermissionSets(this.fallbackPermissionSet);
this.acceptedDefaultPermissionSets = readAcceptedDefaultPermissionSetsOption(options.acceptedDefaultPermissionSets);
}

/**
* [ADR-0131 D5] Where the deployment-level acceptance lives: the posture this
* plugin resolved, and the host's declaration (read only under a wall).
*/
private deploymentAcceptance(): DeploymentAcceptance {
return { posture: this.tenancyPosture, acceptedDefaultPermissionSets: this.acceptedDefaultPermissionSets };
}

async init(ctx: PluginContext): Promise<void> {
Expand Down Expand Up @@ -1895,6 +1937,10 @@ export class SecurityPlugin implements Plugin {
} catch {
/* engine absent — nothing to widen */
}
// [ADR-0131 D5] The host's accepted package defaults are read only under a
// wall; declared under `single` they are refused here, the first moment the
// posture is known, rather than left looking enforced.
refuseAcceptedDefaultsWithoutWall(this.tenancyPosture, this.acceptedDefaultPermissionSets);
// [ADR-0105 D11] Optional app/plugin membership resolver for the §7.3.1
// `IN (current_user.<key>)` sets. Absent is the norm — probe once.
try {
Expand Down Expand Up @@ -2012,6 +2058,7 @@ export class SecurityPlugin implements Plugin {
metadata,
posture: () => this.tenancyPosture,
rederiveEveryone: () => this.declareEveryone(ctx.logger),
acceptedDefaultPermissionSets: this.acceptedDefaultPermissionSets,
logger: ctx.logger,
};
// Typed against the published contract so the registered surface cannot
Expand Down Expand Up @@ -4375,7 +4422,7 @@ export class SecurityPlugin implements Plugin {
if (evt?.type !== 'permission' || evt.state === 'draft') return;
void (async () => {
try {
await reconcileAudienceBindingSuggestions(ql, ctx.logger);
await reconcileAudienceBindingSuggestions(ql, ctx.logger, this.deploymentAcceptance());
} catch (e) {
ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message });
}
Expand Down Expand Up @@ -4471,7 +4518,7 @@ export class SecurityPlugin implements Plugin {
// `manage_metadata` holder's decision — never auto-bound. ONE
// deployment-level pass, owned by the Default Organization (#22801 → A).
try {
await reconcileAudienceBindingSuggestions(ql, ctx.logger);
await reconcileAudienceBindingSuggestions(ql, ctx.logger, this.deploymentAcceptance());
} catch (e) {
ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message });
}
Expand Down Expand Up @@ -4670,13 +4717,16 @@ export class SecurityPlugin implements Plugin {
logger.warn('[security] the everyone anchor does not name a baseline set — high-privilege bits', { set, offending });
});
try {
const derived = await readAcceptedEveryoneSets(ql, this.metadata);
const derived = await readAcceptedEveryoneSets(ql, this.metadata, this.deploymentAcceptance());
for (const name of derived.accepted) if (!sets.includes(name)) sets.push(name);
for (const w of derived.withheld) {
const line =
`[security] the everyone anchor does not carry the accepted set '${w.set}' (package '${w.packageId}'): ` +
`${w.reason}. Its acceptance stays recorded and grants nothing until the set is served again as ` +
'accepted; a manage_metadata holder can revoke it in Setup.';
const line = w.source === 'host-configuration'
? `[security] the everyone anchor does not carry the set '${w.set}' the security plugin's ` +
`\`acceptedDefaultPermissionSets\` option declares: ${w.reason}. It grants nothing while that holds; ` +
'remove the name from the option, or serve the set again as an isDefault set some package declares.'
: `[security] the everyone anchor does not carry the accepted set '${w.set}' (package '${w.packageId}'): ` +
`${w.reason}. Its acceptance stays recorded and grants nothing until the set is served again as ` +
'accepted; a manage_metadata holder can revoke it in Setup.';
if (w.forbidden) logger.error(line, undefined, { set: w.set, package: w.packageId });
else logger.warn(line, { set: w.set, package: w.packageId });
}
Expand Down
Loading
Loading