Skip to content

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:

  1. The Auth0 JWT is verified.
  2. _get_or_create_user(payload) is called:
  3. If the JWT carries no role claim → raises AuthenticationFailed(code="no_tenant").
  4. Creates or loads the local User record with the resolved role.
  5. If user.role != platform_admin and user.company_id is None → raises AuthenticationFailed(code="no_tenant").
  6. _set_request_tenant(request, user) is called:
  7. Platform Admins: the acting-tenant slug (from X-Tenant-Slug header or ?acting_tenant= query param) is consulted first. If a valid slug is present, request.tenant is set to that company regardless of whether the admin also has a company_id. If no slug is present but user.company_id is set, that company is used. If neither is available, request.tenant = None (admin endpoints that don't need a tenant still work).
  8. All other roles: the user is pinned to user.company exclusively. 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/:

  1. The useCurrentUser hook fetches GET /api/me/.
  2. On success:
  3. is_admin: true → redirect to /platform-admin/tenants/
  4. otherwise → redirect to /dashboard/
  5. On 401 with code === "no_tenant" → redirect to /account-error/
  6. 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/.