Tenant Enforcement¶
This page describes how Hardhat Flow enforces the invariant that every non-admin user belongs to exactly one tenant, how request.tenant is resolved, and the /api/me/ contract that the frontend uses at login time.
The Invariant¶
Every User record must satisfy one of two conditions:
| Condition | Allowed |
|---|---|
user.role == platform_admin and user.company_id is None |
✓ Platform Admin — no tenant required |
user.company_id is set (any other role) |
✓ Tenant user |
Any other role with user.company_id == None |
✗ Rejected at authentication |
This is enforced at the DRF authentication layer, not in individual views or serializers.
Authentication Flow¶
Auth0JWTAuthentication (in apps/accounts/authentication.py) handles every API request:
- The Auth0 JWT is verified.
_get_or_create_user(payload)is called:- If the JWT carries no role claim → raises
AuthenticationFailed(code="no_tenant"). - Creates or loads the local
Userrecord with the resolved role. - If
user.role != platform_adminanduser.company_id is None→ raisesAuthenticationFailed(code="no_tenant"). _set_request_tenant(request, user)is called:- Platform Admins: the acting-tenant slug (from
X-Tenant-Slugheader or?acting_tenant=query param) is consulted first. If a valid slug is present,request.tenantis set to that company regardless of whether the admin also has acompany_id. If no slug is present butuser.company_idis set, that company is used. If neither is available,request.tenant = None(admin endpoints that don't need a tenant still work). - All other roles: the user is pinned to
user.companyexclusively. Acting-tenant headers and query params are ignored, preventing cross-tenant access.
The TenantMiddleware is now a cleanup-only wrapper — it clears the thread-local tenant state after every response. It no longer reads the URL slug or performs any tenant lookup.
Error Response Shape¶
When authentication fails because a user has no tenant, the response is:
HTTP 401 Unauthorized
Content-Type: application/json
{
"detail": "Your account is not associated with a company. Please contact your administrator to request access.",
"code": "no_tenant"
}
The frontend branches on code === "no_tenant" to redirect the user to /account-error/ rather than the generic login page.
/api/me/ Endpoint¶
GET /api/me/ returns the authenticated user's profile. This endpoint requires authentication but does not require a tenant — it works for both Platform Admins and tenant users.
Response (200 OK):
{
"id": "uuid",
"email": "[email protected]",
"role": "owner",
"is_admin": false,
"company": {
"id": "uuid",
"slug": "acme-construction",
"name": "Acme Construction LLC"
}
}
For Platform Admins, company is normally null and is_admin is true.
If a Platform Admin sends X-Tenant-Slug: {slug} on the request (and the slug matches a non–soft-deleted company), authentication configures that tenant’s database for the request and GET /api/me/ returns that company in the company object so the UI matches the active tenant. The same slug may be passed as the query parameter acting_tenant (for example GET /api/v1/bids/?acting_tenant=acme); the header wins when both are present. Local Next.js dev rewrites may not forward custom headers to Django, so the app uses acting_tenant there as a fallback. An unknown or deleted slug yields 401 with code: "unknown_tenant_slug".
Acting-tenant slug takes precedence over the admin’s own company_id. This means the "Open as Tenant" flow works correctly even when the Platform Admin user has a company_id set on their row — the slug provided in the request always wins for Platform Admins. Non-admin users never honor this header or query parameter; their tenant always comes from user.company_id.
If the user has no tenant (and is not a Platform Admin), the auth class rejects the request before the view runs — the response is the 401 {"code": "no_tenant"} described above, not a 200 with company: null.
Frontend Post-Login Flow¶
After Auth0 redirects back to the app at /post-login/:
- The
useCurrentUserhook fetchesGET /api/me/. - On success:
is_admin: true→ redirect to/platform-admin/tenants/- otherwise → redirect to
/dashboard/ - On
401withcode === "no_tenant"→ redirect to/account-error/ - On any other error → redirect to
/login/
The /account-error/ page explains that the account is not yet associated with a company and provides a Sign out button.
The app layout (frontend/app/(app)/layout.tsx) also watches the useCurrentUser error and redirects to /account-error/ if code === "no_tenant" on any subsequent navigation — guarding against token refresh after an account is deprovisioned mid-session.
Existing Users Without a Tenant¶
Users whose records exist in the DB with company_id = NULL (and whose role is not platform_admin) will receive the no_tenant error on their next login. They land on /account-error/ and cannot access any tenant API endpoints until a Platform Admin assigns them to a company.
No data migration is performed automatically. If you want existing tenantless users to be visible to admins, create AccessRequest records for them manually or assign a company through the Django admin console at /django-admin/.