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:

  1. x-subscription-id header — an explicit override, used by MCP and by internal callers

  2. active_subscription_id cookie — the dashboard’s current workspace

  3. 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 owner)

—

Remove a member

✓

✓ (not an owner)

—

Assign the owner role

✓

—

—

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

Call.subscriptionId

A Nooks call may arrive before a workspace match. Use lib/utils/call-subscription-scope.ts to build the correct where fragment

CampaignTarget.subscriptionId

Legacy rows predate the column. Scope through campaignId as well

AiAgent.subscriptionId

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 TargetPhoneNumbers row belongs to another workspace, marking it skipped with lastError = "Target phone registry subscription mismatch".

  • The initiate_call MCP tool verifies the target’s campaign belongs to the caller’s workspace before dialing.

  • Email campaign routes verify source.subscriptionId === subscriptionId before duplicating.

Review checklist

  • Did the route call resolveSubscription() and handle success: false?

  • Does every repository where include subscriptionId?

  • 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?