Skip to content

fix(security): grant members owner-scoped read on the personal inbox (#7344) - #7586

Merged
os-help merged 1 commit into
mainfrom
claude/issue-7344-member-inbox-grants
Aug 11, 2026
Merged

fix(security): grant members owner-scoped read on the personal inbox (#7344)#7586
os-help merged 1 commit into
mainfrom
claude/issue-7344-member-inbox-grants

Conversation

@os-help

Copy link
Copy Markdown
Collaborator

Fixes#7344

PM-relayed draft (spec-surface seat, session session_016R9de1FqP7NvwKvqXi92Gh): the implementing dev — dedicated cloud session session_017Prs32y86EcsRGzfdJJK1v (claude-opus-5, recorded in the claim thread) — pushed this branch under the E34-adapted transport; the full OS-DEV-REPORT is the body of the single commit (git show 1890ea8) and is the report of record. The PM opened this draft on its behalf and wrote none of the code. The summary below is an index into the commit report.

Executes the 2026-08-11 maintainer ruling on #7344 (Option A): member_default gains owner-scoped, read-only grants for sys_inbox_message and sys_notification_receipt; sys_activity deliberately NOT included and pinned as an explicit negative.

The gate: producer measurement — PRODUCER EXISTS ⇒ Option A, not C

The ruling made the work conditional on measuring a real producer of member-visible inbox rows. The commit report carries the full six-hop chain, each hop cited: the inbox channel inserts rows keyed by the recipient's own user_id (inbox-channel.ts:112, receipts at :64); the channel is on by default (messaging-service-plugin.ts:98,126); an unqualified emit() defaults to ['inbox'] (messaging-service.ts:620); the approvals pipeline is the producer (approval-service.ts:632, production wiring at approvals-plugin.ts:188-191); member-persona reachability holds in both directions (submitter gets request-info/returned/comment notices; approver gets reminders/reassignments/escalations); and the emit→row hop is covered by an executed conformance test, not just a static read. One suspicious zero-hit (attachMessaging seemingly test-only) was falsified before use.

The change

Two named read-only object grants + two _selfselect RLS policies (user_id == current_user.id), placed on the sys_user_preference / read-only precedent rows in default-permission-sets.ts. Shape rationale measured, not assumed: neither object declares organization_id, so the Layer-0 tenant wall is inert on them and the _self policies are load-bearing; read-only suffices because rows are written by the messaging service's own engine handle and mark-read is served by POST /api/v1/notifications/read, not the generic data API. No '*', no glob — #5491's explicit-allow posture kept.

Tests: the rbac-objects sorted exact-list pin learns the two new policy names (+2, the deliberate gate); a new 10-case [#7344] block mirrors the precedent tests — own-rows readable, cross-user compiled to a positive { user_id } equality (neither null nor deny), unidentified caller fails closed, read-only on all write axes, sys_activity denied on all four axes, anchor-safety preserved.

Verification (from the commit report)

plugin-security: 45 files / 941 tests green; targeted 2 files / 53 green; tsc --noEmit clean. Cross-package pins: spec explain/book 53 green, rest security-routes 8 green. Recorded gap: the QA dogfood boot suites were read statically, not run — their member_default probe (sys_user_preference 200) is unaffected by two added objects.

Changeset: .changeset/member-default-personal-inbox-read.md, @objectstack/plugin-security: patch — shipped authorization behavior. content/docs/releases/ untouched.

Out-of-scope findings (in the commit report)

sys_activity exclusion leaves no dangling nav entry (the Account app never pointed at it); #7266's wrong-app bell routing still stands and now becomes the sole remaining defect in that flow; a future move of mark-read onto the generic data API would need an edit bit + check-clause policy (deliberately not pre-granted).


Generated by Claude Code

…7344)
OS-DEV-REPORT
=============
status: done
branch: claude/issue-7344-member-inbox-grants
base: origin/main @ 9051802 (fetched fresh; measured against origin/main, not a stale tree)
issue: #7344
ruling executed: comment 5248466727 (2026-08-11), Option A
premise_still_valid: YES — re-verified on origin/main @ 9051802. `member_default`
(packages/plugins/plugin-security/src/objects/default-permission-sets.ts:314) named the
better-auth identity tables plus `sys_user_preference` (:363) and nothing else, while
packages/platform-objects/src/apps/account.app.ts:88-99 declares the Notifications nav entry
with `requiresObject: 'sys_inbox_message'` and the app declares no `requiredPermissions`.
The mismatch the card describes is exactly what the code showed.
STEP 1 — PRODUCER MEASUREMENT (the gate): PRODUCER EXISTS ⇒ Option A
---------------------------------------------------------------------
A producer writes member-visible inbox messages today. Approval notifications are the
writer, as the ruling expected. Full chain, each hop cited:
1. INSERT PATH (the rows themselves)
- packages/services/service-messaging/src/inbox-channel.ts:112
`const created = await data.insert(objectName, row)` where `objectName` defaults to
`sys_inbox_message` (:13, :47) and `row.user_id = delivery.recipient` (:95, :99).
The row is keyed by the RECIPIENT's own user id — precisely the rows a plain member
would see under a `user_id == current_user.id` scope.
- packages/services/service-messaging/src/inbox-channel.ts:64
`await data.insert(receiptObject, { ... user_id: r.userId, channel: 'inbox',
state: 'delivered' ... })` → `sys_notification_receipt` (:16). This is why the grant
needs both objects: read-state lives on the receipt, not the inbox row (ADR-0030,
inbox-channel.ts:119-120).
2. THE CHANNEL IS ALWAYS-ON, NOT OPT-IN
- packages/services/service-messaging/src/messaging-service-plugin.ts:98
`registerInbox: true` is the constructor DEFAULT.
- packages/services/service-messaging/src/messaging-service-plugin.ts:126-127
`if (this.options.registerInbox) service.registerChannel(createInboxChannel({ getData }))`
3. THE DEFAULT CHANNEL IS `inbox`, SO ANY UNQUALIFIED emit() MATERIALIZES A ROW
- packages/services/service-messaging/src/messaging-service.ts:620
`const channels = input.channels?.length ? input.channels : ['inbox'];`
- The preference filter at :621 only drops (recipient × channel) pairs the user MUTED
and is fail-open on error (:617-618) — an ordinary member with no preference row is
not filtered out.
4. THE PRODUCER: THE APPROVALS PIPELINE
- packages/plugins/plugin-approvals/src/approval-service.ts:609-640 — `notify()` calls
`this.messaging.emit({ severity: 'info', ...input, payload, audience })` at :632 with
NO `channels` key ⇒ falls to the `['inbox']` default above.
- PRODUCTION wiring (not just tests): packages/plugins/plugin-approvals/src/approvals-plugin.ts:188-191
`const messaging = ctx.getService<ApprovalMessagingSurface>('messaging');`
`if (messaging && typeof messaging.emit === 'function') this.service.attachMessaging(messaging);`
5. REACHABILITY FOR A MEMBER PERSONA (what user action triggers it)
Member-as-submitter (the strongest case — a plain member with no approver role):
a plain member submits a record that enters an approval flow, so the request's
`submitter_id` is that member's user id. An approver then clicks "Request info" (or
comments, or the request is returned):
requestInfo() → approval-service.ts:2872-2875
`notify({ topic: 'approval.request_info', audience: [String(raw.submitter_id)] })`
→ emit() with no `channels` → default `['inbox']` (messaging-service.ts:620)
→ preference filter passes → inbox channel `send()`
→ INSERT sys_inbox_message { user_id: <the member's OWN id> } (inbox-channel.ts:112)
+ INSERT sys_notification_receipt { user_id, channel:'inbox', state:'delivered' } (:64)
Same shape via `approval.returned` (:2353-2355, :2395-2397) and `approval.comment`
(:2915-2918, audience = the other side of the thread).
Member-as-approver:
an approval routes to the member; the submitter clicks "Remind" →
`approval.reminder`, `audience: [approver]` (:2702-2705). Also `approval.reassigned`
→ the new approver (:2643-2645), `approval.ooo_substituted` → the covering approver
(:1622-1624), `approval.escalated` (:3273-3275), `approval.sla_breached` (:3292-3295).
6. THE emit()→ROW HOP IS COVERED BY AN EXECUTED TEST, not just read statically
- packages/services/service-messaging/src/notification-schema-conformance.test.ts:142-158
registers the REAL `createInboxChannel` against an in-memory engine and calls
`service.emit({ topic, audience, payload })` with NO `channels`, then reads the rows
back through `listInbox`. That pins the default-channel → inbox-row materialization.
ZERO-HIT DISCIPLINE: one grep did return a suspicious zero — `attachMessaging` appeared
only in `*.test.ts` on the first pass, which would have argued "wired in tests only, no
real producer". I falsified it before concluding anything: a count probe returned 11 total
occurrences vs 3 non-test, which surfaced the production call site at approvals-plugin.ts:190.
No absence in this report rests on an unfalsified zero-hit.
⇒ The feature is NOT empty. Option A implemented; NOT flipped to Option C.
STEP 2 — WHAT CHANGED
---------------------
1. packages/plugins/plugin-security/src/objects/default-permission-sets.ts
`member_default.objects` (after the `sys_user_preference` precedent row):
sys_inbox_message: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }
sys_notification_receipt: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }
`member_default.rowLevelSecurity` (after `sys_oauth_application_self`):
{ name: 'sys_inbox_message_self', object: 'sys_inbox_message', operation: 'select', using: 'user_id == current_user.id' }
{ name: 'sys_notification_receipt_self', object: 'sys_notification_receipt', operation: 'select', using: 'user_id == current_user.id' }
Shape rationale, measured rather than assumed:
- `_self` policies are REQUIRED, not decorative. Neither object declares an
`organization_id` field (inbox-message.object.ts:50-105, notification-receipt.object.ts —
grep for `organization_id` returns no field declaration on either), so the ADR-0095 D1
Layer 0 tenant wall is inert on them, exactly as it is on `sys_oauth_application`
(default-permission-sets.ts:501-504). Without these policies the new read bit would have
been org-wide — the one outcome the ruling's "RLS-scoped to the caller" forbids.
- `operation: 'select'`, not `'all'`. The `sys_user_preference_self` precedent uses `'all'`
because that grant is read+write; ours is read-only, so it follows the read-only
precedent in the same file (`sys_oauth_access_token_self` :486, `sys_team_member_self` :456).
- READ ONLY, no create/edit/delete. The ruling says "read grants", and the measurement
agrees that nothing in the flow needs more: rows are written by the inbox channel
(inbox-channel.ts:112/:64) through the messaging service's own engine handle, and
mark-read is served by POST /api/v1/notifications/read → `inbox.markRead(userId, ids)`
(packages/runtime/src/domains/notifications.ts:18-19, :198, :205), NOT the generic data
API. So no edit bit on `sys_notification_receipt` is needed to make the inbox usable.
`allowDelete`/`allowExport` stay false, keeping the set bindable to the `everyone`
anchor (ADR-0090 D5 / #2753).
- ⛔ `sys_activity` NOT added, per the ruling. Pinned as an explicit negative test.
- #5491 posture respected: no `'*'` key, no sentinel, no glob — two NAMED objects.
2. packages/plugins/plugin-security/src/objects/rbac-objects.test.ts:99
The existing sorted exact-list `toEqual` pin of `member_default`'s RLS policy names had to
learn the two new names, or it fails. Updated (+2 entries, in sort order). Flagging this
because it is a deliberate pin, not incidental churn — it is the gate that makes any
future silent addition to this set visible.
3. packages/plugins/plugin-security/src/member-default-explicit-allow.test.ts
New `[#7344]` describe block, 10 cases, mirroring the `sys_user_preference` precedent
tests in the same file:
- member CAN read own sys_inbox_message / sys_notification_receipt (object bit)
- the grants are read-only (insert/update/delete all denied) on both objects
- CANNOT read another user's rows: the real `RLSCompiler` compiles member_default's
select policies for each object against a member context and must yield exactly
`{ user_id: 'u_member' }` — a positive equality, asserted to be neither `null` nor
`RLS_DENY_FILTER`, and the policy set for the object is asserted to be exactly
`[<object>_self]`
- an unidentified caller fails CLOSED (`RLS_DENY_FILTER`), not open
- sys_activity remains DENIED on all four axes and has no `_self` policy (the denial
idiom the repo already uses, applied as the ruling's explicit negative)
- the additions stay anchor-safe (no allowDelete/allowExport/viewAllRecords/
modifyAllRecords) and no wildcard crept in alongside them
4. .changeset/member-default-personal-inbox-read.md — patch for @objectstack/plugin-security.
TESTS + TYPECHECK (real output)
-------------------------------
Deps built first (`turbo run build --filter=@objectstack/plugin-security^...`, 17 tasks
successful) — without it vitest fails on `Cannot find package '@objectstack/spec/security'`.
$ cd packages/plugins/plugin-security && npx vitest run src/member-default-explicit-allow.test.ts src/objects/rbac-objects.test.ts
Test Files 2 passed (2)
Tests 53 passed (53)
Duration 3.11s
$ cd packages/plugins/plugin-security && npx vitest run # full package suite
Test Files 45 passed (45)
Tests 941 passed (941)
Duration 19.09s
$ cd packages/plugins/plugin-security && npx tsc --noEmit
=== TYPECHECK EXIT: 0 === # no output, clean
Broader security/RLS surfaces that reference `member_default`, run to catch cross-package pins:
$ cd packages/spec && npx vitest run src/security/explain.test.ts src/system/book.test.ts
Test Files 2 passed (2)
Tests 53 passed (53)
$ cd packages/rest && npx vitest run src/security-routes.test.ts
Test Files 1 passed (1)
Tests 8 passed (8)
NOT run: packages/qa/dogfood (`showcase-default-profile`, `showcase-d7-default-profile`,
`rls-multitenant`, …). They boot a real stack and were out of reach for a sensible cycle time
here. Read statically instead: they use `GET /data/sys_user_preference` as the probe for "is
member_default the governing fallback" and assert 200 under it — adding two unrelated objects
cannot change that verdict. Recording the gap rather than implying coverage I did not run.
CHANGESET
---------
Added: .changeset/member-default-personal-inbox-read.md — `"@objectstack/plugin-security": patch`
(package name read from packages/plugins/plugin-security/package.json:2). This changes shipped
authorization behavior, so it is a required changeset, not an optional one. It carries the
before/after grant table, the verbatim 403 the card measured, and the explicit statement that
this is not a #5491 rollback. content/docs/releases/ NOT touched.
OUT-OF-SCOPE FINDINGS
---------------------
1. `sys_activity` still 403s for a member — correct per the ruling, and it leaves NO nav dead
end: `grep sys_activity packages/platform-objects/src/apps/account.app.ts` returns nothing,
so the `GET /api/v1/data/sys_activity` in the card's evidence comes from a console component
(objectui), not from an Account app nav entry. The ruling's "separate question if it ever
matters" stands, and this PR does not create a new dangling entry by excluding it.
2. #7266 is NOT fixed by this and still matters. This removes the 403; the bell's
"View all notifications" still routes a non-admin to `/apps/setup/sys_inbox_message?view=mine`
(the wrong app). The card predicted exactly this compounding — after this PR a non-admin
lands in the right permission state in the wrong app.
3. Mark-read is safe under read-only grants TODAY because it is served by
/api/v1/notifications (runtime/src/domains/notifications.ts:198/:205), not the data API.
If that ever moves to the generic data API, `sys_notification_receipt` will need an edit
bit and a `check`-clause policy. Noted, deliberately not pre-granted — the ruling says read.
4. `sys_inbox_message` declares a `mine` list view already filtered to
`{current_user_id}` (inbox-message.object.ts:36-47), which is what the nav entry points at.
It now has a matching server-side grant + RLS, so the view is no longer a client-side
filter over an object the caller cannot read at all.
OPEN QUESTIONS
--------------
None blocking. One for the PM to relay if the maintainer wants it on record: this session DID
have read-only GitHub API access (issue body + all 5 comments were read directly, which is how
the ruling text was verified verbatim rather than trusted from the dispatch brief). Write
access was not attempted, per the E34 transport constraint. The E34 note may be worth
narrowing from "no GitHub API tooling" to "no GitHub WRITE tooling" for future cloud dispatches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Prs32y86EcsRGzfdJJK1v
@vercel

vercelBot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 11, 2026 6:28am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security.

12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx(via @objectstack/plugin-security)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/plugin-security)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/access-recipes.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/explain.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/index.mdx(via @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-security)
  • content/docs/ui/audience-based-interfaces.mdx(via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx(via @objectstack/plugin-security)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-security)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 11, 2026
@os-help
os-help marked this pull request as ready for review August 11, 2026 07:13
@os-help
os-help added this pull request to the merge queueAug 11, 2026
Merged via the queue into main with commit 3987a48Aug 11, 2026
26 checks passed
@os-help
os-help deleted the claude/issue-7344-member-inbox-grants branch August 11, 2026 07:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The Account app's Notifications entry 403s for every non-admin — no permission set grants sys_inbox_message

2 participants

@os-help@claude