Skip to content

Commit 0318faf

Browse files
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
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/plugin-security': minor
4+
'@objectstack/core': minor
5+
'@objectstack/plugin-hono-server': patch
6+
---
7+
8+
feat: the server answers `current_user.can(object, verb)` in an option's `visibleWhen` (#18783)
9+
10+
A `select` / `multiselect` / `radio` / `checkboxes` option can gate itself on the acting subject's grants:
11+
12+
```ts
13+
stage: Field.select({
14+
label: 'Stage',
15+
options: [
16+
{ value: 'open', label: 'Open' },
17+
{ value: 'escalated', label: 'Escalated', visibleWhen: "current_user.can('crm_account', 'edit')" },
18+
],
19+
}),
20+
```
21+
22+
`@objectstack/formula` answers `can` from `EvalContext.permissions` and refuses loudly when none is passed — and until now nothing on the write path passed one. Every authenticated write that picked such an option took the evaluator's fail-open branch: the value was admitted, one `warn` said the predicate "failed to evaluate", and the gate was never enforced for anyone.
23+
24+
**What changes.** The write path now evaluates the predicate with the subject's effective object permissions — on `insert` (single and batch), by-id `update`, bulk `update`, and the `validate()` preview. A subject whose map withholds the verb is refused with `VALIDATION_FAILED` and a field error `invalid_option` on that field; a subject who holds it is admitted. Options whose `visibleWhen` never calls `can` are unaffected.
25+
26+
**Where the map comes from — one producer.**
27+
28+
- `@objectstack/plugin-security` implements `ISecurityService.getEffectiveObjectPermissions` (declared optional in `@objectstack/spec`) and registers the same method on the engine.
29+
- `@objectstack/objectql` gains `registerEffectiveObjectPermissionsResolver(fn)`. The engine asks it at most ONCE per write (an N-row bulk update is one resolution), only when a picked option's predicate calls `can`, never for a write with no acting user, and never keeps the answer past the write. The answer goes through formula's `toEvalPermissions`, so a map that is not the published shape is refused rather than answered from.
30+
- `@objectstack/core` exports `buildEffectiveObjectPermissions`: the most-permissive merge plus the super-user seed, wildcard fold, managed-write clamp and `apiOperations` annotation. `/auth/me/permissions` builds its `objects` slot with it and the new security method returns it, so the console and the server's own `can()` read the same map. The four folds (`foldWildcardSuperUser`, `clampManagedObjectWrites`, `seedSuperUserRestrictedObjects`, `annotateEffectiveApiOperations`) and the `ManagedSchemaLike` / `ApiExposureSchemaLike` types moved from `@objectstack/plugin-hono-server` to `@objectstack/core`; `@objectstack/plugin-hono-server` re-exports them under the same names, so no import changes. The `/auth/me/permissions` response is byte-identical for the same resolved sets (measured on five fixtures against the previous build).
31+
32+
**Failure stance.**
33+
34+
- 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".
35+
- 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.
36+
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`.
38+
39+
**No spec key, route or config key is added or removed.**
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#18783] `buildEffectiveObjectPermissions` — the ONE function behind the
5+
* `/auth/me/permissions` `objects` slot and `ISecurityService.getEffectiveObjectPermissions`.
6+
*
7+
* The four folds it composes keep their own pin batteries where they were
8+
* written (plugin-hono-server's `fold-wildcard-superuser.test.ts` and
9+
* `effective-api-operations.test.ts`, which exercise them through that
10+
* package's unchanged re-exports). What is pinned HERE is the composition:
11+
* the merge rule, the order, the guards, and that the result aliases nothing.
12+
*/
13+
14+
import { describe, it, expect } from 'vitest';
15+
import { buildEffectiveObjectPermissions } from './effective-object-permissions.js';
16+
17+
describe('buildEffectiveObjectPermissions', () => {
18+
it('merges most-permissively: `true` wins, otherwise the first defined value stands', () => {
19+
const map: any = buildEffectiveObjectPermissions([
20+
{ objects: { deal: { allowRead: true, allowEdit: false } } },
21+
{ objects: { deal: { allowEdit: true, allowDelete: false } } },
22+
{ objects: { deal: { allowDelete: true, allowCreate: false } } },
23+
{ objects: undefined },
24+
null,
25+
]);
26+
expect(map.deal).toEqual({ allowRead: true, allowEdit: true, allowDelete: true, allowCreate: false });
27+
});
28+
29+
it('builds FRESH entries — nothing in the map aliases an input set', () => {
30+
const entry = { allowRead: true };
31+
const sets = [{ objects: { deal: entry } }];
32+
const map: any = buildEffectiveObjectPermissions(sets);
33+
expect(map.deal).toEqual(entry);
34+
expect(map.deal).not.toBe(entry);
35+
map.deal.allowEdit = true;
36+
expect(entry).toEqual({ allowRead: true });
37+
});
38+
39+
it('seeds, then folds, then clamps, then annotates — in that order', () => {
40+
const schemas: Record<string, any> = {
41+
report: { name: 'report', enable: { apiMethods: ['get', 'list'] } },
42+
sys_member: { name: 'sys_member', managedBy: 'better-auth' },
43+
};
44+
const map: any = buildEffectiveObjectPermissions(
45+
[{ objects: { '*': { viewAllRecords: true, modifyAllRecords: true }, sys_member: { allowRead: true } } }],
46+
{ allSchemas: () => Object.values(schemas), schemaOf: (n) => schemas[n] },
47+
);
48+
// Seed → fold: an entry nobody named, pulled true by the super-user bits.
49+
expect(map.report).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true });
50+
// Fold → clamp: the guard has the last word on a managed object's writes.
51+
expect(map.sys_member).toMatchObject({ allowRead: true, allowEdit: false, allowCreate: false, allowDelete: false });
52+
// Annotate runs last, over the final entries.
53+
expect(Array.isArray(map.report.apiOperations)).toBe(true);
54+
});
55+
56+
it('a throwing schema source degrades the annotations, never the map', () => {
57+
const warns: string[] = [];
58+
const map: any = buildEffectiveObjectPermissions(
59+
[{ objects: { deal: { allowRead: true } } }],
60+
{
61+
allSchemas: () => { throw new Error('registry down'); },
62+
schemaOf: () => { throw new Error('registry down'); },
63+
logger: { warn: (m) => warns.push(m) },
64+
},
65+
);
66+
expect(map).toEqual({ deal: { allowRead: true } });
67+
});
68+
69+
it('with no schema source at all it is the bare merge plus the fold', () => {
70+
const map: any = buildEffectiveObjectPermissions([
71+
{ objects: { '*': { modifyAllRecords: true }, deal: { allowRead: false } } },
72+
]);
73+
expect(map.deal).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true });
74+
expect(map.deal.apiOperations).toBeUndefined();
75+
});
76+
});

0 commit comments

Comments
 (0)