Repository navigation
Commit fe677ae
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
- docs/qa/platform-checklist/areas
- packages
- core/src/security
- plugins
- plugin-hono-server/src
- plugin-security/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
37 | | - | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2522 | 2522 | | |
2523 | 2523 | | |
2524 | 2524 | | |
2525 | | - | |
| 2525 | + | |
2526 | 2526 | | |
2527 | 2527 | | |
2528 | 2528 | | |
2529 | 2529 | | |
2530 | 2530 | | |
2531 | 2531 | | |
| 2532 | + | |
2532 | 2533 | | |
2533 | 2534 | | |
2534 | 2535 | | |
2535 | 2536 | | |
2536 | 2537 | | |
2537 | 2538 | | |
2538 | 2539 | | |
2539 | | - | |
| 2540 | + | |
| 2541 | + | |
2540 | 2542 | | |
2541 | 2543 | | |
2542 | | - | |
| 2544 | + | |
| 2545 | + | |
2543 | 2546 | | |
2544 | 2547 | | |
2545 | 2548 | | |
2546 | 2549 | | |
2547 | 2550 | | |
2548 | 2551 | | |
2549 | 2552 | | |
| 2553 | + | |
2550 | 2554 | | |
2551 | 2555 | | |
2552 | 2556 | | |
| |||
2588 | 2592 | | |
2589 | 2593 | | |
2590 | 2594 | | |
| 2595 | + | |
| 2596 | + | |
| 2597 | + | |
| 2598 | + | |
| 2599 | + | |
| 2600 | + | |
2591 | 2601 | | |
2592 | 2602 | | |
2593 | 2603 | | |
| |||
2607 | 2617 | | |
2608 | 2618 | | |
2609 | 2619 | | |
2610 | | - | |
| 2620 | + | |
2611 | 2621 | | |
2612 | 2622 | | |
2613 | 2623 | | |
| 2624 | + | |
2614 | 2625 | | |
2615 | 2626 | | |
2616 | 2627 | | |
| |||
2622 | 2633 | | |
2623 | 2634 | | |
2624 | 2635 | | |
| 2636 | + | |
| 2637 | + | |
| 2638 | + | |
| 2639 | + | |
| 2640 | + | |
| 2641 | + | |
2625 | 2642 | | |
2626 | 2643 | | |
2627 | 2644 | | |
| |||
0 commit comments