Skip to content

Commit 03c9590

Browse files
committed
docs: changeset and checklist step for plain-wildcard coverage
The changeset tells /auth/me/permissions and getEffectiveObjectPermissions readers what the objects slot gains. The access-security parity item gains the wall-less org admin persona, its step and acceptance clause, the producer's source anchor and the unit parity table in automated.ref. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3fdfdeb commit 03c9590

2 files changed

Lines changed: 44 additions & 4 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/core': patch
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 a direct caller of `buildEffectiveObjectPermissions` 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_project is PRESENT with allowEdit:true although no set W holds names it (the organization_admin_no_bypass '*' covers it); 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_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_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 (the covered public object present and writable, the private sys_secret absent and refused), 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)