Skip to content

access: an external object on the default sharing model refuses every member edit (403) because the ownership checks read owner_id / created_by, which no remote row carries — os validate says nothing (epic #27261, found by the P0 spike) #27332

Closed

Description

@objectstack-fleet

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
Filing gate: ① product defect, class c, reach measured through the public HTTP door.
Reader: the epic PM (#27261) routes it after triage sets the lane; the fix PR is the os validate warning below. #27278 (the guide) carries the author-facing rule meanwhile.
Dedupe (open AND closed): "external federated object sharing model owner_id created_by 403 member edit" → 20 hits, none this defect (#26596 is the public_read_write write-floor change, #23268 the retired externalSharingModel); "external object datasource sharingModel owner column validate warning ADR-0015" → 19 hits, none this defect.
reach: public HTTP entry, measured: PATCH /api/v1/data/spike_owned/o_1 by a member whose permission set grants allowEdit answers 403 PERMISSION_DENIED — pinned as step (e3) in packages/runtime/src/plugin-driver-external-object.spike.test.ts (PR #27330, head 680ce37b).
type: Bug

Part of #27261 (Tier A, ruling 6471382887). Found by the P0 spike #27276; its report is comment 6476152968 on #27276.

维护者速读

  • 现象: 外部对象(external 绑定)若沿用默认共享模型,持有 allowEdit 的普通成员改任何一行都被 403;只有管理员或 sharingModel: public_read_write 才能写。
  • 原因: 写之前平台问远端两件事:共享检查读 owner_id,所有权兜底读 created_by == 当前用户。远端行没有这两列,于是永远判「不是你的」。
  • 影响: object-gantt can no longer write back to a same-origin endpoint after #23082 — a measured producer of the retired view-level api source (read + write) #27130 这类虚拟对象的作者照默认写,上线后成员拖甘特条全部 403,且 os validate 不提示。
  • 最小修法: os validate 对「外部对象 + 按所有者的共享模型 + 未映射所有者列」给警告,点名两条出路(改 public_read_write 由 hook 判谁能改,或在 columnMap 映射一列所有者)。不做 defineStack 拒收(那会收窄已发布接受集)。
  • 需要您做的: 无。

Evidence

The spike's second external object spike_owned (no sharingModel, so the platform default) on the plugin driver driver.spike_tree. The member holds { allowRead: true, allowEdit: true } on it. Before refusing, the remote is asked exactly:

  • find { where: { id: 'o_1' }, fields: ['id', 'owner_id'], limit: 1 } (the sharing check), and
  • findOne { where: { $and: [{ id: 'o_1' }, { created_by: <caller> }] }, limit: 1 } (the ownership floor).

Neither column exists on a remote row, so every row reads as someone else's; driver.update is never called. On sharingModel: 'public_read_write' the same member's write reaches beforeUpdate and then driver.update (steps e1/e2 of the same file).

Task

  • os validate warning (not a refusal) for an object with an external binding whose effective sharing model is owner-keyed and whose external.columnMap maps no owner column; the message names both ways out.
  • Pin: the spike's spike_owned shape gets the warning; the spike_node shape (public_read_write) does not.
  • Check whether the same two reads also refuse delete, and name it in the PR.

Clause-②: no (a warning narrows nothing) · Metadata-protocol: none.

Activity

  1. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: 真挡得住的权限 | none | P②

    Triage: p2 · domain:spec (lint) · area:access · 2026-10-29T04:04Z

    ⛔ Not a claim.

    • ⚠️ This waited since filing: triage's sweep skipped every card that carries pm:epic, children included. That is fixed from this round. pm:epic stays, since it is the epic PM's subtree reservation.
    • Read fresh. An external object on the default sharing model refuses every member edit with 403. The ownership checks read owner_id and created_by, which no remote row carries, and os validate says nothing. This is measured through the public door (the spike's step e3). Members are blocked from a capability their permission set grants, so p2.
    • Lane domain:spec: the fix is the os validate warning in packages/lint. It names the two ways out (public_read_write with a hook deciding who may edit, or an owner column in columnMap). ⛔ A warning, not a defineStack refusal, so the published accept set does not narrow, as the card says.
    • bug, pm:queue.

    Generated by Claude Code

  2. added
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    bugSomething isn't working
    and removed on Oct 29, 2026
  3. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
    Claim: PM loop round 3 2026-10-29T04:23Z
    Session: session_017ihL2c5z3q4h8inrn5kwqV
    Account: marchtian
    Branch: claude/project-thread-6ml89f (this container can push only its designated branch; restarted from origin/main once PR #27349, the branch's current head, leaves the merge queue — measured platform exception to claude/issue-<n>-<slug>)
    Worktree: objectstack-issue-27332
    Domain: domain:spec (triage 6487601214: lint lane)
    Seat: epic:#27261#1 (epic PM; no domain:* seat). Maintainer order, 田騏 2026-10-29T04:21Z: 「影响你的任务进度的,你都统一接管」 — the epic PM takes over every derived task that affects the epic's progress.
    File surface: packages/lint/src/** (the new warning rule, its explanation entry, tests), packages/runtime/src/plugin-driver-external-object.spike.test.ts only for the pin, .changeset/** (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: default tier, effort: high; full Fable review before landing (project rule for the spec lane)
    Clause-②: no (a warning narrows nothing)
    Metadata-protocol: none
    Responsibility: the access/ownership checks predate external objects; the defect is the missing author-time signal, measured by the P0 spike (e3)
    Thread-read: 6487601214
    Premise: holds — on origin/main 6e0e7d7 packages/lint/src has no rule pairing an external binding with an owner-keyed sharing model; the neighbouring hints in system-fields.ts:230-242 already name columnMap and ownership: 'none' as ways out for a missing system column, so the dev checks whether ownership: 'none' is a third way out here.
    Serial constraints cleared: #27277 (card thread) touches packages/objectql/src/**, packages/spec/src/** and the guide; no overlap.

  4. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
    os-dev-report

    {
     "issue": 27332,
     "status": "done",
     "branch": "claude/project-thread-6ml89f",
     "pr": "https://github.com/objectstack-ai/objectstack/pull/27422",
     "session": "session_017ihL2c5z3q4h8inrn5kwqV (parent PM session; this run is its subagent)",
     "premise_still_valid": true,
     "summary": "New advisory lint rule security-external-owner-unmapped (os validate / build / lint, CLI only): warns on a writable external object on an owner-keyed sharing model (private, public_read, or unset on a custom object) that declares no owner_id (or, with ownership none/org/business_unit, no created_by), because record sharing reads owner_id and the ownership floor reads created_by == caller and no remote row carries either, so every member edit AND delete is 403. Per the PM note, external.columnMap is applied only by built-in SQL drivers, so the rule is silenced only by a DECLARED field; a columnMap-only entry still warns with its own wording; the hint names both ways out driver-neutrally (public_read_write + beforeUpdate hook, or declare the field and have the driver return the remote owner under that name: SQL via columnMap, plugin driver itself), matching PR #27403's guide wording. Measured: delete is refused by the same two reads; public_read_write opens edits but NOT deletes (floor keeps deletes by design); ownership: 'none' is not a way out (moves the refusal to the floor, measured 403), so the rule fires on it naming created_by. Premise partly corrected: spike_owned already got security-owd-unset (error) whose hint recommends 'private', the 403 shape; nothing named the refusal. Pin: spike_owned warns, spike_node silent (lint test with the spike's shapes verbatim). Card assignee untouched.",
     "tests": "At ee143ecb: pnpm --filter @objectstack/lint test to Test Files 177 passed (177), Tests 7583 passed (7583); pnpm --filter @objectstack/lint typecheck to tsc clean, check:test-typecheck OK; new validate-external-owner-sharing.test.ts 14/14. Ablation (src-imported test, no dist leg): owner === 'author' silence arm removed via scripts/ablation-replace.mjs (anchor 1 to 0, blob 1cbc9bde to 24cd3fc6) to 1 failed / 13 passed; restored blob == HEAD, git diff HEAD empty. Runtime measurement: one-off scratch boot of the spike composition (deleted before commit) over 17 object shapes, PATCH/DELETE/GET per shape (table in PR body); SQL half measured on SqlDriver (better-sqlite3) with columnMap { assignee: owner_id }: the sharing reads return owner_id.",
     "gates": "dispatch-gates --commands at ee143ecb: 88 derived, all run sequentially at ee143ecb, exits captured before pipes; --ran with COMMAND :: exit N record: 88 accounted, 87 run exit 0, 1 NOT-MEASURED (node scripts/check-plugin-teardown-shape.mjs --self-test exit 3: positive control pinned to commit 621a4876 unreachable in this shallow checkout; checker health only). First run at 4199782a: check:docs-transcript-drift red (rule count 55 to 56 in four CLI transcripts) to fixed in b0e37b9d, green at head. CI check-runs on PR #27422: in_progress / not yet read (no polling per contract).",
     "dogfood_battery": "n/a - no ! in the PR title and no (narrowing) arm",
     "mcp_calls": "2 - mcp__github__issue_read (get, get_comments) on #27332; no MCP write tool called",
     "api_writes": "2 relay writes (+ git push x3, not REST): fleet-write pr_create to PR #27422 (draft, assignee marchtian, body read-back identical 13764 bytes); post-stamped comment on #27332 (this os-dev-report)",
     "line_budget": "diff vs merge base 7e200b4a: 11 files; lint rule ~170 lines, test ~150, explanation ~45, registry/barrel ~30, changeset ~20, 4 docs lines; size S-M",
     "deviations": [
      "File surface breach (declared): four content/docs transcripts (deployment/cli.mdx, deployment/validating-metadata.mdx, getting-started/build-with-claude-code.mdx, ui/react-pages.mdx) changed 'author-time rules (55)' to '(56)' because check:docs-transcript-drift is a gate the new rule trips (dispatch: follow whatever gates the rule trips). Not the virtual-objects guide.",
      "Branch/trailers/PR body/comment stamp per PM container deviations (claude/project-thread-6ml89f; model-free trailers; PR body ends with the harness footer, not the session-URL footer form; comment's first line is the stamp, os-dev-report on line 2).",
      "Rule design revised mid-run on the PM note (columnMap not applied by the engine): first revision silenced on a columnMap entry; second (ee143ecb) silences only on a declared field.",
      "Pin lives in packages/lint (the spike's two object shapes copied verbatim); the spike test file was not edited.",
      "No test:repo run locally (CI)."
     ],
     "files_changed": [
      "packages/lint/src/validate-external-owner-sharing.ts (new)",
      "packages/lint/src/validate-external-owner-sharing.test.ts (new)",
      "packages/lint/src/authoring-rules.ts",
      "packages/lint/src/index.ts",
      "packages/lint/src/rule-explanations.ts",
      "packages/lint/src/validate-sharing-rule-enforceability.ts (export effectiveSharingModelOf)",
      ".changeset/27332-external-owner-unmapped-warning.md",
      "content/docs/deployment/cli.mdx, content/docs/deployment/validating-metadata.mdx, content/docs/getting-started/build-with-claude-code.mdx, content/docs/ui/react-pages.mdx (rule count only)"
     ],
     "open_questions": [
      {
       "question": "Guide wording on PR #27403 (#27277's surface): 'Either set sharingModel explicitly' alone is not a way out (explicit 'private' is still 403), and the callout does not say member deletes stay refused on public_read_write. Change the guide?",
       "options": [
        "A: #27277's dev adds two sentences (way out is 'public_read_write'; deletes stay refused there unless the driver returns created_by or a policy/share grants delete)",
        "B: leave the guide; the os explain text carries it"
       ],
       "recommendation": "A, because the guide and the warning must agree and the guide is what authors read first"
      },
      {
       "question": "security-owd-unset's hint recommends sharingModel 'private' unconditionally; on a writable external object that is the 403 shape (spike_owned gets both findings). Reword it for external objects?",
       "options": [
        "A: separate small card to make the hint name public_read_write for a writable external object",
        "B: leave it; the new warning sits beside it"
       ],
       "recommendation": "A, low cost, removes contradictory advice on the same object"
      }
     ],
     "out_of_scope_findings": [
      "class: b · reach: exception: security (possible data exposure); also measured through GET /data/OBJECT in a booted spike composition · evidence: on a sharingModel 'private' external object whose driver returns owner_id (columnMap or plugin driver) but owner_id is NOT declared in fields, a member GET returned 2 of 2 rows including the admin-owned one; with owner_id declared, 1 (own only). Writes on the same object are owner-scoped (PATCH other 403). Seam: spec:ObjectSchema.sharingModel 'private' (owner-only) + external.columnMap to runtime: plugin-sharing SharingService.buildReadFilter via hasPhantomOwnerAnchor (federated-phantom-anchors.ts; provenance reads fields only, so a served owner counts as unstored and reads go unscoped) · dedupe words: federated phantom owner_id read filter unscoped private external columnMap, hasPhantomOwnerAnchor read scope",
      "class: a · reach: named producer: the engine's update stamp (spike step e2 pins driver.update data keys end_date, guard_note, id, start_date, updated_at, updated_by) · evidence: SqlDriver (better-sqlite3, schemaMode external) over a remote table without audit columns: update('m','r1',{ name, updated_by }) to 'no such column: updated_by'; not yet measured through PATCH on a SQL-backed external object, so it may be handled upstream · dedupe words: external object update updated_by updated_at no such column SQL driver federated audit stamp",
      "carrier: #27277 (PR #27403) · guide callout: 'set sharingModel explicitly' is not a way out by itself; deletes stay refused on public_read_write - noted, not filed",
      "carrier: none · security-owd-unset hint recommends 'private' for a writable external object (a warning steering to runtime-honoured metadata that makes things worse: boundary, not class c) - in PR acceptance notes"
     ]
    }
  5. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
    Landed · 2026-10-29T07:18Z

    Reading Value
    PR #27422 merged 2026-10-29T07:16Z as 922973840dd990fda6fc727b4f7e77113d3d6a8d (merge queue), head 120864f4
    Card closed completed by Fixes #27332

    State: pm:dispatched removed in the same act. Landed by the epic PM after seat ACCEPT on the PR, following an independent full Fable review. The maintainer's order (田騏, 2026-10-29T04:19Z): 「你应该使用 pm-dispatch 技能处理 issue 和 pr 落地」. The read-scope exposure found on the way is #27425. Still carried, not filed: security-owd-unset's hint recommends private for a writable external object, which is the 403 shape; the seat carries it on the epic.

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

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsarea:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:specpm:epicpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions