fix(objectql): a formula field and a CEL defaultValue answer current_user.can() from the security service (#20082) - #20138
Conversation
…n() from the resolved permission map applyFormulaPlan (find, findOne, write echo) and applyFieldDefaults now pass the acting subject's effective object-permission map, resolved at most once per operation through the registered resolver and shared with the option gates. No resolver passes no map; a failed resolution reads null with a warn on a read and refuses the write for a row whose can() default needed it. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…ults across granted, denied, no resolver and a throwing resolver Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…he security service; pin the validate() rejection Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…ather than erasing them Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…binator it does not implement Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 272d0526171097904b08dac3def40254a7cc9b78 && git checkout 272d0526171097904b08dac3def40254a7cc9b78
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 536f2d52fc5d3aa6067782f884c1798614710f02 4fcfbed346c2cdb73653b63ca345d5ea45a0eceb && git checkout -B drift-repro 536f2d52fc5d3aa6067782f884c1798614710f02 && git merge --no-ff 4fcfbed346c2cdb73653b63ca345d5ea45a0eceb
node scripts/docs-audit/affected-docs.mjs --json 536f2d52fc5d3aa6067782f884c1798614710f02
|
Contract reviewServed-tier: Scope: 4 files (+834/−42) on merge base
Measured in detached worktrees at head and base, each with its objectql, plugin-security and driver-sql closure built. The stack was a real ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #20082
Clause-②: no (narrowing)
A
formulafield and a CELdefaultValuethat callcurrent_user.can(object, verb)now get the acting subject's effective object permissions. That is the map PR #20079 wired for option visibility. It comes throughObjectQL.registerEffectiveObjectPermissionsResolverand formula'stoEvalPermissions, and it is resolved at most once per engine operation.The two sites, base against head
Measured one-shot through a real
ObjectQLwith a SQL driver (better-sqlite3:memory:) and a realSecurityPlugin. The caller holdscrm_account: allowRead + allowEdit, soeditis the granted verb anddeletethe denied one. Base is55daf89d74; head is this branch. The same cells are pinned permanently inpackages/objectql/src/engine-formula-default-permission.test.tswith a resolver double.find,findOne, insert echo, update echonull, no log; headtruenull, no log; headfalsenull, no log; headnullplus onewarnper operation,reason: 'no-permission-source'null, no log; headnullplus onewarnper operation carrying the error,reason: 'permission-resolution-failed'defaultValueon insertwarn; head storestruewarn; head storesfalsewarn, which carries formula's "carries no permission data" refusalwarn, row written; head the insert is refused with the resolver's own error, and nothing is writtenrequiredfield with that defaultVALIDATION_FAILED/required; head admitted (true)false)VALIDATION_FAILED/requiredAt base the resolver was asked 0 times by either site, whether it granted, withheld or threw. That is H1, confirmed.
The rule each site follows (H3)
nulland logs nothing:applyFormulaPlanassignsr.ok ? … : null. A read cannot refuse a row over one computed field, so acanformula with no map keeps thatnull. It is no longer silent: the engine logs onewarnper operation naming the object, thecanfields and the reason. A throw fails closed. It is never read as "no grants", which would make an empty map answerfalse, and never as a grant.warn"Failed to evaluate default expression". Arequiredfield so defaulted is then refused by required-validation (measured at base with the real plugin:VALIDATION_FAILED, fieldd_req, coderequired). With no resolver that rule is kept byte for byte: the absent member passes NO map. A throw fails closed the way the option gate does. A row whosecandefault needed the map is refused with the resolution's own error, re-raised untouched. UnderinsertManyonly that row is refused, and thevalidate()preview rejects the same way. A row that supplies the field is never refused by a resolution it did not need.Where the map comes from, and how often (H2)
permissionResolution(context)is one lazy, memoised resolver ask per engine operation. It isundefined, meaning no map, when no resolver is registered or the operation has no acting user. The same conditions apply to the option gate.findandfindOneresolve after the driver returns, and only when a planned formula callscanand at least one row came back. They useopCtx.context, which is the context the security middleware already ran with. Soplugin-security's per-context permission-set memo serves the resolution.readonlystrip, the option gates and the formula fields on the response.resolveOptionPermissionsnow receives the write's resolution instead of calling the resolver itself. When it asks, and what a throw does, are unchanged; its 13-case suite passes as it was.readsPermissionPredicate, the option gate's own AST reading of a receivercancall. It is exported fromrule-validator.tsso that there is one detector, not two. It is not re-exported from the package entry.Resolver asks per operation (pinned):
findover 4 rows with acanformulafindOne, and a by-id update echocandefault and acanformula echocan-gated option AND defaults acanfield AND echoes acanformulavalidate()over 2 rowsfindscananywhere, even with a throwing resolverDeclaration (H4)
candefault needed it is now refused. At base that row was written with the field unset. So the changeset and this body carryClause-②: no (narrowing), a BREAKING banner andadr-0087: not-required (no-migration-prescription). No authored key, stored shape, export or route changes.Clause-②: no. The arm is added here because the dispatch's H4 says a write whose default now fails closed is a narrowing to declare. The seat owns the claim line.SecurityPlugin's middleware resolves the same memoised permission sets before the write and already refuses on a resolution failure. The newly refused path is therefore reachable only whenbuildEffectiveObjectPermissionsthrows, when the map is off-shape, or with a third-party resolver.requiredfield defaulted bycanis now admitted where it was always refused. That is a declared default finally evaluating, not a new surface.Tests
Head is
4fcfbed346. Each line names the commit it ran at. The only commits after9e622fbeddedit the new test file: they type its options objects and make its driver refuse unknown WHERE combinators.engine.tsandrule-validator.tsrestored to55daf89d74(bloba9ec130693= base blob), then restored to HEAD (blobs15ca43c2eband90cec7aea7= HEAD,git diff HEADempty) gaveTests 27 failed | 3 passed (30). For example, "expected null to be true" (grantedfind), "expected [] to have a length of 1 but got +0" (no-resolver warn), and "expected ValidationError: flag is required to be Error: permission store unreachable" (throw on a required default). The 3 that pass are controls that must hold on both sides: no-resolver on a required default, nocananywhere, and a system read. This ran atca6dea2249, with 30 cases; the 31st case, thevalidate()rejection, was added after it.4fcfbed346). The new pin plusengine-option-permission-predicategaveTest Files 2 passed (2)andTests 44 passed (44), which is 31 plus 13.ca6dea2249; src-resolved, so no build). I ranscripts/ablation-replace.mjsto replace the memo'spending ??=withpending =. The anchor went 1 to 0, the marker 0 to 1, and the blob went15ca43c2ebto592f152bbd. The pin then gaveTests 4 failed | 26 passed (30): every "one resolution" cell got 2 or 3 asks. The file was restored with blob == HEAD andgit diff HEADempty.@objectstack/objectql.vitest run --project localgaveTest Files 315 passed (315)andTests 5373 passed (5373). It ran at9e622fbedd; the only later commit retypes the options objects in the new test file.--project repogave 1 file, 5 tests passed.typecheck(src, scripts and the test layer) exited 0 at4fcfbed346, and the test layer still holds 40 files and 234 errors in the debt ledger. The new test file is intsconfig.test.json's program (--listFiles) with 0 errors.@objectstack/plugin-security(9e622fbedd). The full suite, run withobjectqlaliased to source, gaveTest Files 135 passed (135)andTests 2676 passed (2676).55ec8feee6). On a closure built by turbo (64 tasks),field-zoo-roundtrip,field-zoo-value-shape,showcase-static-readonly(the re-default path),showcase-fls-read-mask-stripandexpression-conformancegaveTest Files 5 passed (5)andTests 109 passed (109).9e622fbedd).engine-option-permission-predicate,rule-validator.option-visibility,engine-write-formula-hydration,engine-formula-scale,engine-cel-default-temporal-shape,engine-default-value-tokensandrecord-title, run with the new pin, gaveTest Files 8 passed (8)andTests 170 passed (170).4fcfbed346).eslint --no-inline-config --format jsonon the 3 changed.tsfiles found 3 files, 0 errors and 0 warnings. The population is read fromeslint.config.mjs, and no file was reported ignored. The config sets noparserOptions.project, so no type-aware rule can move a verdict on an untouched file.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat4fcfbed346derived 65 commands.4fcfbed346, and all 65 exited 0.--ranreported65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.9e622fbeddand are green at head:check:query-options-erasure: the new test's options objects were erased toany, and the test surface grew from 236 to 244 sites. They are now typed, and the surface is back to 236.check:where-matcher: the test driver read an unknown$key as a field name. It now refuses it.check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET) on the first pass. Later gates in the list built the missing dists, and it exits 0 at head. A CJS/ESM load probe ofpackages/objectql/distseesObjectQLandevaluateFormulaFieldon both.check-changeset-no-major, fed this body as apull_requestpayload (--event). It reportedLEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose packages/**/src/** it moves is graded patch.check-adr-0087-registration. It reported1 declared-breaking changeset(s), each carrying an ADR-0087 dispositionand[BREAKING+clause-②-narrowing] not-required (no-migration-prescription).check-issue-citations --base 55daf89d74. Exit 0:17 resolves.Acceptance notes
carrier:承接者:无. The expression-conformance ledger rowcel-formuladeclaresfailPolicy: 'fail-soft-log', but a formula that faults for any other reason still readsnullwith no log line (applyFormulaPlan'sr.ok ? … : null). This PR logs only thecancase, which is its own. This is read off the code, not measured at a public door.carrier:承接者:无.evaluateFormulaFieldandresolveRecordTitleare synchronous and hold no resolver, so a title formula callingcanstill yieldsnullthere. The docblock and the changeset say so.carrier:承接者:无. Eachexpandof a related object is its ownfind, so it asks the resolver again.plugin-security's per-context memo absorbs the set resolution; the map itself is rebuilt.carrier:承接者:无. A system read, which has no acting user, of acanformula readsnullwith no warn, as anycurrent_userformula does with no subject.Deviations from the claim's file surface
packages/objectql/src/validation/rule-validator.tsgetsexportonreadsPermissionPredicate, plus a three-line docblock note, so that there is onecandetector.engine.ts, beyond the bodies ofapplyFormulaPlanandapplyFieldDefaults:find,findOne,insert,updateandvalidate;hydrateWriteFormulas, which is now async and takes a permissions callback;resolveOptionPermissions, which now takes the shared resolution. H2 requires that for "at most once per write" across both uses. Its behaviour is unchanged.packages/core,packages/formulaand PR fix(objectql)!:havingtakes the rest ofwhere's filter doors — the comparand-type door, row-independent refusals, a resolved{ $field }, and a refused non-condition #20117'saggregateregion are untouched.Generated by Claude Code