Multi-tenant Memberships
Vecton users belong to N tenants through the tenant_user pivot table.
This page captures how membership, role assignment, and tenant
switching behave after F3 / F6 / F7.
Schema
users
id PK
email UNIQUE
is_system_admin (mass-assignment-guarded since F6)
tenant_id (legacy "active tenant" pointer — see Switch section)
tenants
id PK (UUID)
slug
domain
is_active
tenant_user (pivot)
id PK (UUID)
tenant_id FK
user_id FK
role_id FK (nullable, references roles.id)
is_owner boolean
joined_at timestamp
removed_at timestamp (nullable, soft-delete)
removed_by FK users.id
roles
id PK
tenant_id FK (nullable for global / system roles)
is_system boolean
is_active boolean
The audit's "two sources of truth" warning about users.tenant_id vs
tenant_user[] (cross-repo #37) is now neutralized: the JWT carries a
tenants[] array claim listing every active membership, and tenant
services authorize against that. users.tenant_id survives only as a
UI-level "default landing tenant" hint — never as a security boundary.
Active vs membership
| Question | Source |
|---|---|
| Which tenants is the user in? | User::tenants() (tenant_user filtered to removed_at IS NULL) — also serialised into JwtClaimService::getTenantMemberships() |
| Which is the user's preferred default? | users.tenant_id (UI hint only, not a security boundary) |
| Which tenant context does THIS service serve? | config('app.tenant_id') — fixed per deployment |
| What's their role inside tenant T? | tenant_user.role_id for (T, user) |
| Are they the owner of tenant T? | tenant_user.is_owner for (T, user) |
JWT tenants[] claim
JwtClaimService::buildUserClaims() emits two claims to bridge the
single-tenant → multi-tenant migration:
tid— the user's currently-active tenant (users.tenant_id). Legacy. Will be removed once every consumer readstenants[].tenants— every active membership, in the form[{id, is_owner, role_id}, ...].
/api/user (the endpoint tenant-services hit via
IdentityService::getUserFromToken) returns the same tenants[] field
so the consumers that don't decode the JWT locally still see the array.
Tenant-service CheckPermission reads config('app.tenant_id') (the
service's own tenant binding from env), then checks that the caller's
tenants[] claim contains that ID:
$serviceTenantId = config('app.tenant_id');
$userTenants = $user['tenants'] ?? [];
$belongsHere = collect($userTenants)
->contains(fn ($m) => ($m['id'] ?? null) === $serviceTenantId);
// Backwards compat for tokens issued before the dual-claim rollout.
if (empty($userTenants) && ($user['tenant_id'] ?? null) === $serviceTenantId) {
$belongsHere = true;
}
if (!$belongsHere) {
return response()->json(['error' => 'tenant_not_a_member'], 403);
}
tenant-backend-crm/app/Http/Middleware/CheckPermission.php is the
canonical port of this pattern (cross-repo audit #35, #36). The other
six tenant services (warehouse, webshop, webhook, analytics, email,
ws_proxy) follow the same recipe — copy the snippet, drop the legacy
fallback once their oldest in-flight tokens have rotated.
User::isOwnerOf(Tenant $t) short-circuits the role lookup for the
is_owner=true case (audit #91 noted it ran a query per check; F-future
will memoize per-request).
Cross-tenant role-injection guard
F6 added explicit guards in UserRoleController::store against three
audit-flagged misuses (#40, #93, #94):
not_member(422) — the target user must be a member of the tenant supplied in the request body.role_tenant_mismatch(422) — the role being assigned must be either tenant-bound to the target tenant OR a global (system) role.forbidden(403) — global role assignment requires the caller to be a system admin.
Test coverage: tests/Feature/CrossTenantRoleInjectionTest.php.
Last-owner protection
Both UserController::destroy and Admin\TenantMemberController::removeUser
refuse (422 last_owner) to remove the last is_owner=true member of
a tenant. Audit #64 flagged that the previous code happily accepted the
operation, leaving the tenant ownerless and unmanageable.
To remove the only owner, the caller must first set a new owner:
POST /api/admin/tenants/{tenantId}/owner
{ "user_id": 42 }
Tenant switch
POST /api/auth/switch-tenant records the user's preferred default
tenant. Since every tenant service authorizes against the JWT
tenants[] array (not tid), the switch is a UI-only affordance —
no token is reissued, no token is revoked, in-flight requests keep
working.
- Verify the user has an active membership in the target tenant.
- Verify the target tenant is
is_active=true. - Update
users.tenant_id(the SPA / mobile-app reads this on next /me to pre-select the right tenant after login). - Emit
user.tenant_switchedaudit event.
POST /api/auth/switch-tenant
Authorization: Bearer {existing_token}
{ "tenant_id": "uuid-of-target" }
{
"success": true,
"data": {
"tenant": { "id": "...", "name": "...", "slug": "..." }
}
}
The pre-multi-tenant flow (revoke + createToken) was retired here — it forced every authenticated tab to refresh and broke any in-flight request with 401 (cross-repo audit #36).
Test coverage: tests/Feature/Auth/TenantSwitchTest.php.
Tenant lifecycle
Create
POST /api/admin/tenants (system admin only) provisions in one
transaction:
tenantsrow.- Optional owner user (creates
users+tenant_userwithis_owner=true). - Per-tenant OAuth client via
TenantOAuthService::createClientForTenant. - Per-tenant system token via
TenantSystemTokenService::createForTenant. - Default resources via
TenantProvisioningService::provisionDefaults(non-blocking — failures are returned but don't roll back the tenant).
Suspend
PUT /api/admin/tenants/{id} with { "is_active": false }. The
Auth/AuthController::login flow now blocks login for non-system-admin
users in suspended tenants, returning 403 tenant_suspended so the
tenant-frontend can route them to a graceful suspended page.
Delete
DELETE /api/admin/tenants/{id} (F6 cascade revocation, audit #69):
- Revokes every
oauth_access_tokensrow owned by the tenant. - Revokes all matching
oauth_refresh_tokensrows. - Revokes the tenant's OAuth client (
oauth_clients.revoked = true). - Deactivates the tenant's system Application.
- Soft-deletes the
tenantsrow.
This means a tenant slug can be re-used without inheriting the previous incarnation's live tokens.
Membership operations
| Action | Endpoint | Auth |
|---|---|---|
| List my tenants | GET /api/me/tenants | auth:api |
| Switch active tenant | POST /api/auth/switch-tenant | auth:api |
| Add user to tenant (admin) | POST /api/admin/tenants/{id}/users | auth:api + system_admin + admin.tenants.users.manage |
| Remove user (admin) | DELETE /api/admin/tenants/{id}/users/{userId} | same |
| Set tenant owner | POST /api/admin/tenants/{id}/owner | same |
| Update user role | PUT /api/admin/tenants/{id}/users/{userId}/role | same |
| Tenant-level invite | POST /api/users | auth:api + permission:identity.users.create |
Tenant-level invite goes through UserController::store and is the
path tenant owners use to add members (the admin paths require
system-admin). The MembershipPolicy::manage check authorises both
"is the principal a tenant owner" and "does the principal carry
identity.users.create".
Permission resolution per tenant
When a request lands inside a tenant context, CheckPermission
middleware (and after F-future, Gate::authorize) consults
PermissionResolver::resolveForTenant($user, $tenant). That resolver
returns the union of:
- The user's direct policies (
user_policiespivot). - The user's tenant-pivot role permissions (
tenant_user.role_id).
Global roles attached via user_roles are also picked up by
resolveForUser($user) for non-tenant-scoped abilities like
admin.tenants.create.
The resolver supports dotted glob wildcards in the policy action
list, so a policy with identity.users.* grants every
identity.users.X ability. Tested via
tests/Feature/Authorization/IdentityGatesTest::test_glob_wildcard_in_policy_grants_every_matching_ability.