fix(notifications): enforce can_delete and recipient access on DELETE - #679
ferdinand-van-butzelaar wants to merge 1 commit into
Conversation
`DELETE /api/notifications/{id}` honoured neither of the two checks that
already exist in the codebase.
`can_delete=False` marks a notification as "you must respond to this, you
cannot dismiss it" -- approval requests (`workflow_executor.py:631`), access
grant requests (`access_grants_manager.py:765`), contract deploy requests
(`data_contracts_manager.py:5264`) and role access requests
(`user_routes.py:449`). The flag was persisted correctly but
`delete_notification` went straight to `_repo.remove()`, so it was enforced
by the frontend alone and any direct API call bypassed it.
Separately, the endpoint was guarded only by
`PermissionChecker('notifications', ADMIN)` and never called
`can_user_access_notification()`. Any principal with that feature permission
could delete another user's notification, including one scoped to a role they
are not in -- inconsistent with mark-as-read, the weaker operation, which
does check.
Changes:
- `delete_notification` now loads the row first, raises
`NotificationNotFoundError` when absent and the new
`NotificationNotDeletableError` when `can_delete` is False. The route maps
those to 404 and 403.
- The route calls `can_user_access_notification()` before deleting, mirroring
the mark-as-read endpoint.
- Admins may override `can_delete` via a new `is_admin` keyword, which
defaults to False so any caller that forgets it gets the safe behaviour.
This resolves the open question in the issue: `get_notifications` already
reports `can_delete=True` on everything an admin can see, so without the
override an admin's UI would offer a delete button that always 403s, and
notifications orphaned by a dead workflow could never be cleaned up. The
override is recorded in the audit entry as `admin_override`.
- Adds `NotificationsManager.is_app_admin()` and `_role_maps()`, replacing
the role-lookup and admin-detection block that was duplicated verbatim in
`get_notifications` and `can_user_access_notification`. Admin is resolved
the same way in all three places, so the reported and enforced values of
`can_delete` cannot drift apart.
Tests: 18 new cases covering can_delete enforcement, the admin override, the
default-to-non-admin contract, fail-closed behaviour when role lookup fails,
recipient/role/broadcast access, and the route's status mapping.
Also updates the delete cases in the quarantined
`test_notifications_manager.py` to the new contract. That module stays
quarantined -- it mocks `manager._repo`, which is the real repository
singleton, so all of its cases fail at setup independently of this change.
Closes databrickslabs#675
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mvkonchits-db
left a comment
There was a problem hiding this comment.
Verified every claim against development source rather than taking the description at face value. Clean, correctly-scoped authorization fix that closes the can_delete gap flagged as a follow-up in #674. Approving.
Diagnosis confirmed:
delete_notificationwent straight to_repo.removewith nocan_deleteor recipient check (development L283–290).- The DELETE route was guarded only by
PermissionChecker('notifications', ADMIN)— a per-feature permission ("may use notifications"), not "may act on someone else's notification". - mark-as-read already calls
can_user_access_notification(routes L153); the strictly more destructive delete had the weaker check. This makes them consistent. NotificationDb.can_delete(column) and APINotification.can_delete(model, default True) both exist.
Fix verified:
- Typed exceptions (
NotificationNotFoundError→ 404,NotificationNotDeletableError→ 403) let the route return correct status; the oldif not deleted: 404couldn't distinguish "missing" from "refused". - Fails closed:
is_admindefaults toFalse, and_role_maps()swallows lookup errors into empty maps sois_app_adminreturnsFalseon a role-service failure rather than promoting. - The extracted
is_app_adminis behaviorally identical to the old inline block inget_notifications(role_by_name.get('Admin')+assigned_groups+user_info.groupsmembership). Sinceget_notificationsuses the same notion to reportcan_delete=True, what the API reports and what DELETE enforces can no longer drift apart — that's a real correctness argument, not just DRY tidiness.
One deliberate security decision worth a conscious maintainer sign-off: this lets admins override can_delete=False (the "you must respond" notifications). I agree with the reasoning — get_notifications already reports can_delete=True to admins, so refusing at DELETE would make the API contradict itself, and there's otherwise no cleanup path for a notification orphaned by a dead workflow. The override is narrow (Admin app-role only), logged at INFO, and audit-recorded as admin_override, and the author offered a two-line no-override alternative. Flagging so it's an intentional call, not a side effect.
Minor / non-blocking:
- Extra DB round-trip (route does
get_notification_by_id+ access check, thendelete_notificationre-gets). Negligible and mirrors mark-as-read. - The
update_notificationF811 duplicate (L365 & L419) is real — correctly left out of scope and offered as a separate filing.
One note for whoever merges: CI hasn't run (fork PR from a first-time contributor needs workflow approval). My review is a static verification against source plus a read of the 18-case test suite; worth letting them actually execute before merge.
Closes #675.
The two missing checks
Both already exist in the codebase; the delete endpoint just never called them.
1.
can_deletewas not enforced server-sideNotificationsManager.delete_notificationwent straight toself._repo.remove(...). Reproduced against a session with acan_delete=Falserow present:The four flows that rely on the flag — approval requests (
workflow_executor.py:631), access grants (access_grants_manager.py:765), contract deploys (data_contracts_manager.py:5264), role access requests (user_routes.py:449) — were protected by the frontend alone.2. No recipient access check
The endpoint was guarded only by
PermissionChecker('notifications', FeatureAccessLevel.ADMIN). That permission says the caller may use the notifications feature, not that they may act on someone else's notification.can_user_access_notification()was already called by mark-as-read (notifications_routes.py:153) and the workflow approval handler (workflows_routes.py:1224) — the strictly more destructive operation had the weaker check.The open question in the issue
This PR lets admins override, and makes it explicit. Reasoning:
get_notificationsalready setscan_delete = Trueon everything an admin can see, and the issue confirms that override is intentional. If DELETE refused unconditionally, an admin's UI would render a delete button that always 403s — the API would report one thing and enforce another.can_delete=Falseforever and no way to respond to it.Adminapp role, is logged at INFO, and is recorded in the audit entry asadmin_override.Non-admins get the strict behaviour, and
is_admindefaults toFalseso any caller that forgets to pass it fails safe. If you'd rather have no override at all, it's a two-line change and I'm happy to make it.Changes
delete_notification(db, notification_id, *, is_admin=False)loads the row first, raisesNotificationNotFoundErrorwhen absent and the newNotificationNotDeletableErrorwhencan_deleteis False and the caller is not an admin. A typed exception rather than aFalsereturn so the route can answer 403 and not 404 — the previousif not deleted: 404could not tell the two apart.can_user_access_notification()before deleting, mirroring mark-as-read, and returns 403 on failure.NotificationsManager.is_app_admin()plus a_role_maps()helper. The role-lookup-and-detect-admin block was duplicated verbatim inget_notificationsandcan_user_access_notification; both now share one implementation, so what the API reports ascan_deleteand what it enforces cannot drift apart. Net −40 lines there.Backend only. The frontend already respects the flag it is served, so no UI change is needed.
Tests
18 new cases in
src/backend/src/tests/unit/test_notification_delete_authz.py, against a real session rather than a mocked repository:can_deleteenforcement, the admin override, and the default-to-non-admin contractis_app_admin— true for the Admin role's groups, false for a normal user, false with no user, false when the Admin role has no assigned groups, and false when the role lookup raises (fails closed rather than promoting on error)delete_notificationis not called at all on the two 403 pathsbackend/src/tests/unit+backend/tests)The delete cases in the quarantined
test_notifications_manager.pyare updated to the new contract. That module stays quarantined — it assigns tomanager._repo.get.return_valuewhere_repois the real repository singleton, so every case in it fails at setup, independently of this change.Unrelated, spotted in passing
NotificationsManager.update_notificationis defined twice (lines 416 and 470); the second shadows the first, and ruff flags itF811. Same failure mode as the tool-module duplicates in #677. Not touched here — happy to file it separately.