Skip to main content

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

QuestionSource
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 reads tenants[].
  • 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):

  1. not_member (422) — the target user must be a member of the tenant supplied in the request body.
  2. role_tenant_mismatch (422) — the role being assigned must be either tenant-bound to the target tenant OR a global (system) role.
  3. 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.

  1. Verify the user has an active membership in the target tenant.
  2. Verify the target tenant is is_active=true.
  3. Update users.tenant_id (the SPA / mobile-app reads this on next /me to pre-select the right tenant after login).
  4. Emit user.tenant_switched audit 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:

  • tenants row.
  • Optional owner user (creates users + tenant_user with is_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):

  1. Revokes every oauth_access_tokens row owned by the tenant.
  2. Revokes all matching oauth_refresh_tokens rows.
  3. Revokes the tenant's OAuth client (oauth_clients.revoked = true).
  4. Deactivates the tenant's system Application.
  5. Soft-deletes the tenants row.

This means a tenant slug can be re-used without inheriting the previous incarnation's live tokens.

Membership operations

ActionEndpointAuth
List my tenantsGET /api/me/tenantsauth:api
Switch active tenantPOST /api/auth/switch-tenantauth:api
Add user to tenant (admin)POST /api/admin/tenants/{id}/usersauth:api + system_admin + admin.tenants.users.manage
Remove user (admin)DELETE /api/admin/tenants/{id}/users/{userId}same
Set tenant ownerPOST /api/admin/tenants/{id}/ownersame
Update user rolePUT /api/admin/tenants/{id}/users/{userId}/rolesame
Tenant-level invitePOST /api/usersauth: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_policies pivot).
  • 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.