anon and authenticated API roles hold no
privileges on public.notifications. The row-level policies are kept as defence
in depth.
Who sees a notification
- A notification with a recipient account (
user_id) is visible only to that account. Email is never matched on such rows, so later address changes or reuse of the address by another account do not expose it. New and updated rows do not keep a copy of the recipient’s address; copies stored before this rule are inert and left for a separate batched cleanup. - A row is addressed by email only while it is an unclaimed workspace
invitation (
type = 'workspace_invite'with nouser_id). It is shown to the account whose confirmed address matches. Unconfirmed addresses never match. - Addresses are stored trimmed and lowercased, and compared the same way.
Claiming invitations
An unclaimed invitation is assigned to an account when:- the account is created with an already-confirmed address (for example OAuth),
- the account confirms its address, or
- a confirmed account changes to a new confirmed address.
sync_my_notifications() applies the same rule on demand
for the caller’s confirmed address.
Account deletion
Deleting apublic.users row deletes that account’s notifications instead of
leaving them without a recipient. The rollout migration removed rows left behind
by deleted accounts: recipient-less rows that were not invitations, and
recipient-less invitations with no pending email invite for that workspace and
address.
Scope and exceptions
- Applies to every app that shows the notification inbox: web, satellites through the shared notification popover, and mobile through the API.
- The account email helper used by these checks,
public.get_current_user_email(), resolves only the caller’s own confirmed address. - Delivery (email and push batches) is unchanged; batch rows keep the delivery address they need.
- Notifications are no longer published to Supabase Realtime; clients refresh through the API.
apps/database/supabase/tests/security-notification-email-access.sql
and apps/database/supabase/tests/security-user-email-helper.sql, run by the
security database contract job (apps/database/scripts/verify-security-egress-contract.mjs).