Repository navigation
fix(plugin-security)!: granted_by is provenance — the delegated-admin gate stamps the writer on every non-system insert, and the column is readonly - #22243
Conversation
Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…legated-admin gate stamps the writer on every non-system insert Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…and as audit provenance; changeset Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…and-in the gate now stamps for a tenant-level admin Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…anted-by-provenance
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 10 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 16 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 0b1f39009b61d9384a8bf347386f7917cdd6aa53 && git checkout 0b1f39009b61d9384a8bf347386f7917cdd6aa53
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3ae59661dc4d5029b1ce02e9cdca5cc6d5f74f83 b4ed57b0ad5a299a2d92abdd3effe5e6ff00ee8d && git checkout -B drift-repro 3ae59661dc4d5029b1ce02e9cdca5cc6d5f74f83 && git merge --no-ff b4ed57b0ad5a299a2d92abdd3effe5e6ff00ee8d
node scripts/docs-audit/affected-docs.mjs --json 3ae59661dc4d5029b1ce02e9cdca5cc6d5f74f83
|
Fixes #22201
Clause-②: no (narrowing)
granted_byonsys_user_permission_setandsys_user_positionrecords who wrote a grant. This PR enforces that, following the seat's ruling A in the claim:readonly: true.DelegatedAdminGate.assertfirst runs the authority decision (authorize, the old body). It then stamps the caller'suserIdintogranted_byon every non-system insert it admits into either table, whatever the payload carried. Tenant-level admins, scoped delegates and self-delegations are stamped alike. A refused write throws before the stamp.stampWriter) replaces the three old conditional stamps. Those ran on the delegate and self-delegation paths only, and only when the value was null.isSystemshort-circuits the security middleware before the gate.The change does not widen who may grant or what a grant gives. It touches nothing in
packages/objectqlorpackages/spec, and it writes no data.Measured: stored
granted_byper writer caseAll cases ran on a real
ObjectQLengine overSqlDriver(better-sqlite3), with the realSecurityPluginmiddleware, and so the real gate, in front. OTHER is another existingsys_userthat the caller names as the granter.origin/main3b49318 source, read with the rig at 295745e.readonly: true, gate unchanged.apply(system)sys_user_positionsys_memberwrite attributed to a humansys_user_permission_setMechanism finding (premise correction). Readonly alone does not change what an insert stores. On insert, the engine's create-side static readonly strip skips every
sys_object (staticReadonlyInsertSubject). The card's premise was that "an explicit caller value … is stripped bystripReadonlyFields, so the row lands NULL". That holds on update only. On insert, the caller's value lands as sent. The gate's in-place middleware stamp is therefore what lands, and #14088's hook-write record is not involved. The update strip does judgesys_objects, and that is what closes the update door.Measured: audit bucket per case (
ObjectQL.inspectDanglingReferences)granted_bynames an id with nosys_userrowdanglingprovenance; no[integrity]warning loggeduser_idnames an id with nosys_userrowdanglingdangling; exactly one[integrity]warningBoth tables are covered.
Family, measured one by one
sys_user_position.granted_by: same writer class, same change. Its non-system writes go through the gate, for both tenant-level admins and delegates (including self-delegation). Its system writer, invitation placement, sets the issuer.sys_record_share.granted_by(plugin-sharing): a different class, left unchanged.managedBy: 'engine-owned', so the ADR-0103 engine-owned guard refuses a user-context generic write.granted_byfrom the acting context'suserId, which is null for a rule reconcile.GrantShareInputcarries no granter, so no caller can name one. "granted_by = writer" already holds there.Tests
granted-by-writer-provenance.test.ts(new, 20 cases). Each runs on the real engine with the realSecurityPlugin.provenancewith no warning, beside the business-lookup control that lands indanglingand warns: per table.delegated-admin-gate.test.ts(+16 cases). These pin the gate's own half:sys_memberandsys_position_permission_setnever gain the key.write-preview-field-gate-parity.test.ts(fixture). The suite'ssys_user_positionstand-in now declaresgranted_by, as the shipped object does. The gate now stamps a tenant-level admin's insert, and the stand-in's missing column was refused as an unknown field before the rule that suite pins was reached.bootstrap-platform-admin.tsis read only here (PR fix(plugin-security): the boot heal converges on duplicated permission-set names, and a refused existence read never inserts #22214 is in flight), so it is not called. Its path, a plain{ isSystem: true }insert, is the system-insert pin.Ablations
Each mutation went to disk through
scripts/ablation-replace.mjs, with the anchor hitting exactly once and the blob changing. Each restore was proven by the blob hash equalling the HEAD blob andgit diff HEADbeing empty. The subject resolves through relative source imports, with@objectstack/objectqlaliased to source, so nodist/was in the path.stampWriterkeeps a supplied value (granted_by == nullguard)sys_user_permission_set.granted_byreadonly: falsedangling) / 17 greensys_user_position.granted_byreadonly: falsestampWritercall removedThe first B1 attempt was a no-op. The replacement was a substring of the anchor, so the tool refused (
replace x1 -> x1), and the command never ran. It was re-run withreadonly: false, and the table above shows that run.Validation
All readings below were taken at
b4ed57b0ad, after mergingorigin/main(0e9371f).vitest run --maxWorkers=2(whole package): 177 files, 3766 passed, 45 skipped, exit 0.pnpm --filter @objectstack/plugin-security typecheck: exit 0. That covers the build program, the scripts program andcheck:test-typecheck, which reports 0 errors.tsc --listFilesovertsconfig.test.jsonlists all three touched test files.turbo run build --filter=!@objectstack/docs: 72 of 72 tasks succeeded.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths, derives 65 commands. All 65 ran, pluspnpm --filter @objectstack/spec check:generated, and every one exited 0.--ranreconciliation, with an exit code recorded per command: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.check-adr-0087-registration: green, dispositionnot-required (no-migration-prescription).check-changeset-no-major: green, "This diff introduces nomajorbump". The level axis needs a PR payload, so it is left to CI.check:engine-double-contract: green.check:i18n: green, 9 packages in sync.check:dual-build-cjs-loads: green, 106 entry points across 66 packages..tsfiles ran under the repo's owneslint.config.mjswith--no-inline-config. The JSON report lists 6 files, with 0 errors and 0 warnings, so none was ignored.parserOptions.project, no typed rules; see its own comment), so this diff cannot change the verdict on any file it does not touch.pnpm lintis left to CI.Acceptance notes
droppedFieldsentry or strip warning for it, because the create-side strip does not run onsys_objects.sys_user_position.granted_bystill says "(stamped by the delegated-admin gate for delegate writes)". That is still true, but it no longer covers every case. Rewording it would re-key the translated bundles in four locales, so it is left alone. The docs pages undercontent/docs/permissions/describe the delegate stamp, which also still holds.granted_byexpect the writer or a truthy value (delegation-of-duty,showcase-permission-zoo,membership-actor-attribution), which this change keeps.Generated by Claude Code