Skip to content

security(row scope): an org member can re-parent a sys_user_preference row to another member by PATCHing its user_id, planting a preference the victim then reads as their own #29630

Closed

Description

@objectstack-fleet

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

立卡门类别: ① 产品缺陷 — 具名落点 + 复现 (read from source, not driven). Found by the #29443 card thread (round-2 report issuecomment-6589962356) and confirmed on main by contract review issuecomment-6590198210 on PR #29575, which closed the same path for sys_personal_view with a save rule.
reach: the generic data door PATCH /api/v1/data/sys_user_preference/:id for any organization member, on any row they own.
业务理由: a user's preferences are their own UI state: pinned list views (LIST_PINNED_VIEW_PREFERENCE, #27594), recent items, and whatever ui/packages/data-objectstack/src/userState.ts reads. A member who can write a row into another member's preferences can change what that member's Console opens on, which views and records it surfaces, and any other state a preference key drives. The victim sees it as their own setting, and nothing tells them otherwise.
动手的读者: domain:server (plugin-security row scope, and the identity / preference hooks in plugin-auth or platform-objects). Triage grades and routes it; the seat suggests p1 as an access-boundary defect.
查重 (includes closed issues): sys_user_preference user_id re-parent → 8, sys_user_preference owner change update → 27, owner_only_writes created_by user_id → 1. None covers it. The nearest are #29443 (the sys_personal_view twin, closed in PR #29575), #23804 / #22961 (preference audit events, closed) and #8023 (closed, public_read_write creator-only writes).

What happens on main (from 6590198210)

  • sys-user-preference.object.ts: user_id is a lookup and not readonly, and no hook guards a change to it (identity-write-guard guards sys_user only).
  • member_default holds both owner_only_writes ('*', update, created_by == current_user.id) and sys_user_preference_self (all, user_id == current_user.id). collectRLSPolicies applies an object's defaultRowScope only when no held policy covers that object and operation, and a held '*' policy counts. RLSCompiler.compileFilter OR-combines the policies for one object and operation.
  • So on UPDATE the pre-image passes on either arm, and the post-image passes on created_by. A member can set user_id to another member's id. The row then reads under the victim's _self policy. The per-org (user_id, key) unique index blocks only keys the victim already holds.

Wanted

  1. Measure first: drive it over REST on SQLite and driver-memory (member A re-parents their row to member B, then B reads it), and report the table.
  2. Close it for this object: with the shape PR feat(platform-objects): sys_personal_view, a reader's own named list views, with the owner's delete door (server step) #29575 used for sys_personal_view, a save rule refusing any non-system user_id change with 403. Pin it for a member, an org admin and a platform admin, plus an unchanged-user_id echo as the control.
  3. The class: list every object whose owner column is not created_by and whose row scope relies on that column. For each one, say whether the '*' update floor opens the same path. Either close each one, or file a platform-wide card for the floor itself (the '*' policy taking the place of an object's own scope on UPDATE). Say which and why.

Level: a write that is accepted today is refused, so Clause-②: yes (narrowing), on the same reading as #29443's save rule.

Activity

  1. added
    bugSomething isn't working
    domain:serverDomain lane — seat card indexed by label:pm:seat
    on Nov 2, 2026
  2. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: p1 · domain:server · pm:queue · security · 2026-11-02T22:52Z

    ⛔ Not a claim.


    Generated by Claude Code

  3. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

    Claim: PM loop round 1 · card thread · 2026-11-02T23:08Z
    Session: session_01ADHkmQJdgarUjPLNgFBY67 (the seat); the card thread is session_01ADHkmTNFJm8TGKvyoyKegL
    Account: os-steve (the seat's linked user; the card's assignee)
    Branch: claude/project-thread-rq4y7m
    Worktree: the card thread's own container checkout
    Domain: domain:server
    Seat: domain:server#3
    Ruling-ref: the maintainer, verbatim, in the project thread: 「开 server 2、3、5 号席位, 前任已经下班,接手所有开发,包括跟进之前未完成的开发落地。」
    File surface: packages/platform-objects/src/identity/sys-user-preference.object.ts, the sys_user_preference save rule beside packages/plugins/plugin-auth/src/personal-view-hooks.ts (one shared owner-fixed check if both objects use it) and its registration, their tests, a dogfood case, one .changeset/29630-*.md; read-only for collectRLSPolicies / RLSCompiler / member_default (any change there, or a close on another object, only after a seat ruling, and the claim is amended then)
    Container & model: card thread cmsg_01FdL1XeUyvVNxLreh3F4Qi96qx2coMehMV85gPfR94fp2, mode:thread, model: opus (--tier names no path mandate: PM's call, default), effort: high
    Clause-②: no (narrowing)
    Metadata-protocol: none
    Responsibility: the card thread measures (step 1 table), closes sys_user_preference (step 2), reports the class list (step 3) with options in open_questions, builds further closes only after the seat ruling, opens the draft PR and reports; the seat rules, reviews (narrowing face, CONTRACT_REVIEW_TIER) and lands
    Thread-read: 6590532575
    Premise: holds (site re-read on origin/main 4008e629 by the seat before dispatch)
    Serial constraints cleared: no other seat-3 card on plugin-auth, platform-objects identity or plugin-security row scope; #29610 (seat 2) and #29611 (seat 4) plugin-security files not touched; no ADR edit
    Resume-from: none — fresh from origin/main

    State: pm:queue → pm:dispatched, assigned to os-steve, recorded by this claim. Order: project file server-seat3/standard-order.md + server-seat3/29630-delta.md.

  4. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · os-steve · session_01ADHkmTNFJm8TGKvyoyKegL
    os-dev-report

    {
      "issue": 29630,
      "status": "rework",
      "branch": "claude/project-thread-rq4y7m",
      "pr": null,
      "session": "session_01ADHkmTNFJm8TGKvyoyKegL",
      "premise_still_valid": true,
      "summary": "Stopped on the maintainer's order before the PR; hand-off. Pushed tip 34568e18 (fix e6f17916, tests 3bbd5da8, merge of origin/main d280265b8). Built: one shared owner-fixed rule in plugin-auth (owner-fixed-rule.ts: isOwnerChange, ownerFixedError, assertOwnerUnchanged), now used by sys_personal_view (its inline check removed, message byte-identical) and by a new sys_user_preference beforeUpdate rule (user-preference-hooks.ts), registered in AuthPlugin and IdentityObjectsPlugin beside the personal-view hooks. A non-system update sending a user_id other than the stored one is refused 403 PERMISSION_DENIED naming the row and the column, echoing no other id. No new public export. Also: system-context.mdx row 26c widened to both objects on the shared anchor (register regenerated), changeset .changeset/29630-user-preference-owner-fixed.md (patch, `!`, Clause-② no (narrowing), Metadata-protocol none, adr-0087 not-required). Left: finish gates (3 exit-3 prerequisites + type-check-debt + pnpm lint), the full dogfood battery on the final head, open the draft PR, final report.",
      "tests": "Step-1 measurement and the red readings: carried to the seat privately (security card). New pins: plugin-auth/src/user-preference-hooks.test.ts (10 cases: registration; member, org admin, platform admin refused 403; message names row + column, no other id; clearing the owner refused; controls echo / value-only / system / other object) and qa/dogfood/test/user-preference-owner-fixed.dogfood.test.ts (7 cases over REST). On e6f17916..3bbd5da8: user-preference-hooks + personal-view-hooks Tests 37/37; dogfood file + personal-view-row-scope.dogfood Tests 23/23. On 34568e18: pnpm --filter @objectstack/plugin-auth test Test Files 216/216, Tests 3703 passed 10 skipped; typecheck exit 0. Ablations, each red then restored with blob == HEAD and git diff HEAD empty: (1) drop the rule call in user-preference-hooks.ts: unit 5 refusal pins red, 5 controls green; (2) make the shared isOwnerChange always false: 6 red across both objects (5 preference + the personal-view platform-admin pin); (3) guard the registration in auth-plugin.ts, rebuild plugin-auth, dist-preflight marker present in 2 files: dogfood member and org-admin pins red (2/7); restored, rebuilt, preflight --absent clean, 7/7 green. The platform-admin own-row REST pin stays green under ablation (3): an existing write check already refuses that leg; the unit pin holds the rule on its own for that caller.",
      "gates": "34568e18, unlocked: dispatch-gates --commands listed 95; 93 run (type-check-debt held back to run after the battery), 90 exit 0, 3 exit 3 (prerequisites, NOT MEASURED): check-plugin-teardown-shape --self-test (shallow clone, needs the fixture fetch), spec check:skill-examples, check:dual-build-cjs-loads (needs every package dist; only the dogfood closure + examples were built). check:system-context-census exit 0 (its row edited in this diff). pnpm lint: NOT MEASURED (stopped mid-run by the order). --ran reconciliation: not run.",
      "dogfood_battery": "NOT MEASURED: started on 34568e18 under the lock, stopped by the maintainer's stop order before a verdict.",
      "mcp_calls": "2 — mcp__github__issue_read (get, get_comments); no write tools",
      "api_writes": "1 — this comment (post-stamped.mjs). git push is not a REST write.",
      "files_changed": [
        "packages/plugins/plugin-auth/src/owner-fixed-rule.ts (new)",
        "packages/plugins/plugin-auth/src/user-preference-hooks.ts (new)",
        "packages/plugins/plugin-auth/src/user-preference-hooks.test.ts (new)",
        "packages/plugins/plugin-auth/src/personal-view-hooks.ts",
        "packages/plugins/plugin-auth/src/auth-plugin.ts",
        "packages/plugins/plugin-auth/src/identity-objects-plugin.ts",
        "packages/qa/dogfood/test/user-preference-owner-fixed.dogfood.test.ts (new)",
        "content/docs/permissions/system-context.mdx",
        ".changeset/29630-user-preference-owner-fixed.md (new)"
      ],
      "line_budget": "9 files; no forced edit to an existing test",
      "deviations": [
        "Stopped before the PR on the maintainer's order; no PR opened.",
        "The delta's engine-rig shape was not used for the pins: the rig does not resolve the org_member position, so the RLS check answers before the rule there. Unit pins call the registered hook; the REST dogfood file is the real-stack face.",
        "Clause-②: no (narrowing), not yes: the shared helper is not exported from a published entry.",
        "driver-memory leg of the measure: not reachable for this object (detail to the seat privately)."
      ],
      "open_questions": [
        {
          "question": "Step 3, the class: per-object closes (a) or a platform card for the '*' update floor (b)?",
          "options": [
            "A: close each object here with the shared rule",
            "B: file a platform-wide card for the '*' update floor beside an object's own scope on UPDATE"
          ],
          "recommendation": "B. Among shipped objects nothing is left to close: the census (carried privately) finds every other owner-column-scoped object either refuses user-context writes outright or keeps its owner column readonly. What stays open is any author or app object that scopes rows on its own owner column, which per-object hooks cannot reach. collectRLSPolicies and member_default were not touched."
        },
        {
          "question": "PR closing keyword: Fixes #29630, or Part of #29630 if the seat wants the platform card filed first?",
          "options": ["A: Fixes (the shipped population is closed)", "B: Part of"],
          "recommendation": "A, if the seat rules B on step 3 and files the floor as its own card."
        }
      ],
      "security_findings": "withheld",
      "out_of_scope_findings": ["withheld, carried to the seat privately"]
    }

    Stamped 2026-11-03T00:00Z.


    Generated by Claude Code

  5. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

    Release: session_01ADHkmQJdgarUjPLNgFBY67 · reason: clock-out (maintainer order, quoted below) · next: pm:queue, for the next domain:server seat to resume from the branch
    Resume-from: claude/project-thread-rq4y7m@34568e18383c763be79cdcce1130b14a2022cb2b

    Hand-off note · #29630 · 2026-11-03T00:01Z

    • Order: the maintainer, verbatim, 2026-11-02T23:57Z: 「还在开发、没提 PR 的卡,要求立刻提交代码到分支,也交接」. The card thread pushed its work in progress and stopped. No PR is open.
    • Built on the branch (dev hand-off report 6591649246):
    • Still owed before a PR:
      • pnpm lint and type-check-debt;
      • the full dogfood battery (stopped before a verdict);
      • the three gates that exited 3 on prerequisites (the teardown self-test needs an unshallow fetch, plus skill-examples, and dual-build-cjs-loads needs every dist built);
      • the --ran reconciliation;
      • then the draft PR.
    • Next seat owes:
      • claim with Resume-from above and dispatch a card thread to finish the list;
      • then a narrowing-face review at CONTRACT_REVIEW_TIER, ACCEPT and land.
    • Step 3 (the class): the dev's readings and census are held off-board under the security-findings rule. The dev reports nothing else to close among shipped objects and recommends option (b), a platform-wide card for the '*' update floor. That is for the next seat to rule, after reading the readings through the project.
  6. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-warren · session_01JQwxbXKRzgnVvjzxaYxLUf

    Claim: PM loop round 1 · card thread · 2026-11-03T00:23Z
    Session: session_01JQwxbXKRzgnVvjzxaYxLUf (the seat); the card thread is session_01JQwxbWZsNmNDugrjfzMoJW
    Account: os-warren
    Branch: claude/project-thread-57qwum
    Worktree: the card thread's own container checkout
    Domain: domain:server
    Seat: domain:server#2
    File surface: as claim 6590795150 names it: packages/platform-objects/src/identity/sys-user-preference.object.ts, the shared owner-fixed rule in packages/plugins/plugin-auth/src/ (owner-fixed-rule.ts, user-preference-hooks.ts, personal-view-hooks.ts) and their registrations, their tests, a dogfood case, the system-context census row, one .changeset/29630-*.md; read-only for collectRLSPolicies / RLSCompiler / member_default (any change there, or a close on another object, only after a seat ruling, and the claim is amended then)
    Container & model: card thread, mode:thread, model: TIER_DEFAULT (--tier names no path mandate), effort: project default
    Clause-②: no (narrowing)
    Metadata-protocol: none
    Responsibility: plugin-auth / platform-objects identity (this lane's code) | none — no platform path guards the owner column on this object | any org member over the data door; reachable today
    Thread-read: 6591667134
    Premise: holds (previous seat-3 claim 6590795150 premise; branch tip re-read by the card thread after merging origin/main 7dae3b3c)
    Serial constraints cleared: no open server claim on plugin-auth owner rules, platform-objects identity or plugin-security row scope (#29610 / PR #29637 touches plugin-security explain only)
    Resume-from: claude/project-thread-rq4y7m@34568e18383c763be79cdcce1130b14a2022cb2b

    Order: project file pm-orders/server/order-29630.md.


    Generated by Claude Code

  7. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-warren · session_01JQwxbWZsNmNDugrjfzMoJW
    os-dev-report

    {
      "issue": 29630,
      "status": "done",
      "branch": "claude/project-thread-57qwum",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/29685",
      "session": "session_01JQwxbWZsNmNDugrjfzMoJW",
      "premise_still_valid": true,
      "summary": "Resumed from claude/project-thread-rq4y7m@34568e18 per claim 6592010189 and merged origin/main 7dae3b3c (493dbed3). Verified the built work as handed off (shared owner-fixed-rule.ts in plugin-auth, the sys_user_preference beforeUpdate rule registered in AuthPlugin and IdentityObjectsPlugin, personal-view moved onto the shared check with its message unchanged, unit + REST pins, census row 26c, changeset). One commit added (424e9295): the shared check's header comment and the changeset's 'Who sees it' line now state the rule alone, per triage 6590532575. Finished every owed gate on the final head and opened draft PR #29685 (Fixes #29630). Step 3: class census in project files; recommended option B.",
      "tests": "All on 424e9295. pnpm --filter @objectstack/plugin-auth build, test and typecheck under the lock: VERDICT command-exit 0, Test Files 216 passed (216), Tests 3703 passed, 10 skipped (3713). Pins: user-preference-hooks.test.ts (registration; member, org admin, platform admin refused 403; message names row and column, no other id; clearing the owner refused; controls echo, value-only, system, other object) and user-preference-owner-fixed.dogfood.test.ts (7 REST cases), both inside the runs above and below. Ablations: carried from hand-off 6591649246 (three, each red then restored, blob equal to HEAD); not re-run, since the only later change in a tested file is comment text in owner-fixed-rule.ts.",
      "gates": "424e9295, full build first (pnpm build --concurrency=2 under the lock: VERDICT command-exit 0, 117 of 117 tasks), history unshallowed. dispatch-gates --commands: 95 families, each run once with stdin from /dev/null and the exit captured before any pipe: 95 exit 0, including the three earlier exit-3 families: check-plugin-teardown-shape --self-test (48 cases), spec check:skill-examples (282 prose examples), check:dual-build-cjs-loads (115 entry points, 70 packages), plus check:system-context-census and check:type-check-debt (0 raw tsc errors above the ledger). dispatch-gates --ran: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. pnpm lint: exit 0. check-changeset-no-major --event with the PR body: exit 0, level axis no (narrowing) + none at patch. CI check-runs on the PR: in_progress when this report was written.",
      "dogfood_battery": "424e9295 · os-verify-lock VERDICT command-exit 0 (held 32m17s) · Test Files 400 passed, 3 skipped (403) · Tests 3304 passed, 28 skipped (3332) · no failures",
      "mcp_calls": "0",
      "api_writes": "2 — fleet-write relay pr_create (POST /repos/objectstack-ai/objectstack/dispatches, run 41559634862, PR #29685, 6072 bytes sent and stored, identical); this comment via post-stamped.mjs. git push is not a REST write. Reads: gh api REST (card, comments, claim, PR read-back).",
      "files_changed": [
        "packages/plugins/plugin-auth/src/owner-fixed-rule.ts (new)",
        "packages/plugins/plugin-auth/src/user-preference-hooks.ts (new)",
        "packages/plugins/plugin-auth/src/user-preference-hooks.test.ts (new)",
        "packages/plugins/plugin-auth/src/personal-view-hooks.ts",
        "packages/plugins/plugin-auth/src/auth-plugin.ts",
        "packages/plugins/plugin-auth/src/identity-objects-plugin.ts",
        "packages/qa/dogfood/test/user-preference-owner-fixed.dogfood.test.ts (new)",
        "content/docs/permissions/system-context.mdx",
        ".changeset/29630-user-preference-owner-fixed.md (new)"
      ],
      "deviations": [
        "One commit beyond the hand-off (424e9295): comment and changeset wording only, trimmed to the rule per triage 6590532575; inside the claim's file surface.",
        "PR body folds the repository PR template headings (What changes, Why, Verification) into the os-dev section order, the usual exception.",
        "Clause-②: no (narrowing) confirmed from the diff: one accepted write is refused, no public export or published type moves. Metadata-protocol: none confirmed."
      ],
      "body_amendments": [],
      "open_questions": [
        {
          "question": "Step 3 (the class): class census in project files. Which option?",
          "options": ["A: close per object here", "B: one platform-level card, filed through the advisory channel"],
          "recommendation": "B. Nothing else is left to close among shipped objects, so Fixes #29630 stands; the reasons are in the census file."
        }
      ],
      "security_findings": "withheld",
      "out_of_scope_findings": ["withheld: class census in project files"]
    }

    Stamped 2026-11-03T01:26Z.

  8. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-warren · session_01JQwxbXKRzgnVvjzxaYxLUf

    Landing record · #29630 · 2026-11-03T02:10Z


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions