Authorization
Authentication establishes who. Authorization establishes which workspace and what within it.
Workspace membership
Every tenant-scoped request resolves a workspace through resolveSubscription(), which verifies a
SubscriptionUser row for (subscriptionId, profileId). Requesting a workspace you are not a member of
returns 403 Forbidden: not a member of this subscription.
Resolution order:
-
x-subscription-idheader — an explicit override, used by MCP and by internal callers -
active_subscription_idcookie — the dashboard’s current workspace -
Default — the workspace the user owns, else their oldest membership
Defaulting to the owned workspace matters for a user who has been added to several: their own is almost always the one they mean.
The cookie is written only by POST /api/subscriptions/active and cleared only by
POST /api/auth/signout. Client code never writes it.
Roles
SubscriptionUser.role is owner, admin or member.
| Action | owner | admin | member |
|---|---|---|---|
Read workspace data |
✓ |
✓ |
✓ |
Create and modify campaigns, targets, agents, templates |
✓ |
✓ |
✓ |
List members |
✓ |
✓ |
✓ |
Add a member |
✓ |
✓ |
— |
Change a member’s role |
✓ |
✓ (not to or from |
— |
Remove a member |
✓ |
✓ (not an owner) |
— |
Assign the |
✓ |
— |
— |
Remove an owner |
✓ |
— |
— |
Roles gate workspace administration, not domain operations. A member can run campaigns and edit
agents; what they cannot do is change who else has access.
Enforced in app/api/subscriptions/members/route.ts and
app/api/subscriptions/members/[membershipId]/route.ts. The rule that only owners may touch ownership
prevents an admin from escalating themselves.
Managed from /workspace; hooks in lib/hooks/use-workspace-members.ts and
lib/hooks/use-subscriptions.ts.
Scoping in queries
Isolation is enforced in application code, not by row-level security. Every repository method that reads
tenant data filters on subscriptionId.
Nullable-scope models need care:
| Model | Why nullable, and what to do |
|---|---|
|
A Nooks call may arrive before a workspace match. Use |
|
Legacy rows predate the column. Scope through |
|
Allows a shared agent across workspaces. Decide deliberately whether a query should include shared agents |
Uniqueness constraints encode the boundary — (subscriptionId, email) on Contact, TargetEmails and
SalesRep; (subscriptionId, name) on Domain; (subscriptionId, externalId) on Activity and
Emails. A new unique constraint should include subscriptionId unless the value is genuinely global,
as TargetPhoneNumbers.phoneNumber and PhoneNumbers.phoneNumber are.
Cross-workspace guards in domain code
Some checks catch data that has drifted across the boundary:
-
The campaign broadcaster refuses a target whose
TargetPhoneNumbersrow belongs to another workspace, marking itskippedwithlastError = "Target phone registry subscription mismatch". -
The
initiate_callMCP tool verifies the target’s campaign belongs to the caller’s workspace before dialing. -
Email campaign routes verify
source.subscriptionId === subscriptionIdbefore duplicating.
Review checklist
-
Did the route call
resolveSubscription()and handlesuccess: false? -
Does every repository
whereincludesubscriptionId? -
For nullable-scope models, is the null case handled deliberately?
-
Does a new unique constraint include
subscriptionId? -
Does a new member-administration path check the role, including the owner-only rules?