Skip to content

Commit 9e622fb

Browse files
committed
chore(changeset): formula fields and CEL defaults answer can() from the security service; pin the validate() rejection
Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
1 parent ca6dea2 commit 9e622fb

2 files changed

Lines changed: 44 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
fix(objectql): a `formula` field and a CEL `defaultValue` answer `current_user.can(object, verb)` from the security service (#20082)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) no authored key, stored shape, export or route changes; the narrowing is a runtime refusal when the permission resolution fails, and there is nothing for `objectstack migrate meta` to rewrite -->
10+
11+
**BREAKING**, for one fault path only, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA, so this banner and the ADR-0087 disposition above carry the breaking-ness). An insert row whose CEL `defaultValue` calls `current_user.can(…)` is now refused with the resolution's own error when the registered security service fails to resolve the caller's effective object permissions. Before, that default was left unset with a `warn` and the row was written.
12+
13+
`@objectstack/formula` answers `can` from `EvalContext.permissions`, and neither engine site passed one. With the security plugin registered:
14+
15+
- a formula field calling `current_user.can(…)` read `null` on every `find`, `findOne` and write response, and logged nothing;
16+
- a CEL `defaultValue` calling it was left unset with the `warn` "Failed to evaluate default expression". A `required` field defaulted that way therefore refused every insert.
17+
18+
**What changes.** Both sites now evaluate with the acting subject's effective object permissions. That is the map `ISecurityService.getEffectiveObjectPermissions` returns, which an option's `visibleWhen` already reads.
19+
20+
- A formula field reads `true` or `false`.
21+
- A CEL default stores `true` or `false`, so a `required` field defaulted by `can` is admitted.
22+
23+
The engine asks the security service at most once per operation: once per `find` (not per row), and once per write, shared by its defaults, its `can`-gated options and the formula fields on its response. It asks only when a formula, or a default that will be applied, calls `can`, and only when the operation has an acting user. The answer is never kept past the operation.
24+
25+
**When there is no map.**
26+
27+
- No security service is registered. No permission data is passed, as before. A formula field still reads `null`, and each operation now logs one `warn` naming the object, the fields and `reason: 'no-permission-source'`. A default is still left unset with its existing `warn`.
28+
- The resolution fails: it throws, or it returns a map that is not the published shape. A formula field reads `null`, and one `warn` carries the error with `reason: 'permission-resolution-failed'`, because a read is not refused over one computed field. An insert row whose `can` default needed the map is refused with the resolution's own error. Under `insertMany` only that row is refused, and the `validate()` preview rejects the same way. Rows that supply the field, and objects whose defaults never call `can`, are unaffected.
29+
30+
In no case is `can()` answered `true` without a grant, or `false` from an empty map.
31+
32+
`evaluateFormulaField` (and `resolveRecordTitle`, which uses it) is synchronous and passes no map, so a formula calling `can` still yields `null` there.
33+
34+
No spec key, export or route is added or removed.

‎packages/objectql/src/engine-formula-default-permission.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,16 @@ describe('#20082 — formula fields and CEL defaults answer `can` from the secur
377377
expect(r.asks).toHaveLength(1);
378378
});
379379

380+
it('validate() rejects the same way the write does when a row\'s `can` default needed a failed resolution', async () => {
381+
engine.registerEffectiveObjectPermissionsResolver(countingResolver(() => { throw BOOM; }).fn);
382+
await expect(
383+
engine.validate('crm_gate', [{ subject: 'a' }], { mode: 'insert', context: ACTING } as any),
384+
).rejects.toBe(BOOM);
385+
// A row that supplies the field previews without asking.
386+
const supplied = await engine.validate('crm_gate', [{ subject: 'a', flag: false }], { mode: 'insert', context: ACTING } as any);
387+
expect(supplied.valid).toBe(true);
388+
});
389+
380390
it('never cached across operations: two finds ask twice', async () => {
381391
seed(3);
382392
const r = countingResolver(MAP);

0 commit comments

Comments
 (0)