Repository navigation
Commit e2c4e12
Fixes #20136
Clause-②: no
The effective object-permission map (`buildEffectiveObjectPermissions`
in `@objectstack/core`: the `objects` slot of `GET
/auth/me/permissions`, and what
`ISecurityService.getEffectiveObjectPermissions` hands to
`current_user.can()` on the write path) no longer grants any cell that
`PermissionEvaluator.checkObjectPermission` refuses, for every
super-user shape measured. This completes the family's closing
assertion: 0 over-granted and 0 under-granted cells over every
super-user subject in the parity table.
## What was wrong
Before its per-set super-user fold, the builder ran
`foldWildcardSuperUser` over the MERGED map. That pass put the merged
bypass bits on every entry:
- into an entry the super-user set names ITSELF, where
`resolveObjectPermission` answers that set with its explicit entry (the
walled `organization_admin`'s read-only RBAC rows and write-denied
identity tables, an explicit `{}` entry, an export-only entry);
- `allowCreate` on `modifyAllRecords` alone, which the spec's
`objectPermissionGrants` gives no create cell.
Mechanism hypotheses H3 are both CONFIRMED, by reading and by
measurement: removing that pass alone takes every row below to 0, and
adds 0 under-granted cells anywhere.
## What changes
`packages/core/src/security/effective-object-permissions.ts`:
- `buildEffectiveObjectPermissions` no longer calls
`foldWildcardSuperUser`. The super-user fold is
`foldSuperUserWildcardGrants` alone (added by #20134): per set, onto the
entries that set does not name, exactly the bits
`objectPermissionGrants` says that set's wildcard grants. Another set's
super-user wildcard still widens an entry bit by bit, as
`checkObjectPermission` combines sets. The seed, plain-wildcard
coverage, clamp and annotation are untouched.
- `foldWildcardSuperUser` keeps its name, signature and body. It is a
released export of `@objectstack/plugin-hono-server` (re-exported from
core). Its docblock now says it is not a step of the map, and names
where it is broader (own narrower entry, create) and narrower
(`transfer`, a super-user wildcard's own plain bits) than the server.
Retiring it is an export removal, so it is left as an open question
below rather than done here.
- Docblocks that named the merged fold as a map step
(`wildcardGrantsSuperRead`, the seed, plain coverage, the clamp, the
builder) now name the per-set fold.
Tests:
- `plugin-security` `get-effective-object-permissions.test.ts`:
`KNOWN_OVER_GRANT`, its flip-trigger assertion and its "names a row"
test are DELETED rather than emptied. The table now holds every subject
EXACT in both directions, with the managed-write clamp's narrowing as
its only allowance. Each enumeration shape is a named subject, tagged
with its row (`[#20136 row a]` to `[#20136 row h]`, plus the existing
one-set row), so a regression names its row. Rows b, d, e, f, g and h
are new. The `apiOperations` column runs over them too, which is where
row e's offered-but-refused `export` is held. One spelled-out case: the
walled org admin may not create, edit or delete `sys_position`, and
`can()` says so, with and without `member_default`; `sys_user` edit
without it.
- `core` `effective-object-permissions.test.ts`: a `[#20136]` block (own
read-only entry, `{}` entry, export-only entry under a super-read
wildcard, `modifyAllRecords` without create, another set's wildcard
still widening). Two composition fixtures that relied on the over-grant
are re-spelled: the clamp and fold cases now put the narrower entry in a
SECOND set, so they still exercise the fold.
- `plugin-hono-server`: the endpoint fixture's `sys_member` deny moves
to the second set, so its "clamp over the fold" assertion keeps
exercising the fold. There is a new endpoint case, a super-user set's
own narrower entry served as that set's answer, byte-equal to the
builder. `fold-wildcard-superuser.test.ts` gets a note that it pins the
standalone released helper.
Changesets: `.changeset/20136-super-user-fold-per-set.md`
(`@objectstack/core` patch), plus three one-clause DELIBERATE
CORRECTIONS of pending notes, listed below.
## Measurement
The map answer is the real formula `current_user.can()` over
`toEvalPermissions(map)`. It is compared with
`PermissionEvaluator.checkObjectPermission` on all 10 verbs. `over`
means the map grants a cell the evaluator refuses, and `under` the
reverse. As in the existing pin, create, edit and delete refused on a
guarded managed object is the managed-write clamp and is not counted.
The fixture has 61 registered objects: every object schema exported by
`@objectstack/platform-objects` and by plugin-security's `objects`, plus
4 app objects (public, `apiMethods`-restricted, private, and
`apiEnabled: false`).
- Base: `49144fccc8`, with core's `dist/` built from it.
- Head: `6527062a22`.
- A second base leg (the producer file restored from base, core rebuilt,
the marker proven present in `dist/`) is byte-identical (sha256) to true
base on all 27 subjects.
### Enumeration (over-granted cells, base to head)
| row | shape | base | head |
|---|---|---|---|
| a | walled `organization_admin` + `member_default` | 38 | **0** |
| b | walled `organization_admin` alone | 44 | **0** |
| c | `'*': { modifyAllRecords: true }` alone | 32 | **0** |
| d | `{}` explicit entry inside a super-user set | 8 | **0** |
| e | `'*': { viewAllRecords }` over its own `{ allowExport: true }`
entry | 2 (+1 `export` offered and refused) | **0** (+0) |
| f (new) | `'*': { viewAllRecords }` over its own narrower entry | 1 |
**0** |
| g (new) | `modifyAllRecords`-only `'*'` beside a set naming the object
narrower | 2 | **0** |
| h (new, control) | `admin_full_access` + `organization_admin` +
`member_default` | 0 | 0 |
| existing | one set: a super-user `'*'` and a narrower `crm_account` |
7 | **0** |
- Rows a, b and d reproduce the card's 38, 44 and 8 exactly.
- Row c is 32 here and 36 on the card: it is `create` + `import` on
every object the clamp does not narrow, 16 on this fixture and 18 on the
card's 63-object fixture.
- Row f: the super-read path over the set's own entry, with no
`modifyAllRecords` and no export.
- Row g: the create lift arriving from a SECOND set's wildcard, over an
entry the first set names.
- Row h: the control row, where another set's super-user wildcard
legitimately widens a narrower entry.
### Parity (all 27 subjects)
The 18 subjects of the #20083/#20134/#20135 table, rows a to h, a
super-read wildcard alone, `admin_full_access` alone, and a super-read
wildcard beside a plain one:
- **over**: 8 subjects were non-zero at base (the table above). Every
subject is **0** at head.
- **under**: **0** at base and at head, in every subject. No cell
`checkObjectPermission` grants became refused in the map.
- **`apiOperations` column** (offered and refused, and served but
hidden): row e was 1 offered-and-refused at base (`crm_account`
`export`). It is **0** at head, and every other cell is 0 at both.
### Reach, at a public door (H2)
The write path is PR #20079's `ObjectQL` write path: the real engine,
with the real `SecurityPlugin` resolver (the function it registers on
the engine) as its resolver. `crm_case.stage` has options gated on
`current_user.can(…)`.
| subject | gate | base | head | `checkObjectPermission` |
|---|---|---|---|---|
| walled org admin + `member_default` | `can('sys_position','edit')` |
**ADMITTED** | refused `VALIDATION_FAILED` / `invalid_option` | false |
| walled org admin + `member_default` | `can('sys_position','create')` |
**ADMITTED** | refused | false |
| walled org admin alone | `can('sys_position','edit')`,
`can('sys_position','create')` | **ADMITTED** | refused | false |
| walled org admin alone | `can('sys_user','edit')` | **ADMITTED** |
refused | false |
| `'*': { modifyAllRecords }` | `can('sys_position','create')`,
`can('crm_account','create')` | **ADMITTED** | refused | false |
| the other 18 cells of the 25: the controls `admin_full_access` +
`member_default` and `member_default`, and every cell the server grants
| | unchanged | unchanged | agrees |
`GET /api/v1/auth/me/permissions` was served by the real
`registerCurrentUserEndpoints` on a Hono app over the same resolved sets
and registry.
| subject | `objects.sys_position` create / edit / delete | response
bytes | `objects` sha256 |
|---|---|---|---|
| walled org admin + `member_default` | true / true / true → **false /
false / false** | 13187 → 13203 | `7d78b7069a14` → `7c41ba445efa` |
| walled org admin alone | true / true / true → **false / false /
false** | 12789 → 12807 | `6cf54b268f58` → `65a3ee709036` |
| `'*': { modifyAllRecords }` | true / true / true → **false** / true /
true | 11122 → 11138 | `87f9b1e48f79` → `e5a31ecba130` |
| `admin_full_access` + `member_default` | unchanged | 12987 → 12987 |
identical |
| `member_default` | (no entry) | 6704 → 6704 | identical |
### Collateral (H4)
- **19 of 27 subjects are byte-identical (sha256)**: every subject
holding no super-user wildcard, and every super-user subject with no own
narrower entry and no create-less `modifyAllRecords`. That includes
`admin_full_access` alone and beside `member_default`,
`organization_admin_no_bypass`, and row h.
- **The 8 changed subjects**: no entry is added or removed, and no bit
turns `false` to `true`. Only `true` to `false`:
| subject | bits turned `true` → `false` |
|---|---|
| row a | `allowEdit` ×6, `allowCreate` ×5, `allowDelete` ×5 |
| row b | `allowEdit` ×8, `allowCreate` ×5, `allowDelete` ×5 |
| row c | `allowCreate` ×16 |
| row d | `allowRead`, `allowCreate`, `allowEdit`, `allowDelete` ×1 each
|
| row e | `allowRead` ×1 |
| row f | `allowRead` ×1 |
| row g | `allowCreate` ×1 |
| one-set row | `allowCreate`, `allowEdit`, `allowDelete` ×1 each |
- **One `apiOperations` change**: row e's `crm_account` goes from no
annotation (default-allow, which offered `export`) to the closure
without `export`.
- **Enforcement untouched**: `permission-evaluator.ts` and all of
plugin-security's non-test source have 0 changed lines, so no
`checkObjectPermission` answer changes.
### Reverse verification
The fix was committed first. The mutate leg restored the producer file
from base (`git restore --source=49144fccc8`), rebuilt core, and proved
the marker live with `ablation-dist-preflight` (present in 2 built
files). On that tree:
- core went red on 7 of 32: the 5 new `[#20136]` cases and the 2
re-spelled composition cases;
- plugin-security went red on 10 of 64: rows a, b, c, d, e, f, g and the
one-set row, the spelled-out case, and row e's `apiOperations` column;
- hono-server went red on 1 of 47: the new endpoint case.
Row h and every other subject stayed green. The direction is the
expected one (to red). The restore used `git checkout HEAD --`, proven
by the file's hash equalling its HEAD blob and by an empty `git diff
HEAD`. Core was then rebuilt, with the marker proven absent from
`dist/`.
## Tests and gates, at `6527062a22`
- `pnpm --filter @objectstack/core test`: 53 files, 1358 passed.
`test:repo`: 3 files, 48 passed.
- `pnpm --filter @objectstack/plugin-security test`: 135 files, 2730
passed.
- `pnpm --filter @objectstack/plugin-hono-server test`: 27 files, 324
passed.
- `typecheck` for core, plugin-security and plugin-hono-server (`tsc`,
the scripts/typecheck projects, `check:test-typecheck` over the test
layer): exit 0.
- Access-security dogfood, over the dogfood closure built at head: the
11 `access-security.json` checklist dogfood files plus
`organization-update-door.dogfood.test.ts`, 12 files, 165 passed.
- `node scripts/pm/dispatch-gates.mjs --commands` derived 64 commands
from this diff. All 64 were run, and `--ran` reconciles 64 of 64 with 0
NOT-MEASURED.
- 63 exit 0.
- `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (8
unrelated packages had no `dist/`). After building them it exited 0.
- **`check-empty-changeset --base origin/main` exits 1, by design**: it
is the DELIBERATE CORRECTION class below, and the gate stays red until a
person confirms.
- `node scripts/check-issue-citations.mjs --base 49144fc`: exit 0, 8
citations resolve.
- `eslint --no-inline-config` on the 5 changed TS files: 0 errors, 0
warnings, 5 files counted from `--format json`. All 5 are in the
config's population (`--print-config` resolves each). The config enables
no type-aware linting (no `parserOptions.project`), so this diff cannot
move a verdict on an untouched file.
## Deliberate corrections of pending release notes, for confirmation
`check-empty-changeset` is red on these three by design. Each is one
clause, and each sentence was made false by this PR:
- `.changeset/18783-server-can-option-visibility.md`: "The map still
differs from `checkObjectPermission` … an entry the super-user set
itself names narrower can read as granted." now reads: "The map also
differed … read as granted, which is closed in this same release
(`.changeset/20136-super-user-fold-per-set.md`)."
- `.changeset/20134-super-user-entries-every-bit.md`: "Both over-grants
are left exactly as they were." now reads: "…left exactly as they were
by this change, and both are closed in this same release
(`.changeset/20136-…`)."
- `.changeset/18931-me-permissions-unrestricted-export-annotation.md`:
"Its CRUD bits are folded `true`" now reads: "Its CRUD bits are folded
to what its wildcard grants (all four for the built-in admin sets)". The
note's population includes a bare `'*': { modifyAllRecords }`, which no
longer reads `create`.
No released note was touched.
## Cross-lane
For super-user subjects, two things change: the bytes
`/auth/me/permissions` serves, and the write path's `current_user.can()`
answer. Both change only on the cells listed above, and both move toward
what the server enforces. For the seat to relay to `domain:cli` and
`domain:services` at landing. The REST door, `checkObjectPermission` and
`annotateEffectiveApiOperations` are untouched.
## Open question
`foldWildcardSuperUser` is still exported, but nothing in this
repository now calls it outside its own pins. Over a merged map it
cannot be made to match the server, because the map has lost which set
named which object. Retiring it removes a released export of
`@objectstack/plugin-hono-server`, which is a breaking change
(`Clause-②: yes (narrowing)`). That is a decision for the seat or the
maintainer, not a rider on this fix. Recommendation: retire it in its
own change, because an exported helper documented as the map's fold is
how the merged reading could come back.
## Acceptance notes
- H1: every row a to e was re-measured and reproduced (row c's count
differs only by fixture size, as explained above). Three rows were
added: f, g, and h (control). No other super-user shape measured
diverges at base.
- H5: no released behaviour becomes refused. The write-path `can()` gate
is PR #20079's, whose changeset is still pending. On
`/auth/me/permissions` only map bits narrow, and the server's answer to
every request is unchanged.
- The suggested route turned `KNOWN_OVER_GRANT` into an empty set. This
PR deletes it instead: an empty allowance ledger is a mechanism for
re-admitting a divergence, and the family's assertion is exact.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 369bcbe commit e2c4e12
9 files changed
Lines changed: 258 additions & 99 deletions
File tree
- .changeset
- 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 | |
|---|---|---|---|
| |||
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
15 | | - | |
| 15 | + | |
16 | 16 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
17 | | - | |
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| 12 | + | |
| 13 | + | |
12 | 14 | | |
13 | 15 | | |
14 | 16 | | |
| 17 | + | |
15 | 18 | | |
16 | 19 | | |
17 | 20 | | |
| |||
42 | 45 | | |
43 | 46 | | |
44 | 47 | | |
45 | | - | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
46 | 53 | | |
47 | 54 | | |
48 | | - | |
49 | | - | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
50 | 58 | | |
51 | 59 | | |
52 | 60 | | |
| |||
89 | 97 | | |
90 | 98 | | |
91 | 99 | | |
92 | | - | |
| 100 | + | |
| 101 | + | |
93 | 102 | | |
94 | | - | |
| 103 | + | |
| 104 | + | |
95 | 105 | | |
96 | 106 | | |
97 | 107 | | |
| |||
284 | 294 | | |
285 | 295 | | |
286 | 296 | | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
287 | 374 | | |
288 | 375 | | |
289 | 376 | | |
| |||
0 commit comments