Skip to main content

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

ParamRequiredDescription
team_idoptionalResolve team-scoped access + perm keys
campaign_idrequires team_idResolve 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-js functions.invoke("get-permissions-snapshot", ...).
  • Wraps the call in unstable_cache:
    • Key: ["permissions-snapshot", userId, teamId ?? "", campaignId ?? ""]
    • Revalidate: 3600 seconds
    • Tags: [\permissions-${userId}`]`

fetchPermissionsSnapshot (Edge-safe / Middleware)

src/lib/permissions/fetchPermissionsSnapshot.ts

  • Uses native fetch against $\{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:

  1. POST /api/permissions/revalidate — invalidates the snapshot for the current user (read from the session). Called by:
    • updateSession on real browser refresh (F5 / Ctrl+Shift+R).
    • src/components/common/refresh-permissions-button.tsx on the access-denied screens.
  2. 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