Measured while implementing the authenticated-caller scoping for the inbox write door (#10753 / PR #11450). Filing rather than folding in: the ruling on #10753 names markRead and recall, so the read side is outside that card's scope.
The shape
MessagingService.listInbox(userId, opts) (packages/services/service-messaging/src/messaging-service.ts:372) keys its whole read on the caller-supplied userId:
where = { user_id: userId } against sys_inbox_message (:378)readReceiptStates(data, userId) → where: { user_id: userId, channel: 'inbox' }
On the REST path that parameter is trustworthy — packages/runtime/src/domains/notifications.ts:175 binds it to context.executionContext.userId and answers 401 when there is none. But the service is also registered as a kernel service (ctx.registerService('messaging', service), messaging-service-plugin.ts:162), and the kernel hands every plugin one shared PluginContext (packages/core/src/kernel.ts:65,:91) whose getService carries no caller identity.
So for an in-process caller userId is a free string, and the read lands context-lessly on sys_inbox_message / sys_notification_receipt — the latter managedBy: 'engine-owned' with apiMethods: ['get','list'] (ADR-0103) — so no engine permission check sees it either.
⇒ Any plugin can read any user's inbox titles, bodies and read-state. This is the exact shape PR #11450 removed from the write side, on the read side, and it is arguably the more sensitive half: inbox bodies carry rendered business content.
Why it was not fixed in PR #11450
That PR's ruling (comment 5386675655) covers the write verbs only. Widening it to the read side without a ruling would be scope creep on a card whose whole point was that public-surface widening is the maintainer's call.
Suggested shape (not a decision)
The same one PR #11450 landed: a plugin-facing listInboxAsCaller(caller: ExecutionContext, opts) that derives the recipient from caller.userId and refuses with InboxCallerError (401 UNAUTHENTICATED) otherwise, reusing resolveInboxRecipient from packages/services/service-messaging/src/inbox-caller.ts. listInbox(userId, opts) stays as the published INotificationService contract the REST door needs.
Note the same honest caveat that PR applies to itself: this is a discipline boundary, not a security boundary — an in-process plugin already holds the data engine.
Links
Measured while implementing the authenticated-caller scoping for the inbox write door (#10753 / PR #11450). Filing rather than folding in: the ruling on #10753 names
markReadandrecall, so the read side is outside that card's scope.The shape
MessagingService.listInbox(userId, opts)(packages/services/service-messaging/src/messaging-service.ts:372) keys its whole read on the caller-supplieduserId:where = { user_id: userId }againstsys_inbox_message(:378)readReceiptStates(data, userId)→where: { user_id: userId, channel: 'inbox' }On the REST path that parameter is trustworthy —
packages/runtime/src/domains/notifications.ts:175binds it tocontext.executionContext.userIdand answers 401 when there is none. But the service is also registered as a kernel service (ctx.registerService('messaging', service),messaging-service-plugin.ts:162), and the kernel hands every plugin one sharedPluginContext(packages/core/src/kernel.ts:65,:91) whosegetServicecarries no caller identity.So for an in-process caller
userIdis a free string, and the read lands context-lessly onsys_inbox_message/sys_notification_receipt— the lattermanagedBy: 'engine-owned'withapiMethods: ['get','list'](ADR-0103) — so no engine permission check sees it either.⇒ Any plugin can read any user's inbox titles, bodies and read-state. This is the exact shape PR #11450 removed from the write side, on the read side, and it is arguably the more sensitive half: inbox bodies carry rendered business content.
Why it was not fixed in PR #11450
That PR's ruling (comment
5386675655) covers the write verbs only. Widening it to the read side without a ruling would be scope creep on a card whose whole point was that public-surface widening is the maintainer's call.Suggested shape (not a decision)
The same one PR #11450 landed: a plugin-facing
listInboxAsCaller(caller: ExecutionContext, opts)that derives the recipient fromcaller.userIdand refuses withInboxCallerError(401UNAUTHENTICATED) otherwise, reusingresolveInboxRecipientfrompackages/services/service-messaging/src/inbox-caller.ts.listInbox(userId, opts)stays as the publishedINotificationServicecontract the REST door needs.Note the same honest caveat that PR applies to itself: this is a discipline boundary, not a security boundary — an in-process plugin already holds the data engine.
Links