Permissions Snapshot
The "permissions snapshot" is the unified data structure that drives every access-control decision in the app. It replaces the old per-request session enrichment and the legacy get_user_combined_permissions SQL helper.
The shape
type PermissionsSnapshot = {
teamAccess: boolean;
campaignAccess?: boolean; // present when campaign_id is queried
permissionKeys?: string[]; // union of team + campaign permission keys
};
Defined in src/lib/permissions/fetchPermissionsSnapshot.ts.
Producer: get-permissions-snapshot Edge Function
Location: supabase/functions/get-permissions-snapshot/index.ts
A stateless Deno function. Method: GET. Auth: Authorization: Bearer <user access token>.
Query parameters
| Param | Required | Description |
|---|---|---|
team_id | optional | Resolve team-scoped access + perm keys |
campaign_id | requires team_id | Resolve campaign-scoped access + perm keys |
Resolution rules
The function is intentionally inline — there is no SQL helper to chase. It uses two clients:
- A user-scoped anon client (for
getUser()JWT verification). - A service-role admin client (for the actual permission queries).
no team_id given:
teamAccess = (count of `team_users` rows where user=user_id AND status='active') > 0
permissionKeys = []
team_id given:
teamMembership = team_users where (user, team, status='active') with role.scope joined
teamAccess = membership exists
isTeamAdmin = membership.role.scope === 'inherit_team'
if isTeamAdmin → team_p_sets[team] (team-wide bundles)
else → role_p_sets[role_id] (user's own role bundles)
→ resolve to perm_keys via permission_set_keys
campaign_id (also requires team_id):
allowedCampaigns = union(
campaign_user_roles_team[user, team].campaign,
campaign_user_team_sets[user, team].campaign,
isTeamAdmin ? campaign_team[team].campaign : ∅
)
campaignAccess = campaign_id in allowedCampaigns
if isTeamAdmin → campaign_team_sets[team, campaign]
else → role_p_sets[campaign_user_roles_team[user, team, campaign].role]
+ campaign_user_team_sets[user, team, campaign]
→ resolve to perm_keys
permissionKeys = unique(team perm keys ∪ campaign perm keys)
The function always returns Cache-Control: no-store — caching is the caller's responsibility.
Consumers
There are two callers, depending on whether unstable_cache is available:
getRoutePermissions (RSC / Layouts / Pages / Server Actions)
src/services/accessCheck/getRoutePermissions.ts
- Uses
supabase-jsfunctions.invoke("get-permissions-snapshot", ...). - Wraps the call in
unstable_cache:- Key:
["permissions-snapshot", userId, teamId ?? "", campaignId ?? ""] - Revalidate:
3600seconds - Tags:
[\permissions-${userId}`]`
- Key:
fetchPermissionsSnapshot (Edge-safe / Middleware)
src/lib/permissions/fetchPermissionsSnapshot.ts
-
Uses native
fetchagainst$\{NEXT_PUBLIC_SUPABASE_URL\}/functions/v1/get-permissions-snapshot. -
Uses Next.js fetch-cache tags:
fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
next: { revalidate: 3600, tags: [`permissions-${userId}`] },
}); -
Currently kept as the edge-safe path for any future use (e.g. inside an Edge route handler). The active middleware does not call it on every request — it only invalidates on hard refresh.
Invalidation
Anything that changes a user's effective permissions must invalidate the cache. The single invalidation primitive is:
revalidateTag(`permissions-${userId}`);
There are two centralized invalidation entry points:
POST /api/permissions/revalidate— invalidates the snapshot for the current user (read from the session). Called by:updateSessionon real browser refresh (F5 / Ctrl+Shift+R).src/components/common/refresh-permissions-button.tsxon the access-denied screens.
- Server actions that change membership / role / permission-set assignments — these should call
revalidateTag(\permissions-${userId}`)` directly for the affected user(s).
The userId is never taken from the request body in the API route, so a client cannot invalidate another user's cache.
End-to-end flow
Browser → request /[team]/campaign/[slug]/petitions
↓
Edge middleware: updateSession
- refreshes Supabase cookies (getClaims)
- if hard refresh → POST /api/permissions/revalidate
↓
Server: (campaign-layout)/layout.tsx
- getRoutePermissions({ teamId, campaignId }) ← unstable_cache hit if warm
└─ supabase.functions.invoke("get-permissions-snapshot", ...)
- !teamAccess → redirect("/")
- !campaignAccess → redirect(`/${team}/campaign/no-access`)
↓
Server: page.tsx
- requireAccess({ teamId, campaignId, key: "campaign-petitions-page" })
- render
↓
Client: TeamsContext.permissionKeys drives sidebar / button visibility