Conversation
CalvinTjoaquinn
left a comment
There was a problem hiding this comment.
Traced the catalog change against every handler that touches this resource, and read + update is exactly the set these endpoints need, no more:
| handler | store method | action required |
|---|---|---|
listInboxNotifications |
GetFilteredInboxNotificationsByUserID, GetInboxNotificationsByUserID |
ActionRead (dbauthz.go:4055, :4165) |
watchInboxNotifications |
CountUnreadInboxNotificationsByUserID |
ActionRead (dbauthz.go:2081) |
updateInboxNotificationReadStatus |
UpdateInboxNotificationReadStatus |
ActionUpdate, via update() → fetchAndExec(..., policy.ActionUpdate, ...) (dbauthz.go:1174, :7891) |
markAllInboxNotificationsAsRead |
MarkAllInboxNotificationsAsRead |
ActionUpdate (dbauthz.go:7141) |
Nothing on the read or write paths needs ActionCreate; only InsertInboxNotification does and that is server-side, so keeping create internal costs callers nothing.
That also answers a question the diff invites: the file block immediately above adds file:* alongside file:create, and this block adds no inbox_notification:*. The asymmetry is right, since the wildcard would hand out create as well. Might be worth a one-line comment next to the entries so the next person editing this map does not "fix" it by adding the wildcard for consistency.
One gap: update becomes public but nothing exercises it
Both tests cover the read half. nok - missing scope proves a workspace:read token is refused, and OK proves an inbox_notification:read token can watch. Neither drives inbox_notification:update.
Since the PR's own premise is that these scopes exist in the enum but no token can reach them, the symmetric proof for the other half would be a token scoped to inbox_notification:update against one of:
PUT /notifications/inbox/{id}/read-status- the mark-all endpoint
Both resolve to ActionUpdate per the table above, so one case is enough to pin that update is actually reachable and that it is not silently satisfied by read. As it stands, half of what the catalog now advertises is covered only by inspection.
One thing I suspected and it is fine
The new gate authorizes ResourceInboxNotification.WithOwner(apikey.UserID.String()), while the work it is gating happens per message further down, so I expected the two could disagree about whose inbox is being read. They do not: the per-message count is CountUnreadInboxNotificationsByUserID(ctx, apikey.UserID) at inboxnotifications.go:247 and the subscription channel is InboxNotificationForOwnerEventChannel(apikey.UserID) at :157. The gate covers exactly the work that follows it.
Placing the check before the upgrade also changes the failure from a 1000 close to a 403, which is the part a client can actually act on.
294f8bc to
35684e5
Compare
|
/coder-agents-review |
|
Chat: Review posted | View chat Review history
deep-review v0.13.0 | Round 1 | Last posted: Round 1, 9 findings (2 P3, 3 P4, 4 Nit), COMMENT. Review Finding inventoryFindings
Contested and acknowledgedRound logRound 1Netero: no findings, two notes. Full panel (round 1, multi-domain auth surface): ging-go, kurapika, ryosuke, pariston, mafuuu, bisky, gon, leorio, kite, razor, hisoka, wildcard zoro. 2 P3, 2 P4 inline, 3 Nit inline, 2 body items (CRF-7, CRF-9, no file location), 3 Notes, 6 OOS. Leorio's CRF-6 downgraded P3 to P4: keep-argument is that the PR's goal is an actionable refusal and the body misdirects users to an admin; downgrade reason is that the 403 status itself is the actionable signal and the message is the shared httpapi.Forbidden convention whose root fix is OOS (CRF-17). Kite's note that cross-user IDs stay refused is contradicted for owner tokens by Kurapika's reproduction; empirical result wins. Reviewed against cc73f32..35684e5. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
This PR adds inbox_notification:read and inbox_notification:update to the public scope catalog and adds handler pre-checks so the four inbox endpoints return 403 to under-scoped tokens. Findings: 2 P3, 2 P4, 3 Nit inline, plus a P4 and a Nit on the PR text.
The failing Storybook job does not touch this diff (no files under site/ change, and typesGenerated.ts already listed both scopes). The job log was not readable from the review environment, so the cause is unverified.
Out of scope (needs a ticket or explicit acceptance by a human):
coderd/inboxnotifications.go:340: an unknown or unreadablestarting_beforeID is ignored and the first page is returned, so a client paginating with that cursor gets page one again.coderd/inboxnotifications.go:214: whenSubscribeWithErrfails, the watch handler returns without writing a response, so the client gets an empty 200.coderd/inboxnotifications.go:249: anyCountUnreadInboxNotificationsByUserIDerror in the watch loop closes the socket as a normal close, so a DB failure looks like a clean shutdown.coderd/notifications.go:255: other user-owned notification handlers have no pre-check, so an under-scoped token gets a 500 there.coderd/httpapi/httpapi.go:170:ResourceForbiddenResponse(85 call sites) never names a missing scope, so every scoped-token refusal tells the user to contact an administrator.coderd/inboxnotifications.go:152: theread_statusvalidation error namesstarting_before(same text at:328).
Notes:
coderd/inboxnotifications_test.go:100:NOK - missing scopesends a plain GET; at the base SHA it returns 426, not the 1000 close a real websocket client sees. Awebsocket.Dialasserting 403 would cover the client's path.coderd/inboxnotifications.go:408: a token whose allow list names oneinbox_notification:<uuid>is accepted at creation but refused by every inbox endpoint, because each check authorizes an object with no ID. The same was true before this PR via the unread count.coderd/rbac/scopes_catalog.go:50: once released, these names are stored in tokens and published in OAuth2scopes_supported, so they cannot be renamed or narrowed without breaking existing tokens.
In reply to IC_kwDOGkVX1s8AAAABYbckcA:
P4 [CRF-7] The commit subject and PR title say "read", but the PR also makes inbox_notification:update public and opens both mark-as-read endpoints to scoped tokens. Suggested: fix: let scoped tokens list and mark inbox notifications read. (Leorio)
🤖
In reply to IC_kwDOGkVX1s8AAAABYbckcA:
Nit [CRF-9] The PR description's Verification section pastes go test output, which .claude/docs/PR_STYLE_GUIDE.md lists under "Never Include". Keep the sentence "Its four refusal cases fail without the handler change", which CI does not show, and drop the rest. (Leorio)
🤖
🤖 This review was automatically generated with Coder Agents.
6497e3e to
c418bce
Compare
inbox_notification:read and inbox_notification:update were not public scopes, so no scoped or OAuth token could use the inbox API. The watch endpoint also upgraded before authorizing, then closed the socket on the first notification when counting unread ones failed. Expose both scopes, and authorize every inbox endpoint up front so a missing scope returns 403 instead of a 500 or a clean close. Marking one notification read needs read too, since the response reads it back; without the check an update-only token changed the row and then failed.
read-status acted on any notification RBAC allowed, so an owner could mark another user's notification read. Return 404 unless the notification belongs to the caller, and name the missing scope in the inbox 403s.
c418bce to
4bb697f
Compare
Problem
inbox_notification:readandinbox_notification:updateexist, but aren't in the public scope catalog. So no scoped API key or OAuth token (short ofcoder:all) can list, watch, or mark inbox notifications read.The inbox endpoints also hide a missing scope:
GET /notifications/inbox/watchupgrades the socket without authorizing, then each message counts unread notifications, which needsinbox_notification:read. When that fails the server closes the socket with 1000, so clients see a clean close instead of a refusal.PUT /notifications/inbox/{id}/read-statusreads the notification and unread count back after updating. Anupdate-only token marks the notification read, then gets a 500.PUT /notifications/inbox/{id}/read-statusacts on any notification RBAC allows, so an owner could mark another user's notification read and get its content back. A member passing another user's or an unknown ID got a 500.Found while auditing the VS Code extension's OAuth scopes in coder/vscode-coder#1128.
Fix
Add
inbox_notification:readandinbox_notification:updatetoexternalLowLevel(and the generatedPublicAPIKeyScopes).createstays internal, so there's noinbox_notification:*. The DB enum already has both values, so no migration.Every inbox endpoint authorizes before doing anything and returns 403, with a detail naming the missing scope:
GET /notifications/inboxreadGET /notifications/inbox/watchread(before upgrading)PUT /notifications/inbox/{id}/read-statusread+updatePUT /notifications/inbox/mark-all-as-readupdateread-statusfetches the notification first and returns 404 unless it belongs to the caller.Verification
TestInboxNotifications_Scopescovers each endpoint with too few and enough scopes, and checks a refused request leaves the notification unread. Its four refusal cases fail without the handler change.TestInboxNotification_Watch/NOK - missing scopedials the watch socket with aworkspace:readtoken and gets 403.