Repository navigation
feat(plugin-security)!: a flow that runs as system writes no identity assignment - #25483
Conversation
… assignment The data middleware refuses a governed write to sys_user_position, sys_user_permission_set, sys_invitation or sys_member from a context that carries isSystem and a flowRunId, ahead of its isSystem bypass, with PERMISSION_DENIED and the remedy (runAs 'user' for grants, the organization endpoints for invitations and memberships). System writers without a run, reads, other objects and runAs 'user' runs are unchanged; a runAs 'user' run reaches the delegated-administration gate as the user it acts for. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu
… write refusal check-system-context-census holds every elevation read site to a row on the page; the new refusal reads isSystem, so it gains row 1b. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu
…non-member Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu
📓 Docs Drift CheckThis PR changes 2 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 13 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 19 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 b2205d81b939db54e4b3f7dfee54668a084ee22a && git checkout b2205d81b939db54e4b3f7dfee54668a084ee22a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ea33919f56a9879f95a00d45418385ff7dbb974f 648d903049c0a2f64baa919581404c5dab393d3f && git checkout -B drift-repro ea33919f56a9879f95a00d45418385ff7dbb974f && git merge --no-ff 648d903049c0a2f64baa919581404c5dab393d3f
node scripts/docs-audit/affected-docs.mjs --json ea33919f56a9879f95a00d45418385ff7dbb974f
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
ADR-0087 marker: ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 · ACCEPT · 2026-10-23T16:26Z
Generated by Claude Code |
…commit e649c2d91e (objectui#13440) (objectstack-ai#13445) <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuQx8xAyxeMnyWkn84sEzqVb)_ Closes objectstack-ai#13440 Clause-②: no Metadata-protocol: none Unblocks #13285 and #13305 (both wait for a pin that carries 18.1.0). ## The pin - `.objectstack-sha` moves from `915e94049922` to `e649c2d91e270e3da26820bcfe54a28445305a75`, the objectstack 18.1.0 release commit ("chore: version packages", objectstack-ai/objectstack#25606). It carries objectstack-ai/objectstack#25483 and objectstack-ai/objectstack#25564, which objectstack-ai/objectstack#25251 required to ship in one release, so the same-release hold is met. - **Ancestry, read in the objectstack checkout:** `git merge-base --is-ancestor e649c2d91e origin/main` exits 0, and the bump script reported "(on origin/main)". The `@objectstack/cli@18.1.0` tag peels to `e649c2d91e`. - **The path:** `scripts/bump-objectstack.sh e649c2d91e270e3da26820bcfe54a28445305a75 --no-commit` (so the commit carries the repo's trailer pair), which fetched the sha and built and packed the 7 packages, then `pnpm install`. - **What moved:** `.objectstack-sha` and the seven packed manifests in `.objectstack-pin/`. The manifests change only version strings, `18.0.0` to `18.1.0` (the package's own version and its `@objectstack/*` dependencies). - **`pnpm-lock.yaml` is unchanged.** `pnpm install` left it byte-identical, and `pnpm install --frozen-lockfile` exits 0. So the Dependabot lockfile PRs (objectstack-ai#13438, objectstack-ai#13439) do not overlap this one. - `pnpm objectstack:status` answers `✓ built at the pin e649c2d91e27`. ## What the move breaks, and the follow-through in this PR Each item below is red on the bare pin move and was fixed here because the failing check names its own fix. Nothing else changed. ### 1. The `external` tripwire in `@object-ui/auth` (objectui#13427) `packages/auth/src/__tests__/org-roles.test.ts` › "spells external itself only while the pin predates objectstack#25515" went red as designed. Its message prescribes the swap, and this PR does exactly that: - `packages/auth/src/org-roles.ts` re-exports the spec's `MEMBERSHIP_ROLE_EXTERNAL` as `ORG_ROLE_EXTERNAL` instead of spelling the literal. **The exported name and its value (`'external'`) are unchanged**, and its declared type is still the literal `"external"`, so `@object-ui/auth`'s public face does not move (Clause-② stays `no`). `ORG_ROLE_LABELS` is typed `Record<OrgRole, …>` again, with the `| typeof ORG_ROLE_EXTERNAL` union dropped. - The tripwire test is deleted, with its now unused `@objectstack/spec` namespace import. - `InviteMemberDialog.roleCap.test.tsx` and `org-i18n-holdouts-4474.test.tsx` now expect `external` in the invite dialog's role list. This is the spec's 18.1.0 vocabulary reaching `ORG_ROLES`, which objectui#13427's changeset already described: "Wherever the installed spec lists the role, the invite dialog offers it to every issuer who can invite". It is not a choice made by this card. The owner list gains `external`, and a delegated admin or an unloaded membership is offered `['member', 'external']`. The delegate test's name now says "offered member and external only". ### 2. `check:installed-pin-claims`: 34 present-tense "installed 18.0.0" stamps The gate is blocking through `scripts/__tests__/check-installed-spec-pin-claims.test.ts`. The packed manifests now say 18.1.0, so it flagged 34 lines in 24 files, all comments or test description strings. No code changes. **Every stamp was re-measured before it moved.** The method: - The spec SOURCE diff between the two pins, `git diff --name-only 915e9404 e649c2d91e -- packages/spec/src`, touches only `automation/flow.zod.ts` (the `runAs` description text and a comment), `data/object.zod.ts` (the new `membershipKeys` key and its extension refusal), `identity/*` (the `external` role), `kernel/execution-context.zod.ts` and `security/explain.zod.ts` (descriptions and comments), `security/rls.zod.ts` (`MembershipKeysSchema`), `stack.zod.ts` (the duplicate membership-key refusal), `index.ts` (two new exports) and one semantic migration entry, plus tests. - No claim's subject is in that set. The subjects are `ui/*` rows (`ViewDataSchema`, `ComponentPropsMap['object-calendar']` and `['object-kanban']`, list-view conditional formatting and card style rules, the chart and dashboard `aria` tombstones, record components, form section groups, master-detail details, the gantt `navigation` tombstone), `FieldSchema` and `SelectOptionSchema`, and flow node config (`waitForInput`, the single-brace mark, the canvas-seed save errors). The two claims about non-spec sources were checked at their own source: `packages/lint/src/validate-expressions.ts` (the `current_user` CEL message `celAuthoring.ts` quotes) has an empty diff between the pins, and the trigger packages changed only `CHANGELOG.md` and `package.json`. - The test files that carry a claim, or pin a source file's claim, ran green at the new pin (see the verification comment). Each stamp moves `18.0.0` to `18.1.0`. A stamp that carries its re-measure list (`objectui#12030, … objectui#13261`) gains `objectui#13440`, the same form objectui#13261 used. After the restamp the gate answers `34 at the pin` and `OK`. ### 3. `e2e/live/ci/backend.env` `scripts/__tests__/ci-cd-pipeline-doc.test.ts` › "pins OBJECTSTACK_VERSION to the version the lockfile resolves" went red. `OBJECTSTACK_VERSION` moves `18.0.0` to `18.1.0`. Its `@objectstack/cli@18.1.0` tag peels to this pin's commit, so the live lane's backend and the console's pin are the same release. ### 4. The `ObjectSchema` key-count guards in `app-shell` `MetadataService.retiredObjectEnabled.test.ts` (line 157) and `MetadataService.specKeyObjectPayload.test.ts` (line 171) count the keys `ObjectSchema` accepts, and both went red with `expected 47 to be 46`. Spec 18.1.0 adds one key, `membershipKeys` (objectstack-ai/objectstack#25513). **Measured, not assumed:** the spec's own `authorable-surface/data.json` gains exactly `data/Object:membershipKeys` between `915e9404` and `e649c2d91e`, and loses nothing. Both guards now expect 47, and each carries a comment with that basis, in the same form as the earlier re-point (objectui#12971). The two files pass, 25 tests. ## Changeset `.changeset/13440-objectstack-pin-e649c2d.md` declares `'@object-ui/auth': patch`. The published module now imports `MEMBERSHIP_ROLE_EXTERNAL` from `@objectstack/spec`, so it needs a spec that exports it (18.1.0 or later). `node scripts/objectstack-floor.mjs --dry-run` exits 0 and moves 37 ranges to `^18.1.0` at publish, so the published ranges will require that version. `check-changeset-presence.mjs` and `check-changeset-no-major.mjs` pass. ## Acceptance notes - The restamped comments in published packages (`types`, `core`, `plugin-calendar`, `plugin-designer`, `plugin-detail`, `plugin-kanban`, `app-shell`) change no behaviour, so the changeset names `@object-ui/auth` only. - After merge the seat reads #13285 and #13305 against the new pin, as the card says. - [x] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) Session: `https://claude.ai/code/session_01CErMSwkRLW7c7jk23MHfVP` 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01CErMSwkRLW7c7jk23MHfVP --------- Co-authored-by: Claude <noreply@anthropic.com>
Requested by Sales · project thread
Part of #25251
Clause-②: no (narrowing)
Metadata-protocol: semantic
This is PR1 of ruling 6354966423 (letter B): the run-time arm, in plugin-security. PR2, the
runAsdescribe sentence and the ADR-0087 D3 semantic entry inpackages/spec, belongs to the spec lane and is handed to its seat; the card stays open for it.What changed
The data middleware refuses one system context ahead of its
isSystembypass: a context that carriesisSystemand aflowRunId, which is what arunAs: 'system'flow's data nodes write under (resolveRunDataContext, service-automation). Its governed writes to the four identity-assignment objects are refused withPERMISSION_DENIED(403) and nothing is written.sys_user_position,sys_user_permission_set,sys_invitation,sys_memberisSystem+flowRunId(runAs: 'system'flow, user-triggered or scheduled)isSystem, noflowRunId(seeders, better-auth and its reconciliation, invitation placement, the organization-administrator grant)flowRunId, noisSystem(runAs: 'user'flow)flow-system-identity-write-refusal.ts:assertFlowSystemIdentityWriteAllowed(opCtx)and the object set. The predicate is the ruling's,isSystemANDflowRunId, read by truthiness as the bypass readsisSystem. The verbs are the D12 gate's ownGOVERNED_OPERATIONS, now exported fromdelegated-admin-gate.tsso that a verb added there is refused here too.runAsto'user', so the gate judges the acting user and records them asgranted_by. For invitations and memberships it is the organization endpoints (invite-member,cancel-invitation,update-member-role,remove-member), which apply the invitation's placement and role-cap checks.security-plugin.ts: one import and one call in the data middleware, immediately before// System operations bypass security. Nothing else in the file moves.delegated-admin-gate.tsheader andGRANTED_BY_OBJECTSdocblock, which said every system write short-circuits before the gate, and theresolveRunDataContextdocblock sentence in service-automation that said a system run "reads/writes with full access".content/docs/permissions/system-context.mdxgains row 1b, becausecheck-system-context-censusholds everyisSystemread site to a row on the page.@objectstack/plugin-securityminor, with the BREAKING banner (FROM / TO / Unchanged / Fix) and the ADR-0087not-required (no-migration-prescription)marker. The semantic entry itself is PR2's.Census (taken before any edit)
create_record,update_record,delete_recordandupsert_recordnode literals in objectstack atb2c075290and hotcrm atc14ffde5f(TS, JS, JSON, YAML, MD, MDX;node_modulesanddistexcluded): 317 nodes, 0 aimed at any of the four objects. 78 carry no literal target near the node literal; each was read by hand and none names the four. The count is zero, so nothing is worked around.Tests
plugin-security/src/flow-system-identity-write-refusal.test.ts(44 cases). Most drive the realSecurityPluginmiddleware over a realObjectQLengine and a real SQL driver, and read the stored rows back:GOVERNED_OPERATIONSon each of the four objects.transfer,restoreandpurgehave no engine verb to drive.code,status,details) and the store does not move.{ isSystem: true }inserts, updates and deletes each object.sys_business_unit, under the flow's system context.runAs: 'user'context: a tenant-level administrator's grant is admitted and storesgranted_by= that user. A plain member's is refused by the D12 gate (its own message,details.userId= the member), not by this refusal.service-automation/src/runas-system-identity-write-refusal.integration.test.ts(9 cases): real kernel,ObjectQLPlugin, driver-sql, the identity objects, the realSecurityPluginand the realAutomationEngineCRUD nodes.runAs: 'system'flow'screate_recordon each of the four objects, user-triggered and scheduled, fails withinnerRefusal.codePERMISSION_DENIEDand no row is written. Itsupdate_recordanddelete_recordon a grant and on a membership are refused too, and the rows do not move.runAs: 'user'flow triggered by a tenant-level administrator grants the set, withgranted_by= that administrator. Triggered by a plain member, the D12 gate refuses it.runAs: 'system'flow still reads the four and still writes an ordinary object.Pins describe behaviour only.
Verification
pnpm --filter @objectstack/plugin-security --filter @objectstack/service-automation test(under the verify lock, VERDICTcommand-exit 0): plugin-security 215 files, 4509 passed and 45 skipped; service-automation 199 files, 2628 passed.pnpm typecheckin both packages: exit 0, includingcheck:test-typecheck(both test layers compile, 0 pinned signatures).eslint --no-inline-configon the six changed TypeScript files: exit 0.scripts/ablation-replace.mjs, one behaviour): the middleware's call was disabled behind a planted marker, which was proven in the source (anchor 1 → 0) and indist/(ablation-dist-preflight, 2 built files) before the service-automation leg ran, because that suite reads plugin-security fromdist/.runAs: 'user'cases stay green, as expected.runAs: 'system'creates and the update and delete case. TherunAs: 'user'cases and both controls stay green.git diff HEADempty, rebuilt, marker absent from all 6 built files, and both pins green again (44/44, 9/9).dispatch-gates --commands, 70 families): on the first pass 67 exited 0. The other three:check-system-context-census: fixed. The new module readsisSystemand had no row; row 1b is added and the gate exits 0.check-plugin-teardown-shape --self-test: exit 3, because the clone was shallow. Deepened withgit fetch --unshallow origin main.check:dual-build-cjs-loads: exit 3, because 7 prerequisite dists were missing. They are now built.(narrowing)arm and the!) are running now. Their counts go in theos-dev-reporton Security: a runAs system flow writes identity-assignment rows (sys_user_position, sys_invitation) past the delegated-administration gate #25251.Acceptance notes
claude/project-thread-t123rb, notclaude/issue-25251-…. The claim comment is the identity record.delegated-admin-gate.tswas claimed as comment only; it also gainsexportonGOVERNED_OPERATIONS(and aReadonlySettype), so that both checks read one verb list. The package index is unchanged.content/docs/permissions/system-context.mdxwas out of the claimed surface; the census gate requires the row.set_nullleg of a system flow's delete of a parent row, and a hook body'sctx.apiwrite, are refused the same way. The census found no system flow that deletes a parent of the four objects. Exempting nested writes would let a hook body reopen what the ruling closes.runAs: 'system'flow "stays god-mode throughout".docs/adris outside this card's surface, and the precedent run-time PR (fix(service-automation): flow write nodes refuse a stored-metadata family target (#21624) #21649) did not amend its ADR either. The spec lane's PR2 or a separate ADR edit can carry it.security-plugin.tsand the same docs page; its hunks insecurity-plugin.tsare elsewhere. Whichever lands second mergesmain.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu