Skip to content

[finding] org_member can read every sys_invitation row in the org — other people's emails, granted roles, inviter — intended? #8095

Description

@baozhoutao

Recording an observation for the platform to rule on — I could not tell from the outside whether this is intended.

A user whose org role is member can read every invitation row of the organization through the data API, including invitations addressed to other people: their email address, the role they are being granted, and the inviter.

Repro (measured)

Org 甲 has two invitations: lisi@ (member, accepted) and wangwu@ (admin, pending). lisi@ is a plain member of that org.

As lisi@, with the active organization set to 甲:

await fetch('/api/v1/auth/organization/get-active-member')  // → { role: "member" }
await fetch('/api/v1/data/sys_invitation')
// → 200, total: 2
// [ { email: "lisi@acme-test.com",   role: "member", status: "accepted" },
//   { email: "wangwu@acme-test.com", role: "admin",  status: "pending"  } ]

Identical to what the org owner sees. Full rows also carry inviter_id and expires_at.

For contrast, the same member is correctly denied elsewhere:

GET /api/v1/data/sys_permission_set
→ 403 [Security] Access denied: operation 'find' on object 'sys_permission_set'
      is not permitted for positions [org_member, everyone]

so object-level gating is in force generally — sys_invitation is simply readable by org_member.

Why it might matter

A pending invitation is administrative intent, not a colleague directory: it exposes the addresses of people who are not yet (and may never become) members, and discloses who is about to be made an admin. Membership (sys_member / the members page) is a different question — that one reads like a legitimate staff directory.

The decision

Either confirm org_member read on sys_invitation is intended (in which case this issue is just documentation), or narrow it — e.g. restrict to owner/admin, or row-scope it to email = current_user.email so a member can still see their own invitation.

Related UI-side finding: #8092 (the members page shows invite/remove affordances to member; the server does gate those writes).

Environment

Local dev server http://localhost:8080, multi-org enabled, observed 2026-08-12. The server was started by the maintainer and its exact commit is not verified.

Activity

  1. baozhoutao commented on Aug 12, 2026

    @baozhoutao
    ContributorAuthor

    Bugs filed from the same multi-org walkthrough: #8090, #8091, #8092, #8093, #8094.

  2. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: escalated to needs-user-decision, routed domain:identity; the filer-applied domain:access-security label was removed per the standing rule (not a lane in the domain table — labels are re-routed to the lane of the package the fix would land in). This is a security/permission-boundary question — whether org_member should read the org's whole invitation ledger — and that class is always the maintainer's, whichever way it goes.

    Producer located (the "who writes this" question, answered): the read is not an accident of a wildcard. packages/plugins/plugin-security/src/objects/default-permission-sets.ts → denyWritesOnManagedObjects() grants allowRead: true on every BETTER_AUTH_MANAGED_OBJECTS entry (incl. sys_invitation) for the default member/viewer sets — a deliberate "writes must flow through better-auth, reads permitted subject to RLS" baseline. The RLS chain then org-scopes but does not row-scope within the org. So declared = enforced; the question is whether the declaration is right for this object.

    Four-lens block:

    1. Platform long-term coherence — the blanket read baseline is coarse by design; carving per-object read exceptions erodes its simplicity, but sys_invitation is qualitatively different from a staff directory: it is administrative intent about non-members. A row-scope carve-out follows the existing sys_*_org policy pattern rather than inventing a mechanism.
    2. Measured business pull — measured live by the filer: a plain member reads other people's email addresses, granted roles (including who is about to be made admin), inviter, expiry. Pending invitees are not members and never consented to a directory listing. Real exposure, today, on every multi-org deployment.
    3. AI-agent error-resistance — narrow-by-default wins: an app author (or agent) building on the data API will silently inherit the wide read and ship it. A closed default (owner/admin + own-row) fails loudly and is correctable; the wide default fails silently.
    4. Startup scope discipline — the narrow fix is one RLS row-scope policy + one permission tweak, not a new capability; "confirm as intended + document" is the only cheaper option and spends trust instead of code.

    Recommendation: narrow — restrict sys_invitation read to owner/admin, plus a row-scope carve-out email == current_user.email so an invitee can still see their own invitation (the accept flow's need). If ruled intended instead, the ruling should say so on this card and the read stays documented as a product decision. Related UI half: #8092 (affordance gating; moving to objectui as its own card).

    本评论来自分诊座位 Routine。


    Generated by Claude Code

  3. added theissue type on Aug 12, 2026
  4. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Maintainer ruling — 2026-08-12 (live PM chat, session_01GxKQfv3k8b6a2d2QrZU411; maintainer sam, verbatim: 「接受你的全部建议。」 accepting the decision-box analysis in full). Moved out of the decision box → pm:queue (domain:identity, Bug).

    Ruling: option B — narrow the read. sys_invitation read goes to owner/admin, plus a row-scope carve-out so an invitee still sees their own invitation (email == current_user.email). The current wide read is not intended.

    Rationale on the record: the grant is declared, not accidental — denyWritesOnManagedObjects() sets allowRead: true for every BETTER_AUTH_MANAGED_OBJECTS entry (default-permission-sets.ts:43/:71), and the org RLS (:300) scopes by organization_id but never by row, so a plain member measurably gets the full ledger (200, total: 2, verified live). The ledger carries other people's email addresses, the role they are about to be granted, and who invited them — enough for targeted social engineering, and who is about to become admin is exactly the wrong thing to broadcast. The UI half (#8092) is already closed, so today the console looks gated while the data API is not; leaving that mismatch in place is worse than either consistent answer.

    Scope note for dispatch: fix at the permission-set producer, not by a per-route filter; verify with the same live probe the card used (member vs owner on /api/v1/data/sys_invitation) plus a self-visibility case proving the invitee carve-out works.


    Generated by Claude Code

  5. self-assigned this
    on Aug 12, 2026
  6. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    Claim: domain:identity 执行席位 #6022 — session session_01PEVB6w7D7uCszR9Mw1BL73,分支 claude/issue-8095-narrow-sys-invitation-read。派发中(mode:subagent,opus)。

    先说清楚一件不体面的事:这张卡在 18:23Z 就已裁定,我到 19:55 才派 —— 约 90 分钟的空转,责任在本席位,不在分诊。 同一轮裁定里 #8019(18:27Z)和 #8009(18:26Z)我都在几分钟内派了出去,唯独漏掉这张更早裁定的。原因是我当时按"决策箱两张"的旧记忆去核,只核了记忆里的那两张,没有重新枚举 pm:queue ——用记忆代替重新测量,正是本班反复记录的那类错误,这次它的代价是一张已裁定的安全 bug 白等了一个半小时。记在这里,因为下一班会读到这张卡。

    裁定原样带走,不可再议:option B —— 收窄读取。 sys_invitation 的读取收给 owner/admin,外加一条行级豁免让被邀请人仍能看到自己那一行(email == current_user.email,accept 流程需要)。当前的宽读不是有意为之。

    在生产者处修,不要在路由上加过滤器。 裁定的 scope note 写明了落点:packages/plugins/plugin-security/src/objects/default-permission-sets.ts 的 denyWritesOnManagedObjects() 对每个 BETTER_AUTH_MANAGED_OBJECTS 条目设 allowRead: true(:43 / :71),org RLS(:300)按 organization_id 收敛但从不按行收敛。这是声明与执行一致的情形 —— 声明本身是错的,所以改声明。

    验收判据(两个方向都要,缺一不可):

    1. 用卡片自己的实测探针复现:member vs owner 打 /api/v1/data/sys_invitation,member 现在应当读不到别人的行(原为 200, total: 2);
    2. 自见性用例:被邀请人仍能看到自己那一行 —— 只做收窄而漏掉豁免,会把 accept 流程打断,而那种失败会以"权限修好了"的样子通过第一个断言。

    **串行判断:**本 lane 在飞的 plugin-auth(#8009 实现半、#8193)与 plugin-sharing(#8119)均不碰 plugin-security,实测面不相交,并行安全。

    ⚠️ 提醒实现者:default-permission-sets.ts 里 sys_api_key_self 一类的 RLS 豁免有三处而非两处,彼此以 operation 区分(本 lane 上一轮在 #8053 上数错过一次)。改这个文件前先把命中处逐个读完,不要按计数下结论。


    Generated by Claude Code

  7. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor
    {
      "issue": 8095,
      "status": "done",
      "branch": "claude/issue-8095-narrow-sys-invitation-read",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8242",
      "premise_still_valid": true,
      "summary": "Implemented the 18:23Z ruling (option B) at the permission-set producer. `member_default` and `viewer_readonly` each gain `sys_invitation_self` (select, `email == current_user.email`); the object-level read bit stays open on purpose, because closing it would take the invitee's own row with it and break the accept flow while looking like the same fix. A THIRD policy was needed and is the half I got wrong first: `member_default` resolves for every authenticated principal via the `everyone` anchor, so the addressee scope reached org admins too, and on the DEFAULT `single` posture neither mechanism that normally keeps an admin whole is present — ADR-0105 D4 withholds the `viewAllRecords` short-circuit from a wall-less deployment (`organization_admin_no_bypass`), and ADR-0105 D3 strips `sys_invitation_org` as a platform tenant policy when org isolation is inactive. Measured: with only the member-side scope, the org OWNER read ZERO invitations. `organization_admin` therefore gains `sys_invitation_org_admin` (`id != null`, positions-domained to org_owner/org_admin, no tenant token for the strip to key on); the domain sits on the WIDENING so a principal it does not match keeps the addressee scope and fails closed. Counting hazard checked by reading every hit: `sys_api_key_self` is indeed three policies at two operations (organization_admin `all`, member_default `all`, viewer_readonly `select`) — no new carve-out was pattern-matched onto a neighbour. The BETTER_AUTH_MANAGED_OBJECTS blanket covers 22 objects and was NOT touched: dropping `allowRead` there would retire the read on all 22 and take the invitee's row with it.",
      "tests": "LIVE PROBE (real data API, in-process HTTP, stock showcase boot, two invitations created by the real invite-member endpoint) — packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts, 4 personas:\n  owner            200  total: 2   both rows, role:\"admin\", inviter_id present\n  member           200  total: 0   (the card measured 200, total: 2)\n  invitee          200  total: 1   their own row, and only theirs\n  delegated_admin  200  total: 0   -> filed as #8240\n  member by-id on the outsider row -> 404 {\"code\":\"RECORD_NOT_FOUND\"}, address absent from the body\n\nABLATION (predicted BEFORE measuring; prediction written to disk first). Mutation: delete `sys_invitation_self` from both sets, keep the admin widening. Predicted PLAIN RED in exactly 6 cases; measured exactly those 6, no others, no inversion, no extra diagnostics:\n  1 dogfood 'a plain MEMBER reads NONE of it'      RED  expected [] to have length 0, got 2\n  2 dogfood 'the INVITEE still reads their own row' RED  expected length 1, got 2\n  3 dogfood 'by-id fetch of another person's row'   RED  expected [403,404] to include 200\n  4 default-permission-sets.test.ts member_default  RED\n  5 default-permission-sets.test.ts viewer_readonly RED\n  6 rbac-objects.test.ts exhaustive policy list     RED\n  CONTROL 'the org OWNER still reads the full ledger' stayed GREEN, as predicted.\nThe ablation reproduces the card's original symptom exactly (member gets 2 rows). Restored with `git checkout <branch> -- <path>` from the commit (never git stash), rebuilt, re-verified green.\n\nSUITES:\n  pnpm --filter @objectstack/plugin-security test  -> Test Files 51 passed (51), Tests 1032 passed (1032)\n  pnpm --filter @objectstack/dogfood test          -> Test Files 101 passed | 1 skipped (102), Tests 685 passed | 3 skipped (688)\n  typecheck (plugin-security + dogfood)            -> both Done, clean\n\nGATES (derived via scripts/pm/dispatch-gates.mjs + the three it cannot derive):\n  check:authz-resolver OK | check:cross-package-test-inputs OK | check:docs-audit-scope OK |\n  check:test-source-alias OK | check:query-options-erasure OK (ratchet holds, none new) |\n  check:type-check-coverage OK | check:engine-double-contract OK (no new fake engine) |\n  check:nul-bytes OK (+ targeted grep -naP control-byte scan of every touched file) |\n  check:type-check-debt OK — 'none above its recorded number'. It initially REFUSED to run (service-knowledge unbuilt); I built the full `turbo run build` closure from the repo root as it instructed rather than working around it. Ledger neither raised nor lowered.\n\nONE FALSE LAP WORTH RECORDING: the dogfood suite consumes plugin-security's BUILT dist, not src. My first admin-widening measurement showed the policy missing from the seeded sys_permission_set row — it was a stale dist, not a bug. Rebuilt the package and re-measured.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #8240: `delegated_admin` can issue invitations but no longer reads them via the data API — it normalizes to neither org_owner nor org_admin and does not receive organization_admin from the auto-grant (roles.includes('admin') is an element test on the comma-split list, so 'delegated_admin' misses). Measured at 200/total:0. Three options laid out (leave as ruled / add to the positions domain / scope to inviter_id == self); none taken, since widening past owner/admin is the maintainer's call. Also names the two QA-checklist steps in identity-auth.json that read sys_invitation via the data API as that persona.",
        "filed as #8241 (label: finding): the general trap — any RLS narrowing added to member_default silently narrows ORG ADMINS on the default `single` posture, because both of their widening mechanisms (the viewAllRecords short-circuit, ADR-0105 D4; the _org carve-out, stripped by ADR-0105 D3) are absent there, leaving the admin's read riding on the ABSENCE of a policy. sys_member and sys_team sit in the same latent state today. Suggests three dispositions incl. a possible check:* gate."
      ]
    }

    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

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions