Skip to content

Commit fe677ae

Browse files
fix(core): the effective object-permission map covers a plain * grant, so current_user.can() agrees with checkObjectPermission for a wall-less org admin (#20083) (#20132)
Fixes #20083 Clause-②: yes (widening) The effective object-permission map that `/auth/me/permissions` serves as `objects` and that `ISecurityService.getEffectiveObjectPermissions` hands to `current_user.can()` now covers what a plain `'*'` grant covers. A plain wildcard is one with neither super-user bit. Before, a wall-less org admin (`organization_admin_no_bypass`) had no entry for an object reached only through that wildcard. `can('crm_account', 'edit')` answered `false`, while `PermissionEvaluator.checkObjectPermission('update', 'crm_account', sets)` answered `true`. The fix is at the producer, `buildEffectiveObjectPermissions` in `@objectstack/core`. Formula's `can()` is untouched, and so is its 「absent = no grant」 rule. No `engine.ts` plumbing is touched either. ## What changes A new step, `materializePlainWildcardCoverage` (module-private), runs after the super-user seed and before the fold. For each registered object it applies each set's plain `'*'` the way `resolveObjectPermission` resolves that set: - only registered objects, and only public ones (`access.default` is not `'private'`), because the server refuses an object whose posture it cannot resolve; - a set that names the object contributes nothing more, since its explicit entry is that set's whole answer; - another set's plain wildcard widens an entry that is already present, bit by bit; - only `true` grant bits are copied (`allowRead`, `allowCreate`, `allowEdit`, `allowDelete`, `allowTransfer`, `allowExport`); - an entry the step would ADD is dropped when it grants no verb on its own, so an export-only wildcard adds no entries. A super-user wildcard is left to the seed and the fold, exactly as before. The step reads `access` off each `allSchemas` entry. The source's element type therefore gains an optional `access?: unknown` member. The package exports no new name for it. This is why the declaration line above reads `yes (widening)` where the claim reads `no`, and why the changeset grades `@objectstack/core` `minor`. See **Clause-②** below. ## For `domain:cli` (the route) and `domain:services` (plugin-security) No code in `plugin-hono-server` or `plugin-security` changes, only their pins. What their readers see: - **`GET /auth/me/permissions` → `objects`.** A subject holding a plain wildcard gains one entry for every registered public object the wildcard covers that had none. Each new entry is annotated with `apiOperations` by the same rule as every other entry. An existing entry may gain `true` bits. Nothing is removed. The shape, the keys and the route are unchanged. - **`ISecurityService.getEffectiveObjectPermissions`.** The same map, since it is the same function. The engine's `can()`-gated option write path therefore admits the wall-less org admin wherever the data plane does. - **Byte-identity.** Subjects holding no plain wildcard get a byte-identical response: `admin_full_access`, a walled `organization_admin`, and `member_default` alone. `admin_full_access` + `organization_admin_no_bypass` + `member_default` was byte-identical too in both measurements below. ## Measurement The real `can()` is formula's `ExpressionEngine` over `toEvalPermissions(map)`. It is compared with `PermissionEvaluator.checkObjectPermission` for every verb the vocabulary accepts: `create`, `delete`, `edit`, `export`, `import`, `read`, `remove`, `transfer`, `update`, `write`. `over` means the map grants a verb the evaluator refuses. `under` means the map refuses a verb the evaluator grants. `under` does not count create/edit/delete refused on a guarded managed object: the managed-write clamp narrows those on purpose. **Live showcase.** Measured on `bootStack(showcase)`, with the real `security` service and the real registry, which holds 78 objects (780 cells per subject). The base leg is this head with only the coverage call ablated and `dist/` rebuilt. Apart from comments, types, the now-uncalled helpers and a hoisted `allSchemas` read, it behaves like base `7b27bd00c7`. | subject (resolved sets) | entries | under | over | bytes | |---|---|---|---|---| | wall-less org admin (`showcase_member_default` + `organization_admin_no_bypass` + `member_default`) | 51 → 80 | 215 → **0** | 0 → 0 | 11885 → 16927 | | viewer (`viewer_readonly` + baselines) | 46 → 80 | 34 → **0** | 0 → 0 | 10471 → 15500 | | walled org admin (`organization_admin` + baselines) | 81 → 81 | 45 → 45 | 38 → 38 | identical | | platform admin (`admin_full_access` + baselines) | 81 → 81 | 78 → 78 | 0 → 0 | identical | | member (baselines only) | 45 → 45 | 0 → 0 | 0 → 0 | identical | For the wall-less org admin on the showcase: - `showcase_semantic_zoo` is named by no set. It was ABSENT and is now `{allowCreate, allowRead, allowEdit, allowDelete: true}`. - `showcase_project` is named read-only by `showcase_member_default`. It read `allowEdit: false` and now reads `true`, because the `organization_admin_no_bypass` wildcard applies to it for that set. - `sys_secret` is private, and it stays absent. **Unit fixture, base `7b27bd00c7` against head.** This run uses the real shipped sets from `defaultPermissionSets`. The registry holds 52 platform objects, 7 plugin-security objects and 4 app objects: `crm_account` (public), `crm_lead` (restricted by `apiMethods`), `crm_secret` (private) and `crm_hidden` (`apiEnabled: false`). The map was read three ways: from the direct producer, from the real `/auth/me/permissions` handler and from the real SecurityPlugin member. All three were byte-equal in every row. | subject | under | over | |---|---|---| | `organization_admin_no_bypass` + `member_default` | 106 → **0** | 0 → 0 | | `viewer_readonly` + `member_default` | 32 → **0** | 0 → 0 | | explicit `crm_account: read` beside another set's `'*': read, edit, export` | 171 → **0** | 0 → 0 | | ONE set with `'*': read, edit, delete` and explicit `crm_account: read` | 144 → **0** (`crm_account` edit stays refused on both sides) | 0 → 0 | | explicit `crm_account: read` beside `'*': export` | 1 → **0** | 0 → 0 | | walled `organization_admin`, `admin_full_access`, `member_default`, the dev owner's three sets, an all-false `'*'` | unchanged, byte-identical | unchanged | **Write path.** This used the real ObjectQL engine, with the SecurityPlugin member as its resolver, and an option gated on `current_user.can('crm_account', 'edit')`: - wall-less org admin: refused `VALIDATION_FAILED` / `invalid_option` with 0 rows at base; admitted with 1 row and 0 warns at head; - member, and the one-set-narrower subject: refused at both, where the evaluator also refuses. ## Clause-② - **Behavioural accept set, against the last release.** The last release is `17.4.0`, from 2026-09-09 (npm). `current_user.can` shipped in no release: `.changeset/18545-formula-can-permission-predicate.md` and `.changeset/18783-server-can-option-visibility.md` are both still pending on `origin/main`. Nothing moves relative to a release. - **Public type.** `buildEffectiveObjectPermissions`' schema source gains an optional `access?: unknown` on its `allSchemas` element. A reverse check shows `tsc` in plugin-security reads the rebuilt `dist/index.d.ts`. A value typed as that element carrying `access` is accepted, and the same value carrying an undeclared `posture` key is refused TS2353, naming `ApiExposureSchemaLike & ObjectAccessPostureLike`. Per the #18783 precedent, where an added optional member was graded a public widening, this reads `yes (widening)`, and core is `minor`. The fixed group already goes minor in the next release through the pending #18545 / #18783 changesets. ## Consumers of the response No non-test code in `packages/` or `examples/` requests `/auth/me/permissions`: the one hit is the route's own registration. As a control, the same spelling finds 21 lines in 8 test files. The member's one consumer is the engine resolver the security plugin registers. Every in-repo reader stayed green (suites below). objectui's `can()` is **UNMEASURED**. The sibling repo is not reachable here, and `packages/console` holds no bundle. ## Tests run, at head `403f653799` with `dist/` rebuilt The suites and typechecks below ran as one `&&` chain under the verify lock: `VERDICT command-exit 0`. - `pnpm --filter @objectstack/core test`: 53 files, 1341 tests passed. - `pnpm --filter @objectstack/plugin-security test`: 135 files, 2685 tests passed. - `pnpm --filter @objectstack/plugin-hono-server test`: 27 files, 317 tests passed. - `typecheck` for core, plugin-security and plugin-hono-server: exit 0, test layers included. - Dogfood, 9 files, against the `dist/` closure built from this code: `organization-update-door`, `me-apps-and-everyone-baseline`, `showcase-permission-projection`, `showcase-permission-seeding`, `showcase-permission-zoo`, `two-doors-permission`, `comments-permission-matrix`, `attachments-permission-matrix` and `authz-conformance`. Result: 126 passed, 1 skipped. - New pins: - core `effective-object-permissions.test.ts` (15 tests); - plugin-security `get-effective-object-permissions.test.ts` (19 tests). This includes the table-driven parity pin: plain-wildcard subjects × registered objects × every verb, the real `can()` against `checkObjectPermission`; - plugin-hono-server `current-user-endpoints-effective-objects.test.ts` (3 tests). Its route byte-equality pin now exercises the new step. **Ablation.** The mutation removed the coverage call, placing a marker instead: - It was taken at head `403f653799` through `scripts/ablation-replace.mjs`: anchor 1 → 0, blob `f8e0efad` → `80ea1874`. - `dist/` was rebuilt, and `ablation-dist-preflight` found the marker in 2 built files. - Observed direction: red. - plugin-security: 7 failed, 12 passed. Every plain-wildcard parity row failed, and the reported case failed. The member and all-false rows stayed green. - core: 6 failed, 9 passed. - hono: 1 failed, 2 passed. - Restore: the blob equals HEAD, `git diff HEAD` is empty, and `git status --porcelain` is clean. After a rebuild the marker is absent from `dist/` and the call spelling is present in 2 built files. All three suites are green again: 19, 15 and 3 passed. The first restore attempt was a queue timeout (exit 99, never acquired), and it was re-taken with the same slot. ## Gates, at head `403f653799` - The list comes from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, which derived 66 commands for this diff. I ran each one and recorded its exit code. - **66 of 66 exited 0.** One needed a second run: `pnpm check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3) because 8 unrelated packages had no `dist/`, which is NOT MEASURED rather than red. After building those 8 packages it answered: 105 published require entry points across 67 packages load, 660 emitted CommonJS files parse, 1 cross-format probe agrees. - `dispatch-gates --ran`: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: every family recorded an exit code). - `node scripts/check-issue-citations.mjs --base 7b27bd0`: exit 0, 5 citations resolve. - The CI-only families that `dispatch-gates` names outside this list (Test Core, Dogfood, Build Core, Temporal, the type-check lanes) are **NOT MEASURED** locally, because CI owns them. So is the repo-wide `pnpm lint`. ## Patch round 1, at head `228724b3f3` (seat rulings on the report's two open questions) Written by session `session_01Bvd69VPa6puiNzzPUroDBx`, the same session that opened this PR. - **Q2 → A.** `Clause-②: yes (widening)` stands, and `@objectstack/core` stays `minor`, on the #18783 precedent for an added optional member of a published parameter type. - **Q1 → A, done in this PR.** The one commit on top of `403f653799` rewrites only the "Known gap, not changed here" paragraph of `.changeset/18783-server-can-option-visibility.md`. Every other line of that file is byte-identical: `git diff` shows 1 line removed and 1 added. Nothing else changed, and the branch was not merged with `main`. - **Gates re-run at `228724b3f3`** (exit codes): - `node scripts/check-empty-changeset.mjs --base origin/main`: **1**. This is the foreign-changeset rule refusing `.changeset/18783-server-can-option-visibility.md`, which is expected; see the first Acceptance note. - exit 0: the `--self-test` of `check-empty-changeset`; `check-changeset-no-major --base origin/main` and its `--self-test` ("This diff introduces no `major` bump"); `check-adr-0087-registration --base origin/main` and its `--self-test` ("2 non-breaking changeset(s) seen"). - exit 0: `pnpm check:changeset-gate-self-tests`, `pnpm check:objectui-changeset`, `pnpm check:pm-changeset-deadline-census`, `pnpm check:doc-authoring`, `pnpm check:nul-bytes`, `pnpm check:issue-citations`. - exit 0: `node scripts/check-issue-citations.mjs --base 7b27bd0` (5 citations resolve). - `dispatch-gates --commands` at this head derives the same 66 commands as at `403f653799`. No code, test or checklist file moved, so the full-suite, ablation and gate readings above stand for the code. ## Acceptance notes The parity table also shows three defects that were there before this PR. This PR leaves them unchanged, and none of them is fixed here. - **The super-user fold over-grants within one set** (walled `organization_admin`: 38 over cells, fixture and showcase alike). `foldWildcardSuperUser` folds the merged `'*'` bypass into every entry, including entries that the super-user set itself names narrower. `resolveObjectPermission` answers that set with its explicit entry. - The map therefore grants create, edit, delete and import on `sys_position`, `sys_permission_set`, `sys_position_permission_set`, `sys_user_permission_set` and `sys_user_position`, and edit on `sys_organization`, where the evaluator refuses. - On the write path, an option gated on `current_user.can('sys_position', 'edit')` is ADMITTED for a walled org admin (measured), while `checkObjectPermission('update', 'sys_position')` is `false`. - The fold's docblock says it is "exactly as broad as real enforcement — never broader". - **The super-user entries never carry the wildcard's own bits.** `can(X, 'transfer')` is `false` for `admin_full_access` and a walled `organization_admin` on every object, where `checkObjectPermission('transfer')` is `true`: 63 fixture cells and 78 showcase cells. The seed initialises entries all-false, and the fold lifts only read, create, edit and delete. The same holds for a super-read wildcard carrying plain bits too. This direction fails closed. - **`apiOperations` ignores `enable.apiEnabled: false`.** An object declaring it with no `apiMethods` is annotated with the full operation list, while REST answers 404 for it. This is pre-existing on seeded and explicit entries, and no example app declares such an object. - **DELIBERATE CORRECTION of a pending release note: `.changeset/18783-server-can-option-visibility.md`** (seat ruling Q1 A). That changeset landed with #20079 and is still unreleased. Its "Known gap, not changed here" paragraph describes exactly the plain-wildcard gap this PR closes, and would have shipped false in the same release. The paragraph is rewritten so that every sentence is true at this head. It names this PR's changeset and states the remaining super-user divergence in one neutral sentence, without claiming a fix. `check-empty-changeset` / Check Changeset goes **red on the foreign-changeset rule by design**. Per `landing-operations.md`, a contract-tier review PASS on this same head is what confirms a DELIBERATE CORRECTION red; the class is **not** a COLLISION, so the base text must **not** be restored. - Base text, at `7b27bd00c7` (unchanged since `0318faf692` landed). The file writes the object placeholder inside angle brackets; it is written here as `THAT_OBJECT` because the GitHub body sanitizer drops angle-bracket fragments: > **Known gap, not changed here.** `can()` reads only the per-object entries of the map, and `/auth/me/permissions` lists an object for a `'*'` wildcard grant only when that grant carries a super-user bit. So a subject whose access to an object comes only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — gets `false` from `current_user.can('THAT_OBJECT', …)`, although the data plane admits the write. Before this release such a gate was never enforced for anyone; after it, that population is refused on a `can`-gated option. Any client that answers `can()` from the same `/auth/me/permissions` map gets the same `false`. - Head text, at `228724b3f3`: > **Plain-wildcard coverage, closed in this release.** `can()` reads only the per-object entries of the map. Before #20083, `/auth/me/permissions` listed an object for a `'*'` wildcard grant only when that grant carried a super-user bit, so a subject whose access to an object came only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — got `false` from `current_user.can()` for that object, although the data plane admits the write, and was refused on a `can`-gated option. That gap is closed in this same release by #20083 (`.changeset/20083-effective-map-plain-wildcard.md`): `buildEffectiveObjectPermissions` now puts each set's plain `'*'` on the registered public objects that set does not name, so that population's map — and any client that answers `can()` from the same `/auth/me/permissions` map — carries an entry for each object the wildcard covers, with the wildcard's grants, narrowed on a guarded managed object by the same managed-write clamp as every other entry. The map still differs from `PermissionEvaluator.checkObjectPermission` for subjects holding a super-user wildcard: an entry the super-user set itself names narrower can read as granted, and an entry reached through a super-user wildcard carries no `transfer`. - Left byte-identical as ruled, and flagged for the reviewer: that changeset's sentence "The `/auth/me/permissions` response is byte-identical for the same resolved sets (measured on five fixtures against the previous build)." It describes #20079's move of the merge into core, and it holds for that move. Read against the previous release, though, the response of a plain-wildcard subject now changes in this same release, through this PR. - **Branch base.** The branch is 4 commits behind `origin/main` (`7a13e0562a`, `7c1039b388`, `55daf89d74`, `226e00c038`). They touch service-analytics, driver-turso and the pm-dispatch skill, and none of their paths is in this diff's packages or their dependency closure. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 586934e commit fe677ae

7 files changed

Lines changed: 508 additions & 29 deletions

File tree

‎.changeset/18783-server-can-option-visibility.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,6 @@ stage: Field.select({
3434
- If the security service cannot resolve the map, a write that needs it is refused with the resolution's own error — fail closed. It is never read as "no grants".
3535
- With no security plugin, or an engine older than the seam, there is no permission data. The gate stays unevaluable and the value is admitted with the same `warn` as before, which names the missing input. The security plugin logs one `warn` at start when the engine lacks the seam.
3636

37-
**Known gap, not changed here.** `can()` reads only the per-object entries of the map, and `/auth/me/permissions` lists an object for a `'*'` wildcard grant only when that grant carries a super-user bit. So a subject whose access to an object comes only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — gets `false` from `current_user.can('<that object>', …)`, although the data plane admits the write. Before this release such a gate was never enforced for anyone; after it, that population is refused on a `can`-gated option. Any client that answers `can()` from the same `/auth/me/permissions` map gets the same `false`.
37+
**Plain-wildcard coverage, closed in this release.** `can()` reads only the per-object entries of the map. Before #20083, `/auth/me/permissions` listed an object for a `'*'` wildcard grant only when that grant carried a super-user bit, so a subject whose access to an object came only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — got `false` from `current_user.can()` for that object, although the data plane admits the write, and was refused on a `can`-gated option. That gap is closed in this same release by #20083 (`.changeset/20083-effective-map-plain-wildcard.md`): `buildEffectiveObjectPermissions` now puts each set's plain `'*'` on the registered public objects that set does not name, so that population's map — and any client that answers `can()` from the same `/auth/me/permissions` map — carries an entry for each object the wildcard covers, with the wildcard's grants, narrowed on a guarded managed object by the same managed-write clamp as every other entry. The map still differs from `PermissionEvaluator.checkObjectPermission` for subjects holding a super-user wildcard: an entry the super-user set itself names narrower can read as granted, and an entry reached through a super-user wildcard carries no `transfer`.
3838

3939
**No spec key, route or config key is added or removed.**
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/core': minor
3+
---
4+
5+
fix(core): the effective object-permission map covers what a plain `'*'` grant covers, so `current_user.can()` agrees with the server for a wall-less org admin (#20083)
6+
7+
`buildEffectiveObjectPermissions` builds the `objects` slot of `GET /auth/me/permissions` (`@objectstack/plugin-hono-server`) and the map `ISecurityService.getEffectiveObjectPermissions` returns (`@objectstack/plugin-security`), which the engine hands to `current_user.can(object, verb)` on the write path. It merged each permission set's EXPLICIT entries and kept `'*'` as a key of its own. The server's check does not stop there: `PermissionEvaluator.checkObjectPermission` resolves each set to its explicit entry for the object when it has one, and otherwise to its `'*'` — for a public object always, for a private one only when the wildcard carries a super-user bit. So an object reached only through a plain wildcard (no `viewAllRecords` / `modifyAllRecords`) had no entry in the map, and `can()` — which reads an absent entry as "no grant" — answered `false` where the server allows.
8+
9+
The population it hit: `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins. With it and `member_default`, `current_user.can('crm_account', 'edit')` was `false` while the data plane accepted the edit, so a `can()`-gated option was refused on the write path, and a client that answers `can()` from `/auth/me/permissions` got the same `false`. `viewer_readonly` read the same way (`read` on every object it covers).
10+
11+
**What changes.** A new step in `buildEffectiveObjectPermissions`, after the super-user seed and before the wildcard fold, applies each set's plain `'*'` to the registered objects that set does not name:
12+
13+
- only registered objects, and only public ones (`access.default` other than `'private'`);
14+
- a set that names the object keeps its explicit entry as its whole answer, as on the server;
15+
- another set's plain wildcard widens an entry that is already present, bit by bit;
16+
- only `true` grant bits are copied; a wildcard's `false` or unset bit adds nothing;
17+
- an object the step would add, but whose entry grants no verb on its own, is left out.
18+
19+
The step reads `name` and `access.default` off the `allSchemas` entries, so the element type of `allSchemas` on `buildEffectiveObjectPermissions`' schema source gains an optional `access?: unknown` member (the package exports no new name for it). That is a type widening only: every call that compiled before still compiles, and a schema literal carrying `access` now does too. A direct caller passes the registered schemas themselves there, as both in-repo callers do; an entry without `access` reads as public, exactly as the server reads it.
20+
21+
**What a reader of `/auth/me/permissions` sees.** For a subject holding a plain wildcard, `objects` gains an entry for every registered public object the wildcard covers that had none, annotated with `apiOperations` by the same rule as every other entry. An entry that was already there may gain `true` bits. Nothing is removed. For a subject holding no plain wildcard — `admin_full_access`, a walled `organization_admin`, `member_default` alone — the response is byte-identical to before. The response shape, its keys and the route are unchanged.
22+
23+
This closes the known gap that the `current_user.can()` write-path entry in this release describes: `organization_admin_no_bypass` now reads `true` from `can()` where the data plane allows.

‎docs/qa/platform-checklist/areas/access-security.json‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2522,31 +2522,35 @@
25222522
"title": "The /auth/me/permissions aggregation (and its /auth/me/localization + /me/apps siblings) mirrors server-side enforcement in both directions — and the trio's anonymous 200 is the deliberate exception to the 401 floor",
25232523
"since": "v15",
25242524
"status": "active",
2525-
"revision": 1,
2525+
"revision": 2,
25262526
"priority": "P1",
25272527
"surface": "api",
25282528
"personas": [
25292529
"contributor member C (showcase_contributor — carries the FLS grant on showcase_project budget figures, the field-parity fixture)",
25302530
"plain member P (member_default only — the withheld-verb contrast)",
25312531
"admin (the entitled /me/apps contrast)",
2532+
"wall-less org admin W (organization_admin_no_bypass + member_default, NO admin_full_access — the plain-wildcard population: its only '*' carries no viewAllRecords/modifyAllRecords bit)",
25322533
"anonymous (the designed-200 exception)"
25332534
],
25342535
"fixtures": {
25352536
"app": "showcase",
25362537
"requires": [
25372538
"showcase_contributor: allowEdit on showcase_project plus FLS budget/spent/budget_remaining readable:true/editable:false (permission-sets.ts) — the same fixture access-security.fls-mask-and-strip drives; this item asserts the AGGREGATION agrees with that enforcement, not the enforcement itself",
25382539
"member_default WITHOUT allowEdit on showcase_project — compute the ADR-0090 D5 baseline union per access-security.crud-permission-matrix's knownGaps before picking the withheld verb, or a baseline-granted verb will read as an aggregation violation",
2539-
"an app whose requiredPermissions a plain member lacks (the setup built-in requires setup capabilities admin_full_access carries) — the /me/apps contrast pair"
2540+
"an app whose requiredPermissions a plain member lacks (the setup built-in requires setup capabilities admin_full_access carries) — the /me/apps contrast pair",
2541+
"W: a fresh user who owns or administers an organization under the stock wall-less posture, where auto-org-admin-grant assigns organization_admin_no_bypass (not organization_admin); confirm permissionSets in W's own map before scoring — a W that also resolves admin_full_access is the super-user population, not this one"
25402542
],
25412543
"knownGaps": [
2542-
"the SecurityPlugin-absent fail-open branch (current-user-endpoints.ts empty-but-authenticated body; /me/apps failOpen returning every app) has no showcase fixture — a stack without SecurityPlugin is a different boot. Declared boundary; do not score it here"
2544+
"the SecurityPlugin-absent fail-open branch (current-user-endpoints.ts empty-but-authenticated body; /me/apps failOpen returning every app) has no showcase fixture — a stack without SecurityPlugin is a different boot. Declared boundary; do not score it here",
2545+
"showcase declares no access.default:'private' app object, so W's private-object contrast runs on the platform's sys_secret (private, named by no shipped set) — it proves the plain wildcard's posture boundary, not an app-authored one"
25432546
]
25442547
},
25452548
"steps": [
25462549
"boot showcase isolated; provision C (grant showcase_contributor via sys_user_permission_set), P (baseline only), admin; use a FRESH http client per persona (cache-staleness)",
25472550
"as C: GET /api/v1/auth/me/permissions — record objects.showcase_project, fields['showcase_project.budget'], permissionSets, systemPermissions",
25482551
"verb parity, both directions: as C PATCH a showcase_project name (the map says allowEdit) — 2xx; as P read P's own map (allowEdit absent/false on showcase_project) then issue the identical PATCH — 403 PERMISSION_DENIED",
25492552
"field parity: C's map says budget editable:false — C's budget PATCH is refused/stripped with the stored value unchanged (the fls-mask-and-strip oracle; cross-check only, do not re-score that item here)",
2553+
"plain-wildcard parity: as W, GET /api/v1/auth/me/permissions — objects.showcase_semantic_zoo is PRESENT with allowEdit:true although no set W holds names it (the organization_admin_no_bypass '*' covers it), and objects.showcase_project reads allowEdit:true although showcase_member_default names it read-only (another set's plain '*' widens a named entry, because the server resolves each set on its own); create a showcase_project as W, then PATCH its name — 2xx (W's own row, so row-level scope, which this item does not score, cannot decide it); then objects.sys_secret is ABSENT (private: a plain '*' does not reach it) and W's GET /api/v1/data/sys_secret answers 403 PERMISSION_DENIED (its apiMethods offer list, so the refusal is the permission layer's, not the exposure layer's)",
25502554
"as P then admin: GET /api/v1/me/apps — P's list excludes the capability-gated app and includes showcase_app; admin's includes it; for every returned app verify requiredPermissions ⊆ the caller's merged systemPermissions",
25512555
"as any member: GET /api/v1/auth/me/localization — the resolved currency/locale/timezone",
25522556
"anonymous (no Authorization header, fresh client): GET all three endpoints and capture status + body",
@@ -2588,6 +2592,12 @@
25882592
"oracle": "api",
25892593
"verify": "member trace carries authenticated:true plus the three keys, with timezone and locale non-null; configure localization.currency and localization.timezone and re-trace — both must move to the configured values (an unmoved trace is the #15387 defect, not a pass)",
25902594
"evidence": "the trace"
2595+
},
2596+
{
2597+
"clause": "the plain-wildcard population holds the same parity: for W, whose only '*' carries no super-user bit, the map carries an entry for every registered PUBLIC object that '*' covers and no set of W's names (showcase_semantic_zoo), widens an entry another set names narrower (showcase_project reads allowEdit:true and W's PATCH of its own row is 2xx), and carries NONE for a private object the plain '*' does not reach (sys_secret absent, W's read 403) — an absent entry reads as 'no grant' to current_user.can(), so a missing covered object is an under-claim FAIL against the endpoint, and an entry for the private one an over-claim FAIL",
2598+
"oracle": "api",
2599+
"verify": "W's /auth/me/permissions objects.showcase_semantic_zoo, objects.showcase_project and objects.sys_secret against W's live PATCH and GET outcomes; permissionSets in the same body must list organization_admin_no_bypass and must NOT list admin_full_access, or the persona is wrong and the clause is not scored",
2600+
"evidence": "W's map + the PATCH and GET traces"
25912601
}
25922602
],
25932603
"negative": [
@@ -2607,10 +2617,11 @@
26072617
],
26082618
"automated": {
26092619
"kind": "dogfood",
2610-
"ref": "packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts (the /me/apps half: member sees showcase, requiredPermissions gates, anonymous [], tabPermissions hidden drop and more-visible grant wins) + plugin-hono-server unit suites hono-current-user-endpoints.test.ts / current-user-endpoints-additive-baseline.test.ts / current-user-endpoints-position-grants.test.ts / current-user-endpoints-delegated-resolution.test.ts. STILL MANUAL: the live parity cross-check of the returned maps against actual enforcement responses (clauses 1-2) and the self-scoping probe"
2620+
"ref": "packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts (the /me/apps half: member sees showcase, requiredPermissions gates, anonymous [], tabPermissions hidden drop and more-visible grant wins) + plugin-hono-server unit suites hono-current-user-endpoints.test.ts / current-user-endpoints-additive-baseline.test.ts / current-user-endpoints-position-grants.test.ts / current-user-endpoints-delegated-resolution.test.ts + plugin-security get-effective-object-permissions.test.ts (the plain-wildcard parity table behind the plain-wildcard clause: shipped and authored plain-'*' subjects x registered objects x every can() verb, the map read through the real can() against PermissionEvaluator.checkObjectPermission — a unit oracle over fixture schemas, not the live stack). STILL MANUAL: the live parity cross-check of the returned maps against actual enforcement responses (clauses 1-2 and the plain-wildcard clause) and the self-scoping probe"
26112621
},
26122622
"source": [
26132623
"packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#tabPermissions (/auth/me/permissions aggregation + most-permissive merge), (/auth/me/localization), (/me/apps requiredPermissions/tabPermissions filter), (the /api/v1 prefix)",
2624+
"packages/core/src/security/effective-object-permissions.ts#buildEffectiveObjectPermissions (the objects slot: explicit merge, super-user seed, plain-wildcard coverage, fold, managed-write clamp, apiOperations annotation — the one function the route and ISecurityService.getEffectiveObjectPermissions share)",
26142625
"packages/core/src/security/auth-gate.ts#ALLOW_ROUTES (ALLOW_ROUTES — /me/apps + /me/localization reachable to gated users. Was ALLOW_SUFFIXES, an endsWith test that also exempted any path merely ENDING in those two — /data/xyz/me/apps among them; the allow-list is anchored to a mount base now, so these are EXACT routes at a mount and a record id can no longer spell its way into the exemption)",
26152626
"#7616 (delegated permission-set resolution — the enforcement path's own answer), #2752 (/me/apps registry sourcing), #3391 (effective apiOperations annotation), #4093 (guarded degraded branch), ADR-0090 D5 (additive baseline)",
26162627
"cross-ref access-security.anonymous-deny-surfaces — the 401 floor this trio is the declared exception to",
@@ -2622,6 +2633,12 @@
26222633
"date": "2026-08-30",
26232634
"change": "new — the raw-mounted current-user trio (/auth/me/permissions, /auth/me/localization, /me/apps) is the console's whole permission layer and appeared in no ledger and no checklist item: aggregation-vs-enforcement parity was untested in either direction, and the deliberate anonymous-200 design (distinct bodies per endpoint: authenticated:false for two, apps:[] for the third — corrected from the sweep register, which implied one shape for all three) was unpinned and at risk of being 'fixed' into a 401",
26242635
"ref": "#sweep-2026-08-30"
2636+
},
2637+
{
2638+
"revision": 2,
2639+
"date": "2026-09-25",
2640+
"change": "the parity steps never reached the population the map got wrong: a wall-less org admin (organization_admin_no_bypass, a plain '*' with no super-user bit) had NO entry for an app object covered only by that wildcard, so current_user.can() answered false where checkObjectPermission allowed. Added persona W, its step and acceptance clause (an object no set names present and writable, a narrower named entry widened, the private sys_secret absent and refused — the shape measured on a booted showcase), the source anchor for the producer, and the unit parity table to automated.ref",
2641+
"ref": "#20083"
26252642
}
26262643
]
26272644
},

0 commit comments

Comments
 (0)