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:
- It verifies the token against the Tenant store JWKS (ES256, issuer
<tenant>/auth/v1, audienceauthenticated), and refuses anonymous tokens (401). Supabase's own JWT check is off for this function, because the token is not the Data hub's. - It reads
membershipsin the Tenant store with the user's own token. With no row, or on a failed read, it refuses (403 no_seat). - 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, andapp_metadata.tenant_user_id. A user with that email but another ID or notenant_user_idwas not made by this function, and is refused. - It issues a session with
generateLinkandverifyOtp, ends that session on the server at once (admin.signOut), and returns onlyaccess_tokenandexpires_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.ymldeploys the function with every delivery, from the GitHub environment'sSUPABASE_ACCESS_TOKENandSUPABASE_PROJECT_REF.- This supersedes the anonymous-session decision of #671.