Repository navigation
[finding] org_member can read every sys_invitation row in the org — other people's emails, granted roles, inviter — intended? #8095
Description
Activity
Triage: escalated to
needs-user-decision, routeddomain:identity; the filer-applieddomain:access-securitylabel 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 — whetherorg_membershould 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()grantsallowRead: trueon everyBETTER_AUTH_MANAGED_OBJECTSentry (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:
- Platform long-term coherence — the blanket read baseline is coarse by design; carving per-object read exceptions erodes its simplicity, but
sys_invitationis qualitatively different from a staff directory: it is administrative intent about non-members. A row-scope carve-out follows the existingsys_*_orgpolicy pattern rather than inventing a mechanism. - Measured business pull — measured live by the filer: a plain
memberreads 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. - 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.
- 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_invitationread toowner/admin, plus a row-scope carve-outemail == current_user.emailso 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
- Platform long-term coherence — the blanket read baseline is coarse by design; carving per-object read exceptions erodes its simplicity, but
- addedbugSomething isn't workingSomething isn't workingand removed
on Aug 12, 2026 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_invitationread goes toowner/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()setsallowRead: truefor everyBETTER_AUTH_MANAGED_OBJECTSentry (default-permission-sets.ts:43/:71), and the org RLS (:300) scopes byorganization_idbut 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
Claim:
domain:identity执行席位 #6022 — sessionsession_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收敛但从不按行收敛。这是声明与执行一致的情形 —— 声明本身是错的,所以改声明。验收判据(两个方向都要,缺一不可):
- 用卡片自己的实测探针复现:
membervsowner打/api/v1/data/sys_invitation,member 现在应当读不到别人的行(原为200, total: 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
- 用卡片自己的实测探针复现:
{ "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
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
membercan 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) andwangwu@(admin, pending).lisi@is a plainmemberof that org.As
lisi@, with the active organization set to 甲:Identical to what the org owner sees. Full rows also carry
inviter_idandexpires_at.For contrast, the same member is correctly denied elsewhere:
so object-level gating is in force generally —
sys_invitationis simply readable byorg_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_memberread onsys_invitationis intended (in which case this issue is just documentation), or narrow it — e.g. restrict toowner/admin, or row-scope it toemail = current_user.emailso 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.