Skip to content

feat(lint): security-external-owner-unmapped warns when a writable external object refuses every member write - #27422

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-6ml89f
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-6ml89f

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by 田騏 · project thread
Fixes #27332
Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么: os validate / os build / os lint 新增一条警告 security-external-owner-unmapped:可写的外部对象若用按所有者的共享模型(private、public_read,或不写),又没在 fields 声明所有者检查读的那一列,就提示;警告点名两条出路(改 public_read_write 由 beforeUpdate hook 判谁能改;或在 fields 声明 owner_id,并让驱动以这个名字返回远端所有者——内置 SQL 驱动靠 external.columnMap 做到,插件驱动要自己返回,因为 columnMap 不由引擎执行)。
  • 为什么改: 这类对象上持有 allowEdit 的普通成员改任何一行、删任何一行都被 403(共享检查读 owner_id,所有权兜底读 created_by == 当前用户,远端行都没有这两列),以前 os validate 不提示。实测删除同样被拒;ownership: 'none' 不是出路(只是把拒绝从共享检查挪到兜底),所以它照样报警,改为点名 created_by。
  • 风险与回滚: 只是警告,不拒收,defineStack 和所有发布入口接受的集合不变;运行时零改动。回滚 = revert 本 PR。
  • 先看: packages/lint/src/validate-external-owner-sharing.ts(判定的两条分支 undeclaredOwnershipColumn,以及只有 columnMap 没声明字段时的措辞)、packages/lint/src/rule-explanations.ts(os explain 的长文)、packages/lint/src/authoring-rules.ts(登记为 advisory、仅 CLI)。
  • 两分钟验证: pnpm --filter @objectstack/lint exec vitest run src/validate-external-owner-sharing.test.ts,预期 14 个用例全过:spike 的 spike_owned 报一条警告,spike_node(public_read_write)不报,且在 os validate 的结果里是 advisory 不是 error。
  • 需要你做的: 无,席位落地。

What changes

A new advisory rule, security-external-owner-unmapped, on all three authoring commands (validate, build, lint; CLI only, held off the runtime publish door for the reason every advisory object rule carries, RUNTIME_OBJECT_ADVISORY_VOLUME).

It warns on an object that has an external binding with writable: true, an effective sharing model that keys writes to an owner (private, public_read, or none on a custom object, which the runtime treats as private), and no remote column serving the check that refuses the write:

  1. Owner arm. The object carries owner_id (ownership: 'user', the default) as the platform's injected, unstored anchor, not declared in fields. Record sharing reads owner_id, gets null, and refuses.
  2. Floor arm. The object carries no owner_id (ownership: 'none' | 'org' | 'business_unit'). Record sharing then abstains, and the platform ownership floor (created_by == current_user.id) decides. It warns when created_by is the unstored anchor, not declared, or when the audit columns are off (the floor then compiles to a deny).

What lets the owner write is the driver returning the remote owner under the field name the check reads. external.columnMap is applied by the built-in SQL drivers, not by the engine, so on a plugin driver a columnMap entry serves nothing. The author's vouch the rule reads is therefore a declared owner_id / created_by field (the provenance the runtime guards read: resolveInjectedColumnProvenance, @objectstack/spec/data); a columnMap entry alone still warns, worded for that case (works on a built-in SQL driver only). The message names the column the refusing check reads; the hint names both ways out, driver-neutrally: public_read_write plus a beforeUpdate hook, or declare the field and have the driver return the remote owner under that name (a built-in SQL driver through external.columnMap, a plugin driver by itself). This matches the guide wording on PR #27403 ("have your driver return the owner under the field name the checks read"). The long text (both checks, why ownership: 'none' is no way out, deletes) is os explain security-external-owner-unmapped, and agrees with the guide's "Choose the sharing model on purpose" callout (content/docs/data-modeling/virtual-objects.mdx, not edited here).

Supporting edits: the effective-sharing-model mirror in validate-sharing-rule-enforceability.ts is exported and reused instead of copied a second time; the rule id is exported from the root barrel; one changeset (@objectstack/lint patch).

Outside packages/lint: the rule count in four CLI docs transcripts (55 to 56, see notes). Not touched: the runtime, defineStack, the spike test (the pin lives in the lint test, with the spike's two object shapes copied verbatim), the virtual-objects guide.

Delete (the card's third task)

Yes, the same two reads refuse delete. Measured over the spike's plugin-driver composition (a one-off harness, not committed): DELETE /data/OBJECT/ID by a member holding allowDelete answers 403 PERMISSION_DENIED on the default, private and public_read models, and the remote is asked exactly find { where: { id }, fields: ['id', 'owner_id'], limit: 1 } then findOne { where: { $and: [{ id }, { created_by: CALLER }] }, limit: 1 }; driver.delete is never called.

One more fact the PR names because the warning's first way out depends on it: on public_read_write the edit passes (200) but the delete is still refused (403). The floor keeps deletes to created_by there by design (owdOpenWritesCoversOperation in plugin-security: that OWD opens update only), and an external object has no stored created_by. So "public_read_write plus a hook" opens member edits, not member deletes. The rule does not warn on it (the card pins spike_node silent); the os explain text says it.

Measured: which knobs make a member edit pass

One-off boot of the spike composition (engine, sqlite default datasource, security, sharing, REST) with one external object per row on a plugin driver. Rows marked "map" ran on a variant of the driver that renames the remote column the way the SQL driver applies columnMap (remote column to local field), i.e. a driver that returns the owner under the checked name; the other rows' driver ignores columnMap, as every plugin driver does. Member holds allowRead/allowEdit/allowDelete; rows r_mine (owner or creator = member), r_theirs (= admin).

The SQL-driver half was measured separately on SqlDriver (better-sqlite3) over a remote table rows(id, name, assignee) registered as external with columnMap: { assignee: 'owner_id' }: the sharing check's exact reads, find { where: { id }, fields: ['id', 'owner_id'], limit: 1 } and findOne { where: { $and: [{ id }, { owner_id: U }] } }, both return the row with owner_id: 'u1' (with and without owner_id declared); without the map the projection returns no owner_id.

object shape PATCH own PATCH other DELETE own warns
no sharingModel (spike_owned) 403 403 403 yes (owner_id)
private / public_read 403 403 403 yes (owner_id)
public_read_write (spike_node) 200 200 403 no
private, map remote owner to owner_id, not declared 200 403 200 yes (columnMap wording)
same, plus owner_id declared in fields 200 404 (read-scoped) 200 no
private, driver returns owner_id itself, declared 200 404 200 no
private, ownership: 'none' (also unset model; also 'org') 403 403 403 yes (created_by)
private, ownership: 'none', systemFields.audit: false 403 403 403 yes (created_by)
private, ownership: 'none', map remote creator to created_by, not declared 200 403 200 yes (columnMap wording)
same, plus created_by declared 200 403 200 no
private, ownership: 'none', driver returns created_by itself, declared 200 403 200 no
same, not declared 200 403 200 yes (false positive, see notes)
private, columnMap maps only created_by (owner_id still injected) 403 403 403 yes (owner_id)
private, systemFields.audit: false 403 403 403 yes (owner_id)

So ownership: 'none' is not a third way out (the neighbouring unprovisionedAnchorFix hint in system-fields.ts offers "opt the object out of the injection" for a different finding; here it only moves the refusal to the floor), and the rule keeps firing on it.

Tests

  • New packages/lint/src/validate-external-owner-sharing.test.ts (14 cases): the spike's two shapes through defineStack (spike_owned warns naming owner_id at objects[1].sharingModel; spike_node silent; through runAuthoringRules('validate') it lands in advisories, never errors), then the matrix above: owner-keyed models warn; public_read_write, controlled_by_parent, read-only bindings, platform-stored objects and an unset system object are silent; a declared owner_id silences (with or without a columnMap entry); a columnMap entry alone still warns, worded for it; a created_by-only map does not serve the owner check; ownership: 'none' | 'org' | 'business_unit' warns naming created_by, the audit-off variant warns, a columnMap entry for created_by alone warns, a declared created_by silences.
  • pnpm --filter @objectstack/lint test at ee143ec: 177 files / 7583 tests passed. pnpm --filter @objectstack/lint typecheck: tsc --noEmit clean, check:test-typecheck OK.
  • Ablation (one, on the rule's silence read): the owner === 'author' arm removed via scripts/ablation-replace.mjs (anchor 1 to 0, blob 1cbc9bde to 24cd3fc6), the declared-owner_id case went red (1 failed / 13 passed), restore proven by blob == HEAD and an empty git diff HEAD. (An earlier ablation on the first revision's columnMap silence also went red; that silence was removed when the PM's measurement showed columnMap is not applied by the engine.)

Acceptance notes

  • Premise, partly corrected. "os validate says nothing" is not literally true for the spike's spike_owned: it already gets security-owd-unset (error) because it declares no sharingModel. But that rule's hint recommends 'private', which on a writable external object is exactly the 403 shape; nothing named the refusal. Both now fire on spike_owned, and the new warning names the way out. Whether security-owd-unset's hint should stop recommending 'private' for an external object is left for the card owner; it is a wording change on a gating rule, not this card.
  • Read scoping with an undeclared owner. In the measurement, private with the driver returning owner_id but no declared field let the member READ the admin's row (GET returned 2 of 2 remaining rows; with the field declared, 1). The sharing service's phantom-anchor test reads fields only, so a served-but-undeclared owner counts as unstored for reads while writes are owner-scoped. That is one more reason the rule asks for the declaration. Reported separately in the dev report; not changed here.
  • Known false positive. A remote table whose column is literally named created_by (or owner_id) and returned by the driver, but not declared, passes the checks while the rule still warns: lint cannot see what a driver returns. The hint (declare the field) is harmless there and, for owner_id, also scopes reads.
  • Docs transcripts. The new rule moves the author-time rule count os validate / os build print from 55 to 56; four CLI transcripts under content/docs/ (deployment/cli.mdx, deployment/validating-metadata.mdx, getting-started/build-with-claude-code.mdx, ui/react-pages.mdx) are updated so check:docs-transcript-drift holds. Not the virtual-objects guide.
  • Guide wording. The guide callout (PR fix(objectql)!: floor a plugin driver on an external datasource — the engine applies the query, up to 1000 rows #27403 head) and this warning agree on edits and on the driver's job. Two sentences belong to the guide's own card: deletes stay refused on public_read_write, and "set sharingModel explicitly" alone is not a way out (an explicit 'private' is still refused); the way out is 'public_read_write'.
  • The table above came from a scratch harness that was deleted before commit; the spike's committed step (e3) remains the durable runtime pin for the edit half.

Verification

Local runs at head ee143ecb.

Derived gate families (dispatch-gates --commands, 88 commands)

node scripts/pm/dispatch-gates.mjs --commands at ee143ec derived 88 commands; all 88 run sequentially at ee143ec, exit codes captured before any pipe; --ran with the COMMAND :: exit N record: 88 accounted, 87 run, 1 NOT-MEASURED.

  • 87 exit 0 (including check:docs-transcript-drift, check:dual-build-cjs-loads, check:doc-authoring, check:nul-bytes, check:issue-citations, check:type-check-coverage, check:type-check-debt, spec check:docs, spec check:liveness, lint check:doc-security-posture).
  • NOT MEASURED: node scripts/check-plugin-teardown-shape.mjs --self-test, exit 3 (prerequisite: its positive control is pinned to commit 621a487, which this shallow checkout cannot reach). Checker health only, not a verdict on this diff; CI's full clone runs it.
  • First run at 4199782 found check:docs-transcript-drift red (four transcripts printed 55 rules, the registry now derives 56); fixed in b0e37b9.

🤖 Generated with Claude Code

https://claude.ai/code/session_017ihL2c5z3q4h8inrn5kwqV

… write

An external object on an owner-keyed sharing model whose owner column no
remote row serves refuses every member edit and delete (403): record
sharing reads owner_id, the ownership floor reads created_by == caller,
and neither has storage. security-external-owner-unmapped is an advisory
on os validate / build / lint naming the two ways out (public_read_write
plus a beforeUpdate hook, or owner_id mapped through external.columnMap).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2c5z3q4h8inrn5kwqV
The new advisory rule raises the count authoringRulesFor('validate') and
('build') derive; check:docs-transcript-drift holds the transcripts to it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2c5z3q4h8inrn5kwqV
…umnMap

external.columnMap is applied by the built-in SQL drivers, not by the
engine, so on a plugin driver a columnMap entry for owner_id serves no
check. The warning is now silenced only by a declared owner_id (or
created_by on an object without owner_id), words a columnMap-only entry
on its own, and names the way out driver-neutrally: declare the field and
have the driver return the remote owner under that name.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2c5z3q4h8inrn5kwqV
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 25 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

51 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json c5d46341747746b180626ad2fcb918324341cd22.

⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c5d46341747746b180626ad2fcb918324341cd22 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from dee780778641558ffea39b5306ed2faffab9357b — the merge of head 120864f47554b20338ec27a4c5e4a6249ebe8ca4 into base c5d46341747746b180626ad2fcb918324341cd22, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin dee780778641558ffea39b5306ed2faffab9357b && git checkout dee780778641558ffea39b5306ed2faffab9357b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c5d46341747746b180626ad2fcb918324341cd22 120864f47554b20338ec27a4c5e4a6249ebe8ca4 && git checkout -B drift-repro c5d46341747746b180626ad2fcb918324341cd22 && git merge --no-ff 120864f47554b20338ec27a4c5e4a6249ebe8ca4

node scripts/docs-audit/affected-docs.mjs --json c5d46341747746b180626ad2fcb918324341cd22

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c5d46341747746b180626ad2fcb918324341cd22 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Review nits: the verdict now says a member write is refused unless an
app-authored row-level policy admits the row (sharing's admitsByIdWrite
retracts its refusal for such a row); the created_by hint drops the
audit-columns clause (a declared created_by suffices with audit off); a
datasource the stack declares with external.allowWrites: false is skipped.
The fix line is pinned whole in the test.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2c5z3q4h8inrn5kwqV
@github-actions github-actions Bot added size/l and removed size/m labels Oct 29, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
ACCEPT · PR #27422 @ 120864f47554b20338ec27a4c5e4a6249ebe8ca4 · 2026-10-29T06:53Z

维护者速读

席位意见:ACCEPT。独立 Fable 完整复核 ACCEPT-with-nits,三处措辞/范围已在 120864f4 改好并有测试钉住。

席位核对

  1. Shape: the seat read the diff (11 files, +500/−5). An independent full Fable review at ee143ecb found effectiveSharingModelOf to be a byte-mirror of the runtime's effectiveSharingModel. The "declared field silences it" condition matches the runtime's provenance check (hasPhantomOwnerAnchor). Severity is warning only, on CLI surfaces. The seat read the ee143ecb..120864f4 delta itself (4 files).
  2. Premise measured: the dev booted the spike composition over 17 shapes; the table is in the PR body. The same two reads refuse delete. public_read_write opens edits but not deletes. ownership: 'none' is not a way out (measured 403). columnMap is applied only by the built-in SQL driver, so on its own it is no way out for a plugin driver.
  3. Gate check-runs on 120864f4: 50 runs: 41 success, 9 skipped, 0 failed or pending. mergeable_state: clean. The dev's local run: lint 177 files / 7589 tests and typecheck green; of 88 derived gate commands, 87 exit 0 and 1 is NOT-MEASURED. That one is the check-plugin-teardown-shape --self-test positive control, pinned to a commit the shallow clone cannot reach; it checks the checker, not this diff.
  4. Accepted deviations: branch claude/project-thread-6ml89f (platform exception, in the claim). Four docs transcripts move 55→56 rules, outside the claimed surface; check:docs-transcript-drift requires it. The pin lives in packages/lint (spike shapes copied); the spike file is untouched.
  5. Out of scope: the read-scope exposure the dev found is filed as sharing: a private external object whose driver serves owner_id without declaring it in fields gives every member every row — hasPhantomOwnerAnchor reads fields only, so the read filter goes unscoped (epic #27261, found by #27332's dev) #27425; the Fable review confirmed it in code. The guide callout gaps were carried into objectql: virtual-object correctness floor — applyDriverQueryInMemory helper, per-datasource row cap with a loud refusal, supports honoured for plugin drivers, refusal-envelope mapping for driver failures (epic #27261 P1) #27277's PR fix(objectql)!: floor a plugin driver on an external datasource — the engine applies the query, up to 1000 rows #27403. Not filed: security-owd-unset's hint recommends private for a writable external object, which is contradictory beside this warning; the seat carries it on the epic. Not filed either: the SQL-driver updated_by column on an audit-less remote table, since it was not measured through PATCH. Clause-②: no · Metadata-protocol: none.
  6. Landing: not governed (check-governed-merges --pr 27422: 505 lines ≤ 3000). Landed by the seat through the relay on the maintainer's order (田騏 2026-10-29T04:19Z: 「你应该使用 pm-dispatch 技能处理 issue 和 pr 落地」).

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants