Repository navigation
Commit 0318faf
feat(objectql,plugin-security,core): the server answers current_user.can() in an option's visibleWhen (#20079)
Fixes #18783
Clause-②: yes (widening)
Executes ruling A on the card (maintainer 「同意」, comment 5725678115): the
census comes first, then the reader, then the wiring. The server now
answers `current_user.can(object, verb)` in an option's `visibleWhen`.
Before this PR the predicate faulted on every authenticated write and
the value was admitted unenforced. The interface member is the one PR
#19622 declared:
`ISecurityService.getEffectiveObjectPermissions?(context?)`.
`packages/spec` is not touched.
**Declared cross-lane edits.** `packages/plugins/plugin-security` is
`domain:services` surface; the ruling places it on this card.
`packages/core` and `packages/plugins/plugin-hono-server` are outside
the claim's file surface. They are touched because the dispatch's H2
requires the `/auth/me/permissions` route and the member to read ONE
function, and `@objectstack/core` is the only package both already
depend on (plugin-hono-server must not take a runtime dependency on
plugin-security).
## What lands
- **objectql** gets a new engine seam,
`registerEffectiveObjectPermissionsResolver(fn)`. It is the same shape
as `registerWriteGateProbe`, which the security plugin already registers
on the engine.
- `resolveOptionPermissions` asks the resolver at most ONCE per write: a
batch insert, a by-id update, an N-row bulk update or a `validate()`
preview.
- It asks only when three things hold: a resolver is registered, the
write has an acting user, and some payload picks an option whose
`visibleWhen` calls `can`.
- The answer goes through formula's `toEvalPermissions`. A resolution
throw, or a map that is not the published shape, is re-raised untouched
for exactly the payloads that needed the map: the write fails CLOSED.
- **rule-validator**: `evaluateValidationRules` takes `permissions` and
passes it to the per-option `visibleWhen` evaluation.
- `optionVisibilityReadsPermissions` answers "does this write need the
map". It reads the parsed CEL AST for a receiver call named `can`, and
uses the same picker (`pickedGatedOptions`) the evaluator judges with.
- `undefined` stays "no permission data": the predicate is loudly
unevaluable and the existing fail-open branch admits the value with a
warn that names the missing input.
- **plugin-security** implements `getEffectiveObjectPermissions` on the
class and on the registered `security` literal, and registers the same
method on the engine.
- The member resolves the sets with `resolvePermissionSetsForContext`
and builds the map with `buildEffectiveObjectPermissions` over the
plugin's engine. It freezes the map at the top level.
- A resolution failure propagates untouched and never becomes `{}`.
- An engine without the seam gets one `warn` at start.
- **core** gets `buildEffectiveObjectPermissions`: the most-permissive
merge, then `seedSuperUserRestrictedObjects`, `foldWildcardSuperUser`,
`clampManagedObjectWrites` and `annotateEffectiveApiOperations`. The
four folds and `ManagedSchemaLike` / `ApiExposureSchemaLike` moved here
unchanged (reindented) from plugin-hono-server.
- **plugin-hono-server**: `/auth/me/permissions` builds its `objects`
slot with `buildEffectiveObjectPermissions`. The six moved names are
re-exported from `@objectstack/core`, so the package root still exports
them. ESM probe: the re-exported `foldWildcardSuperUser` is the same
function object as core's (`===`).
## Census — every in-repo evaluation site that binds `current_user`
The grep, over non-test source outside `packages/formula` (the engine
itself) and `packages/qa`:
```
git grep -n -E "\b(ExpressionEngine|celEngine|templateEngine)\.evaluate\b|\bresolveSeed(Record)?\(|\bcompileCelToFilter\(" -- 'packages/**/*.ts' ':!**/*.test.ts' ':!**/dist/**' ':!packages/formula/**' ':!packages/qa/**'
```
It gives 24 lines, 18 of them code (6 are comments). Completeness
control: a token scan for
`ExpressionEngine|celEngine|templateEngine|resolveSeedRecord|resolveSeed|buildScope|getEngine`
over the same tree gives 24 files. Every file outside the grep's
population mentions the tokens only in comments, or uses an unrelated
`getEngine`. Positive control: the grep's population contains the site
the ruling names (`rule-validator.ts` `evaluateOptionVisibility`).
| site (file : symbol) | binds `current_user` | author-reachable `can`
today | verdict |
|---|---|---|---|
| `objectql/src/validation/rule-validator.ts` :
`evaluateOptionVisibility` (reached from 5 engine call sites: insert,
by-id update, bulk update twice, `validate()`) | yes (`buildEvalUser`) |
yes. It faulted with "carries no permission data" and the fail-open
branch ADMITTED the value (measured on the base head, below). This is a
live violation and, by the ruling's own words, the p1 trigger. | **wired
here** |
| `objectql/src/engine.ts` : `applyFormulaPlan` (formula virtual fields,
read and write-back) | yes (own `{id, positions}`) | yes: value `null`
on read and on the insert echo, with NO log (measured at this head with
a resolver registered) | out of this card's plumbing: a value
expression, not a predicate, and it builds its own user object. Reported
as a finding. |
| `objectql/src/engine.ts` : `applyFieldDefaults` (CEL `defaultValue`) |
yes (own `{id, positions}`) | yes: field left unset, and `Failed to
evaluate default expression` names the missing input (measured) | out of
this card's plumbing, same reason. Reported as a finding. |
| `metadata-protocol/src/seed-loader.ts` : `resolveSeedRecord` | yes
(seed identity, or `{ id: null }`) | the record is dropped loudly
(`errored++`, an actionable error) | out of scope: boot-time replay with
no request and no acting subject whose grants would mean anything. The
loud refusal is the correct answer. |
| `plugin-security/src/rls-compiler.ts` : `compileExpressionOutcome`
(`compileCelToFilter`, `current_user` as a lowering variable) | as a
filter variable | `unsupported method "can()"`: the policy drops and RLS
denies (fail closed) | out of scope: an RLS predicate is lowered into a
driver filter, and a filter cannot consult a permission map |
| `lint/src/validate-rls-predicate-enforceability.ts` | probe variable |
authoring gate, not a runtime evaluation | out of the population |
| hook `condition` (`hook-wrappers.ts`), `readonlyWhen`, `requiredWhen`
x2, `script`, `conditional` (`rule-validator.ts`), approvals expression
approver, share-link eligibility, flow `celScope` x2, sharing-rule
seeder, lint sharing gate | **no** | n/a | outside the census:
`current_user` is not bound there |
## H1 — red on the base head, green here
Ruling pin, `rule-validator.option-visibility.test.ts`, run on the base
(`2274894cc`) before the fix:
- `REFUSES a can-gated option … withholds the verb` failed with
`expected undefined to be an instance of ValidationError` (admitted).
- `ADMITS it …` failed with `expected [ { …(2) } ] to have a length of
+0 but got 1` (the fail-open warn).
- The run ended `Tests 7 failed | 31 passed (38)`. The no-`can` control
and the no-permission-data case were green on the base, which is their
point.
With the fix: `Tests 38 passed (38)`.
## H2 — the producer, and byte-equality
The `/auth/me/permissions` merge lived inline in plugin-hono-server's
`current-user-endpoints.ts`. It is now
`buildEffectiveObjectPermissions`, read by both the route and the
member.
- **Route unchanged.** Whole response bodies from the base route and
this head's route, for the same resolved sets and schemas, are
byte-identical on five fixtures (super-user without export, super-user
with export, wall-less org admin, plain rep, nothing). Measured
one-shot: lengths 1461/381/633/336/168, all `equal=true`.
- **Permanent pins, both halves.**
`current-user-endpoints-effective-objects.test.ts` pins that the route's
`objects` equals `buildEffectiveObjectPermissions` over the resolved
sets, byte for byte. `get-effective-object-permissions.test.ts` pins
that the member equals the same function over
`resolvePermissionSetsForContext`, for three subjects. Both fixtures
make the seed, fold, clamp and annotate steps all fire.
## H3 — resolutions per write
- Bulk update across N matched rows: 1 resolution for N=1 and for N=25.
- Batch insert of 7 rows: 1 resolution.
- Two writes: 2 resolutions, and a grant revoked between them is refused
on the second, so nothing is kept across writes.
- A write whose gates never call `can` makes 0 resolutions, even with a
resolver that would throw. A system write makes 0.
The set resolution under the member is plugin-security's existing
per-context memo, keyed on the request's context object and retired by
the write epoch. Within one request context a second ask costs no second
set load. A new context loads again (pinned).
## Both failure directions (pinned)
- Resolver throws: the insert rejects with that very error object
(`code`, `status` intact), it is not a `ValidationError`, and nothing is
written.
- Off-shape map (`{ crm_account: true }`): `TypeError` from
`toEvalPermissions`, nothing written.
- No resolver: admitted, with one warn whose `meta.error.message`
contains `carries no permission data`. It is not denied.
**Ablation:** the insert call site's `permissions:
insertPermissionsFor(rows[i])` was replaced by `permissions: undefined
/* ABLATION-18783 */` through `scripts/ablation-replace.mjs`. The anchor
went 1 to 0, the marker 0 to 1, and the blob changed.
- 5 engine cases went red (refuse, admit, throw fails closed, off-shape
map, revocation); the 8 control and non-insert cases stayed green.
- The file was restored to its HEAD blob and `git diff HEAD` was empty.
- The first attempt used a replacement already present 178 times. The
tool refused it (count unchanged) and restored, so it was a no-op and
was re-run with the marker.
## Known gap — measured, not changed here
`can()` reads only per-object entries. `/auth/me/permissions`
materialises an entry for an object covered by `'*'` only when that
wildcard carries a super-user bit (the seed pass). With the shipped
`organization_admin_no_bypass` plus `member_default` (the grant a
deployment without an organization wall gives organization owners and
admins):
- the map has no `crm_account` entry;
- `current_user.can('crm_account', 'edit')` evaluates to `false`;
- `PermissionEvaluator.checkObjectPermission('update', 'crm_account',
sets)` is `true`.
`admin_full_access` and walled `organization_admin` answer `true` on
both sides. Before this PR that population's `can` gate was never
enforced for anyone. From this PR on, the server refuses them on a
`can`-gated option. The fix belongs to the map's producer (materialising
plain-wildcard coverage, which changes the `/auth/me/permissions`
response) or to formula's `can`. It is raised as a question in the
report, not decided here.
## Overlap with in-flight work
`rule-validator.ts`: this diff touches the `EvaluateRulesOptions`
interface (one new member), the `USER_SCOPE_ROOTS` docblock, the
`evaluateOptionVisibility` region (a new picker plus helpers), and ONE
line inside `evaluateValidationRules`'s body: the
`evaluateOptionVisibility(...)` call gains `opts.permissions`. That call
is not the function's head, the `requiredWhen` / `readonlyWhen` arms or
`unevaluableRuleError`, which are draft PR #20028's region.
`traversalRefusal` (PR #20049) is already on `main` and was merged in
here without a conflict.
## Verification (at `f3fe6d6cfd`)
- Suites: objectql `test` 312 files / 5292 tests and `test:repo` 1/5;
plugin-security 134 / 2658; plugin-hono-server 27 / 313 (+1 todo); core
`test` 53 / 1331 and `test:repo` 3 / 48. All passed. The objectql,
plugin-hono-server and core runs are from the merge commit `b5416a2b49`;
the two later commits touch only plugin-security's new test file, and
plugin-security's suite and typecheck were re-run at `f3fe6d6cfd`.
- `typecheck` passed for core, objectql, plugin-security and
plugin-hono-server. Each new test file is in a tsc program
(`--listFiles`, via `tsconfig.test.json` or the main config).
- The four packages build (`check-dts-emitted` present). CJS and ESM
load probes see the new core exports and the hono re-exports.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 72 commands, and all were run at
`f3fe6d6cfd`. 69 exited 0.
- 3 exited 3 with PREREQUISITE NOT MET, so they are NOT MEASURED:
`check:dual-build-cjs-loads`, `check:type-check-debt` (both need the
whole workspace built) and `check:i18n` (needs the CLI build closure).
`--ran` reconciliation: 72 derived, 69 run, 3 NOT-MEASURED, 0 UNRUN.
- `check-issue-citations --base b76aad5`: every citation this change
adds resolves.
- Narrowed eslint (`--no-inline-config`, `--format json`) on the 11
changed `.ts` files: 11 files, 0 errors, 0 warnings. `eslint.config.mjs`
enables no type-aware linting (no `parserOptions.project`), so the diff
cannot move a verdict on an untouched file.
## Acceptance notes
- plugin-hono-server's pin batteries for the four folds
(`fold-wildcard-superuser.test.ts`, `effective-api-operations.test.ts`)
stay where they are. They exercise the folds through that package's
unchanged re-exports. core carries its own composition pins.
- The route's failure stance is unchanged: a set-resolution failure
still answers `objects: {}` (its pre-existing `.catch(() => [])`). The
member's stance differs on purpose: it throws.
- `check-changeset-no-major`'s Clause-② level axis reports NOT
APPLICABLE locally (there is no `pull_request` payload). CI reads it.
## Seat amendment (5825601266)
- `Clause-②` is corrected from the claim's `no` to `yes (widening)`. The
diff adds public exports to `@objectstack/core`
(`buildEffectiveObjectPermissions`, plus the folds and types moved from
`plugin-hono-server`) and a public
`ObjectQL.registerEffectiveObjectPermissionsResolver`.
- The widened file surface (`packages/core`, and `plugin-hono-server`,
which is `domain:cli` surface) is accepted as the single-producer
consequence of the member's "computed once" rule.
- The open questions are answered A (land, and fix the plain-wildcard
gap at the producer, as its own card), B (this line) and A (keep
`Fixes`; the value-expression rows are filed as their own card).
- #18783 is re-graded to p1 per ruling A's census clause.
## Patch round 1 (merge-queue failure, `7a6b091b2a`)
- **What failed:** the queue removed this PR on `Test Core (1/6)` /
`Spec property liveness` (queue run 36088336600). The spec liveness gate
reported `permission/objects.allowExport` UNANCHORED, because its
evidence cited `plugin-hono-server/src/current-user-endpoints.ts`,
which, after this PR moved `annotateEffectiveApiOperations` into
`@objectstack/core`, no longer names `allowExport` (0 mentions; the new
file has 7). PR CI runs only the affected subset, and the queue runs the
full suite.
- **Fix:** one ledger file, `packages/spec/liveness/permission.json`.
The citation is repointed to
`packages/core/src/security/effective-object-permissions.ts#annotateEffectiveApiOperations`,
with `verifiedAt` 2026-09-25 and a dated note. It is a declared
cross-lane pointer update in the spec LEDGER, not in `src`.
- `check:liveness` went from exit 1 (`1 UNANCHORED`) to exit 0.
- `check-liveness.test.ts` went from 20 failed / 44 passed to 64 passed.
- The sweep for other pointers made false by the move found none. The
two governed ADR mentions (0103, 0124) are still true and were not
edited.
- The delta contract review is recorded against this head.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 63a8eb4 commit 0318faf
13 files changed
Lines changed: 1705 additions & 355 deletions
File tree
- .changeset
- packages
- core/src/security
- objectql/src
- validation
- plugins
- plugin-hono-server/src
- plugin-security/src
- spec/liveness
| 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 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
Lines changed: 76 additions & 0 deletions
| 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 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
0 commit comments