Skip to content

Data hub sessions only for Seat holders, through a session exchange

Status: accepted Date: 2026-09-29 Issue: #916 (spec), #919

Context

The Read contracts are granted to authenticated. Consumers (the sales application in the browser, and the agent service's MCP server) reached that role with an anonymous Data hub session (#671). Anonymous sign-in and email sign-up are both open on the Data hub, and the publishable key is public. Thus anyone could read the Read contracts without a SourceAgent account.

Supabase third-party auth cannot trust another Supabase project's tokens. It accepts only Clerk, Firebase, Auth0, Cognito, and WorkOS (checked 2026-09-29), so the Data hub cannot accept Tenant store tokens directly.

Decision

The Edge Function hub-session exchanges a Tenant store user access token for a Data hub session, for Seat holders only:

  1. It verifies the token against the Tenant store JWKS (ES256, issuer <tenant>/auth/v1, audience authenticated), and refuses anonymous tokens (401). Supabase's own JWT check is off for this function, because the token is not the Data hub's.
  2. It reads memberships in the Tenant store with the user's own token. With no row, or on a failed read, it refuses (403 no_seat).
  3. It creates or reuses the Data hub user linked to the Tenant store user with the admin API. The linked user has the Tenant store user's ID, the email <tenant user id>@tenant-users.sourceagent.invalid, no password, and app_metadata.tenant_user_id. A user with that email but another ID or no tenant_user_id was not made by this function, and is refused.
  4. It issues a session with generateLink and verifyOtp, ends that session on the server at once (admin.signOut), and returns only access_token and expires_at. The access token still reads the Read contracts until it expires, but the Auth API refuses it, so its holder cannot set a password, change the email, or refresh it.

Consumers set that access token as their Data hub session and exchange again before it expires. After both consumers do so, anonymous sign-in and email sign-up go off on both Data hubs, the access token lifetime becomes 600 seconds, and every user without tenant_user_id (anonymous or signed up) is deleted (#920). The Read contracts, their grants, and RLS do not change.

The service role key is used only inside this function, in the Data hub's own project. The consumers never hold it.

Considered options

  • Keep anonymous sessions, add rate limits. It slows bulk reading, but it does not close the path. Rejected.
  • A shared JWT signing key in both projects. It is not documented whether the Data API checks the issuer or trusts a standby key, and it couples the two projects' key management. Rejected.
  • A read gateway with a database role that can only run the Read contracts. It is stricter (no Data hub users), but every read gets one more hop, and table reads must become RPCs. Rejected for now. It stays possible later.
  • Session exchange (chosen). Consumers change only how they get their session, reads keep their speed, and every session belongs to one Tenant store user.

Consequences

  • A user who loses their Seat loses Data hub access when their session expires (600 seconds after #920).
  • Each Data hub session belongs to one Tenant store user, so per-user limits or audit are possible later.
  • The Data hub keeps one linked user per Tenant store user who has read data.
  • A Tenant store token that the user signed out of still exchanges until it expires, because the Tenant store's JWKS check does not see sign-outs. The MCP server has the same limit (agent ADR-0019).
  • _deliver.yml deploys the function with every delivery, from the GitHub environment's SUPABASE_ACCESS_TOKEN and SUPABASE_PROJECT_REF.
  • This supersedes the anonymous-session decision of #671.