Skip to content

app-showcase: "Mark Done" lets a member who may not edit tasks write them — the script body runs elevated, the action declares no requiredPermissions, and the console still offers the button #25468

Closed

Description

@marchtian

Requested by 田騏 · project thread

Found during an exploratory walkthrough of the showcase as a non-admin persona.

Environment

  • objectstack main @ b9c7a8f, PORT=3000 pnpm dev (showcase, fresh DB)
  • objectui main @ 6fb31dc console on :5180
  • Signed in as the seeded persona phone.demo@example.com (Mei Phone)

What the persona is allowed to do

The persona's grant on showcase_task is read + create only (the member-level set in src/security/permission-sets.ts carries showcase_task: { allowRead: true, allowCreate: true }). The console agrees: the task record page shows no 编辑 / Edit button for this user, and a direct write is refused:

PATCH /api/v1/data/showcase_task/JVVzP8bTy9ZhbTCd  {"priority":"high"}
→ 403 {"code":"PERMISSION_DENIED","object":"showcase_task", ...}

Steps

  1. As the persona, open task Content backlog (it is assigned to the persona, so the select RLS lets it be read).
  2. Click Mark Done in the record header.

Actual

Toast "Task marked done.", and the write lands:

GET /api/v1/data/showcase_task/JVVzP8bTy9ZhbTCd
→ done: true, progress: 100, updated_by: usr_showcase_phone_demo

So a user who cannot edit a task can edit it through the action.

Why it happens

This is the documented posture, not a runtime accident: a script action body is trusted application code and its ctx.api runs isSystem (buildActionExecutionContext; the actionAiExposureError docblock says a body's reads and writes "are NOT bounded by the caller's RLS/FLS"). The gate an author is expected to use is requiredPermissions (ADR-0066 D4, actionPermissionError). showcase_mark_done declares capabilities: ['api.write'] and no requiredPermissions, and its visible predicate only checks record.done, so every reader of a task gets a working write button.

The showcase is the reference app people copy actions from, so the pattern it teaches is "a record-header script action that writes is open to every reader". The same shape probably applies to the other api.write actions in src/ui/actions/index.ts; I only exercised Mark Done. Log Time (type: 'form') is also offered to this persona, but I did not check what its submit does for a user without edit.

Expected

Either the showcase action is gated (a requiredPermissions entry, or a visible/disabled predicate tied to edit access) so the demo models the safe pattern, or the platform makes the gap visible to authors.

Possible platform follow-ups (for triage, not part of the showcase fix)

  • a lint warning for a script action whose body declares api.write and whose action declares no requiredPermissions, since that combination is always an elevation for every caller who can see the record;
  • or the console hiding/disabling a record action that writes the current object when the caller's object permission has no allowEdit, the way it already hides Edit.

Activity

  1. added
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    bugSomething isn't working
    domain:serverDomain lane — seat card indexed by label:pm:seat
    pm:queueReady for the PM dispatch loop
    on Oct 23, 2026
  2. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    Contributor

    Graded · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-23T16:02Z

    p2, domain:server, area:access, bug, security, pm:queue.

    • Scope: the showcase fix only. Gate the showcase's writing script actions, starting with showcase_mark_done and then every api.write action in examples/app-showcase/src/ui/actions/index.ts, with requiredPermissions (ADR-0066 D4), so the reference app models the safe pattern. The dev checks Log Time's submit the same way.
    • Not taken: the lint warning for "api.write with no requiredPermissions", which would be a new mandatory gate (「新增门禁默认否」). Also not taken: a console rule that hides writing actions by object permission, a platform behaviour nobody has ruled on. Both are noted here, not filed.
    • examples/* goes to the lane of the subsystem it exercises, here actions and security.

    Generated by Claude Code

  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Oct 23, 2026
  4. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    Contributor

    Claim: card thread · domain:services seat 1 (seat post #6021, project run by the Sales account, 由Sales账户的项目负责) · p2 · security · the whole card (showcase scope, per the triage grading 6369139790) · 2026-10-23T18:45Z
    Session: session_01SGzB5Np4fydLKpGjZm1dNr
    Account: os-sales (the seat's linked user; the card's assignee)
    Branch: claude/project-thread-z0wf71 (the harness-pinned branch for this session; it names no issue, so the Duplicate Fix Guard's branch-name hint will not match — this claim is the identity record)
    Worktree: objectstack-issue-25468
    Domain: domain:server
    Seat: domain:services#1 (seat post #6021; domain:services reads as domain:server)
    File surface (read on origin/main aa2dbab7b; stop on a breach and explain it in the report):

    • examples/app-showcase/src/ui/actions/index.ts: the three actions whose script body declares api.write (showcase_mark_done, showcase_reopen_task, showcase_submit_signoff).
    • examples/app-showcase/src/security/capabilities.ts and permission-sets.ts, only if the gate chosen needs a declared capability and its grant.
    • The showcase's own pins in examples/app-showcase/test/.
    • Any platform-checklist item in docs/qa/platform-checklist/areas/*.json whose fixture text names one of these three actions and would go stale (a domain:devx touch, declared in the PR if made).
    • No changeset: @objectstack/example-showcase is private: true and publishes nothing (skip-changeset).
    • Not packages/**, not objectui. The two platform follow-ups the card lists stay out, per the grading.

    Measured (the card's first act), on aa2dbab7b:

    • showcase_log_time is type: 'form'. The platform action route has no server dispatch for that type (headlessActionTypeError), so its submit is an ordinary data-plane write under the caller's own identity. It is not an elevation and is out of the fix.
    • The route for the three script actions is open between the grading's requiredPermissions and a write performed as the caller. The analysis went to the maintainer for a ruling before any code lands, and the ruling will be recorded here.
      Container & model: M, card thread (in-session executor), model: default tier (dispatch-gates --tier: no path-derived mandate), effort: xhigh
      Clause-②: no
      Metadata-protocol: none
      Basis for the Clause-② line: the change narrows who may invoke three actions of a private example app; no published accept set or public surface widens.
      Responsibility: the showcase's own action declarations (app code, examples/app-showcase) | the platform gate exists and these actions do not use it | every signed-in reader of a task or invoice in a showcase boot; the showcase is the reference app authors copy actions from
      Thread-read: 6369139790
      Serial constraints cleared: none (no open PR or in-flight claim declares these files; PR feat(security): an external organization role — portal users read their own identity rows and reach data only through explicit grants #25515, the one open PR naming the showcase, touches no file under examples/)
      Resume-from: none

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    Contributor

    Ruling: #25468 security fork (showcase record write actions) · letter B · ruled by Sales 「25468 B」 · recorded 2026-10-24T13:17Z

    Ruled by Sales, the project owner, in the project's ruling thread at 2026-10-24T13:14:36Z, verbatim: 「25468 B」. This card thread (session_01SGzB5Np4fydLKpGjZm1dNr, services seat 1, claim 6372266664) prepared the analysis and recommended B; it did not rule. Reversible on one word from Sales.

    B departs from the triage grading 6369139790. The grading prescribed gating the writing script actions with requiredPermissions (ADR-0066 D4). The fix below is a different one, and that departure is Sales's ruling, not the seat's.

    Disclosure: this record states mechanism only, per the security-card rule; it carries no reproduction.

    The question

    Three showcase record actions (showcase_mark_done, showcase_reopen_task, showcase_submit_signoff) write from a script body. A body's ctx.api runs with the system identity (buildActionExecutionContext stamps isSystem: true), so the write does not carry the caller's object, row or field permissions. Which gate closes it?

    • A, the grading's route: add requiredPermissions naming a new package capability, granted on showcase_contributor.
    • B, ruled: rebind each action to the declared record write, operation: 'update' + patch, which the platform action route performs as the caller, and remove the bodies.
    • C: B for Mark Done and Submit for Sign-off; Reopen keeps its body.

    Governing text

    Why B (measured on origin/main aa2dbab7b)

    • A gates on a package capability, and nobody holds it. Platform and org admins hold none: their system permissions are the union of the sets they hold plus a fixed platform list. Neither actionPermissionError nor the console's action filter has an admin bypass. On a fresh showcase boot, no provisioned user holds showcase_contributor. Under A, nobody, admins included, could use the three actions until someone is granted the set, and five checklist items drive them as admin. A also declares "may edit" twice (the object grant and the capability), and a holder's body write would still bypass row and field security.
    • B has one permission source: the caller's own object, row and field permissions. Whoever may edit the record may use the button. Whoever may not gets the same 403 the data plane gives, and nothing is written. B adds no capability and no key.
    • C would leave one ungated elevated write in the reference app that authors copy.

    Costs accepted with B

    1. A caller who may not edit still sees the buttons and is refused when they click. A console hint that hides them by edit permission stays out, per the grading.
    2. The showcase no longer has a script body that writes through ctx.api. Its remaining bodies (Archive, Action Param Gallery) keep type: 'script' covered.

    Execution (this card thread)

    On branch claude/project-thread-z0wf71:

    • the three actions become declared writes;
    • the showcase pins are updated;
    • a new dogfood pin over real HTTP pairs each refusal with the data plane's own answer, and with an allow for a caller who may edit;
    • two platform-checklist items are respelled, a domain:devx touch declared in the PR.

    A draft PR goes up via the relay with Fixes #25468 and is handed to services seat 1 for ACCEPT.

    os-decision-facets

    • ① 实际业务需求:「标记完成」「提交会签」是编辑的快捷方式,真实用户就是能编辑这条记录的人。A 实测把默认管理员挡掉,且全新启动无人可用;B 的可用人群正好等于能编辑的人。
    • ② 项目长远合理性(权重 ≥50%):B 只有一处权限来源,用的是维护者 09-01 裁定(5494341350)、已上线的声明式写;A 让「能编辑」有两份声明要手工对齐,脚本体写入仍绕过行级和字段级权限。
    • ③ 防 AI 写错:B 的写法没有脚本体可信任,照抄的人在结构上提不了权;A 要求作者发明能力名并授给对的权限集,漏一处就是没有门或谁都用不了;C 留下一个可被照抄的无门提权写。
    • ④ 创业阶段不扩散:B 不新增任何能力或键;A 新增一个包能力和授予。

    Prior rulings read: requiredpermissions,api.write,issystem,declarative,showcase_mark_done → 33 hits; ADR-0007 Decision §10, ADR-0007 Decision §3, ADR-0018 Decision §5, ADR-0021 D1, ADR-0030 Decision §9, ADR-0032 Decision §3, ADR-0066 D1, ADR-0066 D3, ADR-0066 D4, ADR-0067 D5; thread: none; repo: objectstack-ai/objectstack. ADR-0066 D4 defines the capability gate; it does not decide which gate a showcase write action uses, so the question was a decision and not execution.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    Contributor

    os-dev-report

    {
      "issue": 25468,
      "status": "done",
      "branch": "claude/project-thread-z0wf71",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/25656",
      "session": "session_01SGzB5Np4fydLKpGjZm1dNr",
      "premise_still_valid": true,
      "summary": "Ruled B by Sales (record 6384686314, verbatim 「25468 B」; departs from triage 6369139790). The three showcase record actions that wrote from a system-identity script body (showcase_mark_done, showcase_reopen_task, showcase_submit_signoff) now declare the write (operation 'update' + patch), which the platform action route performs as the caller: object CRUD, RLS, FLS, hooks and validation apply, a caller without edit gets the data plane's 403 and nothing is written. Showcase pins updated (a new pin: no action body writes without requiredPermissions); a new dogfood pin pairs each refusal with the data plane's own answer and with an allow. Two platform-checklist items respelled (domain:devx touch, declared): api-backend.packaged-action-disabled-dispatch rev 7 (also fixes its stale 'the enabled control CANNOT 404', measured 404 RECORD_NOT_FOUND), records-forms.script-action-hook-refusal-toast rev 3 (Reopen still answers 400 VALIDATION_ERROR with the sentence, measured).",
      "tests": "Merged head b829a362 (branch + origin/main 49653f91): examples/app-showcase vitest 'Test Files 39 passed (39) / Tests 484 passed (484)', tsc --noEmit exit 0; packages/qa/dogfood test/showcase-record-write-actions.dogfood.test.ts under os-verify-lock 'Tests 7 passed (7)', VERDICT command-exit 0; dogfood tsc --noEmit exit 0 (pre-merge head). Ablation, on the pre-merge head and again on b829a362: examples/app-showcase/src/ui/actions/index.ts overwritten with git show origin/main:... (bodies back), the pin answers 'Tests 3 failed | 4 passed (7)' - exactly the three refusal pins (member Mark Done, member Reopen, auditor Submit) red, allow pins green; restored with git checkout HEAD -- path, git status --porcelain empty. The source is imported directly (package entry is objectstack.config.ts), so no rebuild sits between the mutation and the run. eslint --no-inline-config on the 6 changed TS files exit 0.",
      "gates": "Pre-merge head 3027e0a3: dispatch-gates --commands derived 63; all 63 recorded 'cmd :: exit N', 61 exit 0 first pass; check-plugin-teardown-shape --self-test exit 3 (pinned fixture commit absent from the shallow clone; fetched with git fetch --depth=1, rerun exit 0) and check:dual-build-cjs-loads exit 3 (seven unbuilt dists; built under the lock, rerun exit 0). --ran: '63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN'. check:platform-checklist rerun on b829a362: OK. CI families: PR #25656 check-runs on b829a362, cited by the seat at ACCEPT.",
      "dogfood_battery": "n/a - no ! in the PR title and Clause-② no with no narrowing arm",
      "mcp_calls": "19, all reads, counted in this session's transcript: 10 mcp__github__issue_read, 5 mcp__github__pull_request_read, 1 mcp__github__search_issues, 3 mcp__github__search_pull_requests. Zero MCP write tools.",
      "api_writes": "5, all via the fleet-write relay as objectstack-fleet[bot]: claim comment 6372266664 (post-stamped, POST issues/25468/comments); the claim's label and assignee stroke (pm:queue to pm:dispatched, assign os-sales); ruling record 6384686314 (post-stamped); pr_create #25656 (draft, assignee os-sales, label skip-changeset; read-back 7678 bytes identical); this report. git push is not a REST write.",
      "open_questions": [],
      "out_of_scope_findings": [
        "class: none of a/b/c - noted in PR Acceptance notes only · showcase_portfolio_snapshot keeps an api.read script body, ai.exposed, no requiredPermissions, so its account/project/invoice counts are computed with the system identity for any signed-in caller, including one who cannot read invoices; it writes nothing and was outside the grading (write actions only) · carrier: none (seat to judge) · dedupe words: portfolio_snapshot, api.read, requiredPermissions, elevated read, counts",
        "fixed in this PR because the PR edits the item: api-backend.packaged-action-disabled-dispatch said the enabled control cannot 404 on a nonexistent record id; every door consumes refuseDeniedSubjectLoad, measured 404 RECORD_NOT_FOUND"
      ]
    }

    Generated by Claude Code

  7. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    Contributor

    Seat pointer · domain:server seat 1 (seat post #6367) · os-sales · session_01JrXUZYyTV4i588KLZRxSvJ · ⛔ not a claim

    The maintainer voided domain:services seat 1 (#6021) in the Sales account's project and summoned domain:server seat 1 there; #6021 is now an archive of #6367. The reviewing seat for claim 6372266664 (card thread session_01SGzB5Np4fydLKpGjZm1dNr) is therefore seat post #6367, same account. The claim, its session, branch and file surface are unchanged. ACCEPT and landing for PR #25656 under ruling B (6384686314) come from #6367.


    Generated by Claude Code

  8. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    Contributor

    ACCEPT → PR #25656 comment 6385006343 · domain:server seat 1 (seat post #6367) · session session_01JrXUZYyTV4i588KLZRxSvJ


    Generated by Claude Code

  9. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    Contributor

    Landed · domain:server seat 1 (seat post #6367) · session session_01JrXUZYyTV4i588KLZRxSvJ · 2026-10-24T14:50Z

    reading result
    PR #25656 object merged: true, merge commit 1286eea054, merged 2026-10-24T14:47:03Z through the merge queue
    origin/main 1286eea054 is an ancestor of origin/main, single parent (squash)
    queue branch no gh-readonly-queue/main/* head for PR 25656 remains

    State: card closed completed by the PR's Fixes #25468 (ruling B 6384686314). pm:dispatched stripped in this act.


    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

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions