From 1890ea832c5249cb602def4dabdf77f4a61d0fec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 05:28:10 +0000 Subject: [PATCH] fix(security): grant members owner-scoped read on the personal inbox (#7344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: objectstack-ai/objectstack#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('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: } (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 `[_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 Claude-Session: https://claude.ai/code/session_017Prs32y86EcsRGzfdJJK1v --- .../member-default-personal-inbox-read.md | 50 ++++++++++++ .../src/member-default-explicit-allow.test.ts | 80 +++++++++++++++++++ .../src/objects/default-permission-sets.ts | 43 ++++++++++ .../src/objects/rbac-objects.test.ts | 5 ++ 4 files changed, 178 insertions(+) create mode 100644 .changeset/member-default-personal-inbox-read.md diff --git a/.changeset/member-default-personal-inbox-read.md b/.changeset/member-default-personal-inbox-read.md new file mode 100644 index 0000000000..b0e9cfbd44 --- /dev/null +++ b/.changeset/member-default-personal-inbox-read.md @@ -0,0 +1,50 @@ +--- +"@objectstack/plugin-security": patch +--- + +fix(security): `member_default` grants owner-scoped READ on the personal inbox (#7344) + +The Account app's **Inbox → Notifications** entry was a dead end for every +non-admin. The app declares no `requiredPermissions`, so it is reachable by +design for every authenticated user, but the object behind that entry was named +by no shipped permission set — so the read came back `403 PERMISSION_DENIED`, +verbatim from the browser-measured run: + +``` +[Security] Access denied: operation 'find' on object 'sys_inbox_message' +is not permitted for positions [org_member, contributor, finance, everyone] +``` + +Two consequences were measured: the Notifications entry never rendered anything +for the audience the Account app exists for, and the console bell's notification +half was structurally **0** for any non-admin (a badge reading `2` was +`0 notifications + 2 pending approvals`). + +`member_default` now NAMES both halves of the personal inbox, read-only: + +| object | read | create | edit | delete | row scoping | +|:---|:---:|:---:|:---:|:---:|:---| +| `sys_inbox_message` | ✅ | ❌ | ❌ | ❌ | `sys_inbox_message_self` — `user_id == current_user.id` | +| `sys_notification_receipt` | ✅ | ❌ | ❌ | ❌ | `sys_notification_receipt_self` — `user_id == current_user.id` | + +`sys_notification_receipt` is not an extra: read-state lives on the receipt, not +on the inbox row (ADR-0030), so the entry needs both to render. + +**This is not a rollback of #5491.** The baseline stays explicit-allow — no +wildcard returns, and these are two NAMED objects in exactly the shape +`sys_user_preference` already uses there: an object grant plus a `_self` RLS +carve-out. Neither object declares `organization_id`, so Layer 0 is inert on +them (as it is on `sys_oauth_application`) and the `_self` policies ARE the row +scoping — without them the read bit would have been org-wide. A member reads +their own rows and only their own; an unidentified caller fails closed to +`RLS_DENY_FILTER` rather than open. + +The grants are READ-only because nothing in the flow needs more: rows are +written by the always-on `inbox` messaging channel keyed on the recipient, and +mark-read is served by `POST /api/v1/notifications/read` rather than the generic +data API. `allowDelete`/`allowExport` stay false, so the set remains bindable to +the `everyone` anchor (ADR-0090 D5). + +`sys_activity` is deliberately **not** included, per the maintainer ruling — it +is not a per-user-scoped shape, and it is a separate question if it ever +matters. It is pinned as an explicit negative in the tests. diff --git a/packages/plugins/plugin-security/src/member-default-explicit-allow.test.ts b/packages/plugins/plugin-security/src/member-default-explicit-allow.test.ts index 9100a4f371..199ae32850 100644 --- a/packages/plugins/plugin-security/src/member-default-explicit-allow.test.ts +++ b/packages/plugins/plugin-security/src/member-default-explicit-allow.test.ts @@ -27,6 +27,7 @@ import { describe, it, expect } from 'vitest'; import { PermissionSetSchema } from '@objectstack/spec/security'; import type { PermissionSet } from '@objectstack/spec/security'; import { PermissionEvaluator } from './permission-evaluator.js'; +import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; import { defaultPermissionSets, BETTER_AUTH_MANAGED_OBJECTS } from './objects/default-permission-sets.js'; const evaluator = new PermissionEvaluator(); @@ -151,6 +152,85 @@ describe('[#5491] what the baseline still declares, it still enforces', () => { }); }); +// [#7344] Maintainer ruling (2026-08-11): extend `member_default` with +// owner-scoped READ grants for `sys_inbox_message` and +// `sys_notification_receipt`, RLS-scoped to the caller. `sys_activity` is +// deliberately NOT included — it is not a per-user-scoped shape. +// +// The measured defect: the Account app declares a Notifications nav entry with +// `requiresObject: 'sys_inbox_message'` and no `requiredPermissions`, so every +// authenticated member can reach the app but no shipped set named the object — +// `[Security] Access denied: operation 'find' on object 'sys_inbox_message'`. +// +// This mirrors the `sys_user_preference` precedent above: an explicit object +// grant PLUS a `_self` RLS policy, because the grant alone would be org-wide. +// Both halves are asserted here — the read bit is worthless if the scoping is +// missing, and the scoping is what makes the grant safe to ship on the +// `everyone` anchor. +const INBOX_OBJECTS = ['sys_inbox_message', 'sys_notification_receipt'] as const; + +const MEMBER = { userId: 'u_member', tenantId: 'org_1', positions: ['org_member'] } as any; +const rls = new RLSCompiler(); + +/** The policies `member_default` contributes for one object × operation. */ +const policiesFor = (object: string, operation: string) => + (MEMBER_DEFAULT.rowLevelSecurity ?? []).filter( + (p: any) => (p.object === object || p.object === '*') && (p.operation === operation || p.operation === 'all'), + ); + +describe('[#7344] the personal inbox is readable by a member, scoped to their own rows', () => { + it.each(INBOX_OBJECTS)('%s: the baseline NAMES it, so a member with no app profile can read', (object) => { + expect(allows('find', [MEMBER_DEFAULT], object)).toBe(true); + }); + + it.each(INBOX_OBJECTS)('%s: the grant is READ-ONLY — the writer is the inbox channel, not the member', (object) => { + expect(allows('insert', [MEMBER_DEFAULT], object), `${object} insert`).toBe(false); + expect(allows('update', [MEMBER_DEFAULT], object), `${object} update`).toBe(false); + expect(allows('delete', [MEMBER_DEFAULT], object), `${object} delete`).toBe(false); + }); + + it.each(INBOX_OBJECTS)('%s: a read is RLS-narrowed to the caller — another user\'s rows are unreachable', (object) => { + const policies = policiesFor(object, 'select'); + expect(policies.map((p: any) => p.name)).toEqual([`${object}_self`]); + const filter = rls.compileFilter(policies as any, MEMBER); + // The scoping is a positive `user_id` equality, not a fail-closed sentinel: + // the member sees their own rows and ONLY their own. A row belonging to + // another user cannot satisfy this filter, which is the "cannot read another + // user's rows" half of the ruling. + expect(filter).not.toBeNull(); + expect(filter).not.toEqual(RLS_DENY_FILTER); + expect(filter).toEqual({ user_id: 'u_member' }); + }); + + it.each(INBOX_OBJECTS)('%s: an anonymous/unidentified caller fails CLOSED, not open', (object) => { + // No `current_user.id` to compile against ⇒ the sentinel, i.e. zero rows. + expect(rls.compileFilter(policiesFor(object, 'select') as any, {} as any)).toEqual(RLS_DENY_FILTER); + }); + + it('`sys_activity` is deliberately NOT granted — the ruling excludes it', () => { + // Named as an explicit negative so a future "while we are here" sweep has to + // argue with the ruling rather than quietly widen past it. It is not a + // per-user-scoped shape; a separate question if it ever matters. + for (const { operation } of AXES) { + expect(allows(operation, [MEMBER_DEFAULT], 'sys_activity'), `sys_activity ${operation}`).toBe(false); + } + const names = (MEMBER_DEFAULT.rowLevelSecurity ?? []).map((p: any) => p.name); + expect(names).not.toContain('sys_activity_self'); + }); + + it('the additions stay anchor-safe and explicit (no wildcard crept in with them)', () => { + for (const object of INBOX_OBJECTS) { + const perm = (MEMBER_DEFAULT.objects as any)[object]; + expect(perm, `${object} is named`).toBeTruthy(); + expect(perm.allowDelete ?? false).toBe(false); + expect(perm.allowExport ?? false).toBe(false); + expect(perm.viewAllRecords ?? false).toBe(false); + expect(perm.modifyAllRecords ?? false).toBe(false); + } + expect(Object.keys(MEMBER_DEFAULT.objects ?? {})).not.toContain('*'); + }); +}); + describe('[#5491] the admin sets keep their wildcards (this is a BASELINE change only)', () => { it.each(['admin_full_access', 'organization_admin', 'viewer_readonly'])( '%s still carries a `*` entry', diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index 9260a9a154..1c9dfdb329 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -361,6 +361,30 @@ const baseDefaultPermissionSets: PermissionSet[] = [ // implicit; making it explicit is the migration, not a widening — the // effective access for a member is byte-identical. sys_user_preference: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + // [#7344] The personal inbox, READ-ONLY. Same reasoning as the line above, + // applied to the other pair of platform objects a platform app points every + // authenticated member at: the Account app's `Inbox` group declares a + // Notifications entry with `requiresObject: 'sys_inbox_message'` + // (`packages/platform-objects/src/apps/account.app.ts`) and declares no + // `requiredPermissions`, so the app is reachable by design while the object + // behind the entry was named by no shipped set — every non-admin got + // `403 PERMISSION_DENIED` and the bell's notification half was structurally + // zero. `sys_notification_receipt` rides along because read-state lives + // there (ADR-0030), not on the inbox row, so the entry needs both. + // + // Maintainer ruling (2026-08-11): READ grants only. Rows are produced by + // the always-on `inbox` messaging channel keyed on the recipient + // (`service-messaging/src/inbox-channel.ts`) under the service's own engine + // access, and mark-read is served by `/api/v1/notifications` rather than the + // generic data API — so no create/edit bit is needed by the flow, and + // `allowDelete` stays false like everything else in this anchor-bound set. + // `sys_activity` is deliberately NOT included: it is not a per-user-scoped + // shape (no `user_id` to scope by), and it is a separate question if it ever + // matters. Two NAMED additions in #5491's explicit-allow shape — the + // `_self` policies below scope both to the caller — not a widening pattern + // and not a step back toward a wildcard. + sys_inbox_message: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + sys_notification_receipt: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, }, rowLevelSecurity: [ // [ADR-0095 D1] The wildcard `tenant_isolation` policy RETIRED here — the @@ -508,6 +532,25 @@ const baseDefaultPermissionSets: PermissionSet[] = [ operation: 'all', using: 'user_id == current_user.id', }, + // [#7344] The personal inbox (Account → Inbox → Notifications, and the + // console bell). Neither object declares `organization_id`, so Layer 0 is + // inert on them exactly as it is on `sys_oauth_application` above and these + // `_self` policies ARE their row scoping — without them the read bit added + // to `objects` would be org-wide, which is the one outcome the ruling's + // "RLS-scoped to the caller" forbids. `select` (not `all`) because the + // grants are read-only; the writer is the inbox channel, not the member. + { + 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', + }, ], }), PermissionSetSchema.parse({ diff --git a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts index c3f3a000e7..dc6f6726bf 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -102,6 +102,11 @@ describe('default permission sets', () => { 'sys_account_self', 'sys_api_key_self', 'sys_device_code_self', + // [#7344] The personal-inbox pair — not better-auth tables, but the same + // `_self` shape for the same reason: no `organization_id`, so Layer 0 is + // inert and these policies are the row scoping for their read grants. + 'sys_inbox_message_self', + 'sys_notification_receipt_self', 'sys_oauth_access_token_self', 'sys_oauth_application_self', 'sys_oauth_consent_self',