Skip to content

回归:#547 实测通过的 marketing_campaign_updates RLS 写扩权在 rc.2 上失效(403);同 PR 的 campaign_member 扩权仍工作 #706

Closed

Description

@yinlianghui

Found during the 17.0 GA acceptance sweep on @objectstack/* 17.0.0-rc.2 (current main), permission matrix phase. This is a regression against a behavior #547 explicitly runtime-verified.

Then vs now

#547 added two RLS update-wideners so marketing's declared campaign grants would be real, and its PR body records the runtime verification: campaign-member insert as marketing_user → 201.

rc.2 measured, marketing_user with its own token:

Widener rc.2 behavior
marketing_campaign_member_updates ✅ works — PATCH on an admin-owned member and on an owner_id-NULL member both → 200 (control: sales_rep → 403)
marketing_campaign_updates (crm_campaign) ❌ regressed — PATCH on a not-own campaign → 403 "FORBIDDEN: insufficient privileges to update crm_campaign …"

Note the error shape: the failing denial is the sharing-middleware FORBIDDEN, not the row-gate's "[Security] … (row-level security)" shape that all other write denials in the sweep produced. That suggests the failing layer answers before RLS evaluation on objects that carry sharing rules — i.e. the widener never gets consulted on crm_campaign, while on crm_campaign_member (no sharing rules of its own) it does.

Impact

Marketing cannot edit campaigns they didn't personally create — the exact posture #547 was written to fix. Enrolment (member writes) still works, so the symptom is subtle: members move, the campaign itself is frozen.

Reproduction

# as marketing_user-bound user
PATCH /api/v1/data/crm_campaign/<admin-owned id> {"description":"x"}
→ 403 FORBIDDEN: insufficient privileges to update crm_campaign <id>
PATCH /api/v1/data/crm_campaign_member/<admin-owned id> {"status":"responded"}
→ 200

Attribution

Platform (ordering of sharing middleware vs RLS wideners on objects with sharing rules), given one widener works and the other — identical in mechanism, different only in the object's sharing-rule presence — does not. The CRM declarations are unchanged since #547.

Evidence: acceptance run w4/results.jsonl (M5). Refs #547 #705

Activity

  1. added
    bugSomething isn't working
    upstream:objectstackBlocked on / caused by the ObjectStack platform — tracked upstream
    on Aug 5, 2026
  2. yinlianghui commented on Aug 5, 2026

    @yinlianghui
    CollaboratorAuthor

    Blocked-by: objectstack-ai/objectstack#5493

    分诊(PM 循环第 1 轮,会话 session_01VHrPAGEgFDoHjphqYG4BMa):判定为平台缺陷(带 sharing rules 的对象上,sharing middleware 先于 RLS 扩权应答 —— 错误形状即证据),⛔ 不在 hotcrm 侧修。已立镜像单 objectstack#5493。CRM 声明自 #547 起未变、无需改动。本单打 pm:blocked。


    Generated by Claude Code

  3. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    GA re-probe — the named symptom is gone, but the defect survives with its direction inverted. Staying open.

    Version: @objectstack/* 17.0.0 GA, repo at d4ddee0, objectstack dev -p 4001, real Bearer tokens. Mirror objectstack#5493 closed via merged PR objectstack#6909, "fix(sharing): defer to an app-authored RLS widener before hard-refusing a by-id write".

    Read the PR title closely — by-id write. That is exactly as much as GA fixed.

    The two wideners have swapped states

    widener rc.2 (this card) 17.0.0 GA
    marketing_campaign_updates (crm_campaign) 403 FORBIDDEN, sharing-middleware shape 200
    marketing_campaign_member_updates (crm_campaign_member) 200 403

    So this card's literal claim — "the campaign widener regressed while the member widener still works" — no longer reproduces. Its inverse now does, and the user-visible workflow the card exists to protect is still broken. The GA refusal:

    [Security] Access denied: update on 'crm_campaign_member' requires edit access to its
    master record (master 'crm_campaign' not editable by this user (record sharing))
    

    on a campaign the same principal had just successfully PATCHed.

    The isolation — one variable, everything else held constant

    Same principal (marketing_user), same objects, same payloads, same session. The only thing that changes between columns is who created the campaign.

    step campaign created by ADMIN campaign created by MARKETING
    1. PATCH the campaign itself 200 200
    2. INSERT a member (the "Add to Campaign" enrolment path) 403 201
    3. UPDATE an admin-created member row 403 200
    4. control — admin updates that same member row 200 200
    5. security/explain update on the campaign, record-scoped allowed=true allowed=true

    Row 1 shows the RLS widener in force in both columns. Row 5 is the platform's own layer-by-layer verdict agreeing — object_crud: grants, and allowed=true for update on that specific record. Rows 2 and 3 refuse anyway in the left column, blaming record sharing.

    Two subsystems therefore disagree about the same principal, the same record and the same operation: the by-id write path defers to the widener (post-#6909), and the ADR-0055 controlled_by_parent master-editability check does not — it resolves master editability through ownership and sys_record_share only.

    Controls

    • sales_rep PATCH on the same campaign (no crm_campaign edit grant at all) — 403 object-level. So "campaigns became writable by anyone" is excluded; row 1's 200 is the widener.
    • marketing_user DELETE on the campaign (allowDelete: false) — 403. The widener names operation: 'update' and widens nothing else.

    Impact on this app

    marketing_campaign_member_updates and the enrolment path are marketing's core write surface — src/actions/lead.actions.ts's "Add to Campaign" inserts crm_campaign_member rows, and marketing-user.profile.ts documents precisely this dependency: "enrolling a member is a write DERIVED from the campaign (controlled_by_parent), so it requires campaign edit at the row level." On GA that insert is refused for every campaign a marketing user did not personally create. On rc.2 it worked. Relative to rc.2 this is a regression on the member path, even as the campaign path was repaired.

    Disposition

    Staying open, pm:blocked retained — still blocked on the platform, and the CRM declarations remain unchanged and correct since #547.

    Mirror filed: objectstack-ai/objectstack#8679, as the successor to the closed #5493, carrying the isolation table above. Nominated for target:v17 on objectstack-ai/objectstack#8667 — nomination only; no label applied and the first-batch list untouched (#1150: "Seat A decides nothing").

    A note for re-triage: this card's title and body now describe a state that no longer exists. Whoever picks it up may prefer to re-title it around the surviving defect rather than read the rc.2 table as current.

    Probes: 30-706.mjs, 31-706-isolate.mjs — throwaway scaffolding, deleted. Executed under #1152 (GA close-out sweep #1150, group 3).


    Generated by Claude Code


    Generated by Claude Code

  4. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    Closed — moved upstream to objectstack-ai/objectstack#8679

    Dispositioned under the transfer sweep #1156 (part 2). Maintainer ruling, 2026-08-14, quoted verbatim and untranslated:

    hotcrm 席位的原则是用平台的能力做元数据应用的开发,平台的需求应该转给平台

    and for the hotcrm side of a transferred card, the same ruling: 「关掉,只留指向镜像的指针」. Authority: #1156.

    The mirror — verified before this close, not assumed

    objectstack-ai/objectstack#8679 — "controlled_by_parent children: the master-editability check still refuses an RLS-widened master, so #6909's fix covers the by-id write but not the derived-write path (17.0.0 GA)". Confirmed open, labelled bug, and carrying this card's defect rather than a similar-sounding one: it reproduces the same five-row isolation table, the same principal, and the same refusal string naming record sharing as the deciding layer.

    The reading this close rests on

    The GA re-probe already on this card — comment of 2026-08-14, @objectstack/* 17.0.0 GA at d4ddee0. Its finding, restated so this close is readable without following the link: the rc.2 symptom in the title above is gone (the crm_campaign widener now answers 200), but the defect survives with its direction inverted — the ADR-0055 master-editability check refuses a derived crm_campaign_member write on a master the same principal has just successfully PATCHed, and security/explain answers allowed=true for that exact record and operation.

    That inversion is why the close is a transfer and not a non-reproducer: the user-visible workflow this card exists to protect ("Add to Campaign" enrolment) is still broken, and it is still broken in the platform.

    Why it closes here

    The fix is the ADR-0055 master-accessibility resolution deferring to the same composition #6909 installed on the by-id path. That is platform source. The CRM declarations are unchanged and correct since #547, so there is no producer in this repo to change — the condition under which the ruling above sends a card upstream.

    Label note: pm:blocked dropped in the same write. It recorded a block on objectstack#5493, which is closed as completed via merged PR objectstack#6909 — the blocker it named has landed. The successor block is now #8679 and is tracked there; a closed card should not wear a stale blocker pointing at a merged fix. bug, prio:p1 and upstream:objectstack are left as they were.

    Nothing is lost by this close: #8679 is open, carries the full isolation table and reproduction, and is readable without any hotcrm context.


    Generated by Claude Code


    Generated by Claude Code

  5. added
    priority:p1High: required for production / M2
    and removed on Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority:p1High: required for production / M2upstream:objectstackBlocked on / caused by the ObjectStack platform — tracked upstream

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions