Repository navigation
Commit d112087
Part of #15204 — stage U2, build. This PR builds the U2 ruling. The
other stages of the card stay open.
Clause-②: yes (narrowing)
## What this does
This PR builds ruling #22801 → A (ruling record 6108478638). A package's
`isDefault: true` permission set reaches every signed-in member only
through ONE deployment-level acceptance by a caller holding
`manage_metadata`, and that acceptance takes effect. Under a wall, only
the platform operator can make it.
- **Accept** (`POST /api/v1/security/suggested-bindings/:id/confirm`)
records the decision on the suggestion row. The security plugin then
re-derives the `everyone` anchor's definition as the baseline plus every
accepted set (`SecurityPlugin.declareEveryone`). It also runs at
`kernel:ready`, on uninstall and on a package-door publish. Every
derivation bumps the metadata epoch, so the grants caches retire.
- **The high-risk gate is on the definition (ADR-0090 D7).** The accept
refuses a set whose definition carries a bit the `everyone` anchor
forbids, with `403 PERMISSION_DENIED` (ADR-0112). The derivation asks
the same `describeAnchorForbiddenBits` again on each run. A set that
later fails it, or that the catalog no longer serves, is withheld. The
withheld set is reported with its reason: at `error` for forbidden bits,
at `warn` otherwise. It is never dropped silently.
- **Reconciliation reads the definition.** "Observed" now means that
`everyone`'s definition names the set. No junction row is read.
- **The acceptance record belongs to the Default Organization.** Every
`sys_audience_binding_suggestion` row is read and written under the
Default Organization's tenant: the one with `slug='default'`, else the
only organization there is. That is the derivation plugin-auth's boot
invariant uses. With none, the surface answers `409 SUGGESTION_STATE`
and names the remedy.
- **The reading of 2c's "sealed".** An acceptance changes the
derivation's input, and no caller writes the anchor's definition. The
plugin, which is the definition's only declarer, re-declares it through
the registry seam it already used at boot. The metadata door still
refuses `PUT /meta/position/everyone` with `403`, and the dogfood pin
asserts this. The reading is written into
`content/docs/permissions/authorization.mdx`, beside 2c's door, and into
the changeset.
- **The spec texts are kept and made true.**
`confirmAudienceBindingSuggestion` keeps "writes the binding", and the
package-level `isDefault` note keeps "an admin confirms in Setup". Each
gains the mechanism it now describes. ADR-0090 D5 is unchanged: packages
suggest, admins confirm, nothing is auto-bound.
**Landing point.** The producer is the one the dispatch named:
`packages/plugins/plugin-security/src/suggested-audience-bindings.ts`,
the `everyone` derivation in `security-plugin.ts`, and the object. The
REST routes and the dispatcher twin change text only.
**What stages 6b-2 and 8 need is delivered.** The module no longer
imports `readDeclared` or `upsertPackagePermissionSet`. It no longer
reads `sys_position`, `sys_permission_set` or
`sys_position_permission_set`. It writes no junction row. Declarations
come from the security catalog
(`securityCatalogReaderOf(ql).list('permission')`), the source the
resolver reads set bodies from.
## Decisions taken inside the ruling (also in the report's
`open_questions`)
1. **`guest`: refused loudly, not honoured.** A confirm of a
`guest`-anchor row answers `409 SUGGESTION_STATE` and states why: the
`guest` anchor's sets are not derived from acceptances, so accepting it
would record a grant that never takes effect. No producer emits a
`guest` suggestion today (only `isDefault` → `everyone`). The four axes:
① zero producers; ② no speculative guest derivation; ③ declared =
enforced, refused rather than silently recorded; ④ no new capability.
2. **Revoke = dismiss on an accepted row.** The dispatch's pin asks for
"back to 403 after revoke". A deployment-wide widening with uninstall as
its only undo would leave a gap. Dismiss now takes a `pending` row
(decline, as before) or an accepted row (revoke, and `everyone` is
re-derived). Paths and envelopes are unchanged. A row only the baseline
names is refused `409`.
3. **Rows written before this PR are never promoted.** A new field,
`deployment_decision` (boolean, default `false`), marks a row a
`manage_metadata` holder resolved. Only accepted rows with that flag
feed the derivation.
- A Default Organization row a tenant administrator confirmed or
dismissed is set back to `pending` by the sync (`reopened`), unless the
baseline already names the set.
- A row owned by another organization, or by none, is not read.
`reconcileAudienceBindingSuggestions` counts it (`unread`) and warns
once per boot or publish. Such rows granted nothing before this PR and
grant nothing now. There is no migration piece (#22601 → B).
4. **All three routes take the same authority, list included.** That
authority is `manage_metadata`, plus the ADR-0095 `PLATFORM_ADMIN` rung
under a wall. The ADR-0126 §5 rule for install-wide rows is read off the
execution context: `systemPermissions` and `posture`, never a position
name.
5. **The #18031 report is kept.** It covers a package record whose
`manifest.permissions` is in the ADR-0025 reading. It still reads the
installed package records, but only to make that report; it never reads
a declaration from them.
## What objectui needs
Paths, request shapes and response envelopes are unchanged: `{ data: {
suggestions, synced } }` and `{ data: { suggestion, bindingCreated } }`.
`synced` gains `reopened`. Two behaviour changes reach
`SuggestedBindingsPanel`:
- a caller without `manage_metadata` (a tenant administrator) now gets
`403 PERMISSION_DENIED` on the list. The panel should not render for
them, or should render the refusal.
- `POST …/dismiss` on a `confirmed` row whose resolver is set revokes
it. The panel lists `pending` only, so no revoke control exists yet.
## Tests
`main` was merged after 6a (#22793) landed, through
`scripts/pm/os-regen-merge.sh`.
- The module conflict was 6a's one comment line, inside a function this
PR deletes; ours was taken.
- The tenant-audit census was regenerated over the merge, and its prose
counts were corrected.
- Row 57 of `system-context.mdx` was re-applied, because the script
takes main's side of that file.
Final head: `d493e22`.
- `plugin-security`, `pnpm exec vitest run` at the final head: 196
files, 3,992 passed, 45 skipped. Typecheck is clean, test typecheck
included.
- `suggested-audience-bindings.test.ts` is rewritten for the
deployment-level record: 37 cases. Each refusal case asserts `code` and
`statusCode`.
- creation, the observed path and pruning, all owned by the Default
Organization;
- a legacy row RE-OPENED and never promoted, and another organization's
row unread and reported;
- a refusal when no Default Organization exists;
- the authority (tenant admin 403, unauthenticated 403, a non-operator
under a wall 403, with the operator and the holder as controls);
- accept → `everyone` re-derived, with an anti-vacuity check that the
set was absent before; `bindingCreated` false on a baseline set;
- the forbidden-bits refusal, and the three #18535 capability-token
cases;
- the different-package refusal, the `guest` refusal, and 404 on another
organization's row;
- the derivation withholding a set that gained a forbidden bit or left
the catalog;
- revoke, and dismiss of an observed row refused;
- the #12981 refusal-accounting cases and the #18031 manifest report,
kept.
- `suggested-audience-bindings-install-path.test.ts` (557 lines) is
deleted. Its whole subject was per-organization rows, which the ruling
retires. Its two pinned `engine-double-contract` rows go with it.
- **Dogfood pin**,
`packages/qa/dogfood/test/suggested-binding-acceptance.dogfood.test.ts`:
a real showcase with a vanilla `SecurityPlugin`, so
`showcase_member_default` is a package suggestion. 5/5 pass:
- control: pending, and the member gets 403;
- a tenant administrator's list and accept get `403 PERMISSION_DENIED`,
and nothing changes;
- a forbidden-bit set's accept gets `403 PERMISSION_DENIED`;
- the admin's accept answers 200 with `bindingCreated: true`.
`everyone`'s definition gains exactly the set and stays
`com.objectstack.plugin-security`'s declaration. No `sys_metadata` row
shadows it, `PUT /meta/position/everyone` still answers 403, and the
member's read goes 403 → 200;
- revoke takes `everyone` back to the baseline, and the member gets 403
again.
- **Ablation** (one-time proof, not kept). The `await
deps.rederiveEveryone()` in the confirm path was replaced through
`scripts/ablation-replace.mjs` (anchor 1 → 0, blob `1c0b0a67` →
`10a90e69`). `plugin-security` was rebuilt, and
`ablation-dist-preflight` found the marker in 2 built files. The dogfood
accept case went red (`expected false to be true`, on `bindingCreated`).
The tool restored the source (blob == HEAD, `git diff HEAD` empty).
After a rebuild, `--absent` confirmed the marker is gone from all 6
built files and the tree is clean.
- **Full dogfood suite** at the final head: 247 files passed, 1 skipped;
2,002 tests passed, 9 skipped.
- Other packages:
- `cli` at the final head: unit, 281 files, 4,185 passed; typecheck
clean.
- Before the merge, at `d99797c`: `rest` 272 files, 5,245 passed;
`runtime` 349 files, 5,038 passed; `client` 51 files, 653 passed; `spec`
(`src/contracts`, `src/security`) 58 files, 762 passed. Typecheck was
clean for all four.
- The merge brought no change to `rest`, `runtime` or `client`. `spec`
`check:generated` is green at the final head: all 14 artifacts are
current.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 125 commands, and all were run.
- Red on the base too: `check:platform-checklist` reports the same 7
problems on base `c4e7fa5` (anchors in the form-intake and attachments
items), measured in a throwaway base worktree. None is in the item this
PR edits.
- The other results, the reconcile with `--ran`, and anything NOT
MEASURED are listed in the report.
- Repaired in this PR: the tenant-audit census, regenerated because the
two retired write sites leave it (233 → 231 sites on the merged base),
with its hand-written prose counts corrected. `system-context.mdx` row
57 is re-anchored to `assertDeploymentAuthority`.
- At the final head `d493e22`: 124 of 125 exit 0. The reconcile reads
"125 derived, 125 run, 0 NOT-MEASURED (a DERIVED zero — all 125 recorded
an exit code and none of them is 3)". eslint (`--no-inline-config`) is
clean on the 13 touched TS files; the repo-wide `pnpm lint` is CI's.
## Changesets
- `.changeset/15204-su2-suggestion-acceptance.md`:
`@objectstack/plugin-security` `minor`, **BREAKING**, `Clause-②: yes
(narrowing)`, with ADR-0087 disposition `not-required
(runtime-interface-only …)`, which the gate verified.
- The behaviour narrowing for tenant administrators is stated as before
and now, with its remedy.
- The exported-surface moves are stated as compiler-carried facts, not
as a FROM → TO block. ADR-0087 D7 refuses a prescription beside
`runtime-interface-only`; the gate refused the first spelling and named
that rule.
- `.changeset/15204-su2-contract-text.md`: `patch` for
`@objectstack/spec`, `client`, `rest` and `cli`. Text only.
- `@objectstack/runtime` changes comments only and takes no changeset.
## Acceptance notes (out of scope; carrier #22619 for the ADR notes)
- **ADR-0090 D9.** "the admin confirms each individually" now means a
`manage_metadata` holder accepts each suggestion once for the
deployment. `guest` suggestions are refused (decision 1).
- **ADR-0131 D3.** "an ADR-0090 D9 suggestion, when accepted, edits the
position's definition instead of inserting a row" holds, read as above:
the acceptance changes the derivation's input, and the plugin
re-declares the definition.
- **ADR-0138 §5.** "An administrator binds a low-privilege set to
`guest`, directly or by confirming a package's suggestion" names two
paths that do not exist on this head. 2c sealed the anchors, so there is
no direct bind, and a `guest` suggestion is refused here.
- `bindBaselineToEveryone` and the junction gate
`assertAudienceAnchorBindingGate` still write and guard junction rows
that grant nothing. 6a's lane (#22793) owns that.
- The publish-time re-derivation of `everyone` sits inside the
`permission` publish materializer (`SecurityPlugin.declareEveryone`
after `reconcileAudienceBindingSuggestions`). Stage 6b-2 deletes that
closure and re-homes the call, for example onto `onMetadataMutation` for
type `permission` (seat answer 6109520247). It also takes the contract
record's flag A there: today the materializer re-derives only when the
publish materialized a row and the body carries `isDefault`.
- _Added by the seat at ACCEPT, from the dev's note; the head is
unchanged._
## Size
Against `main` (`git diff --shortstat origin/main...HEAD`): 23 files,
+1,224 / −1,599, so 2,823 changed lines. Test deletions and regenerated
files are included. That is under the 3,000 cap.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01D4f5gJKWdzmeZP57AVkRJd)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 581ca7d commit d112087
23 files changed
Lines changed: 1224 additions & 1599 deletions
File tree
- .changeset
- content/docs/permissions
- docs
- audits
- qa/platform-checklist/areas
- packages
- client/src
- cli/src/utils
- plugins/plugin-security
- src
- objects
- qa/dogfood/test
- rest/src
- runtime/src/domains
- spec/src
- contracts
- security
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
428 | 428 | | |
429 | 429 | | |
430 | 430 | | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
431 | 443 | | |
432 | 444 | | |
433 | 445 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
253 | 253 | | |
254 | 254 | | |
255 | 255 | | |
256 | | - | |
257 | | - | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
258 | 259 | | |
259 | 260 | | |
260 | 261 | | |
261 | 262 | | |
262 | 263 | | |
263 | | - | |
264 | | - | |
265 | | - | |
266 | | - | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
267 | 269 | | |
268 | 270 | | |
269 | 271 | | |
270 | | - | |
271 | | - | |
| 272 | + | |
| 273 | + | |
272 | 274 | | |
273 | 275 | | |
274 | 276 | | |
275 | 277 | | |
276 | 278 | | |
277 | 279 | | |
278 | 280 | | |
279 | | - | |
280 | | - | |
281 | | - | |
282 | | - | |
283 | | - | |
284 | | - | |
285 | | - | |
286 | | - | |
287 | | - | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
288 | 305 | | |
289 | 306 | | |
290 | 307 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
182 | 182 | | |
183 | 183 | | |
184 | 184 | | |
185 | | - | |
| 185 | + | |
186 | 186 | | |
187 | 187 | | |
188 | 188 | | |
| |||
0 commit comments