Tenants Module

Complete

Overview

Multi-tenancy is the architectural foundation of ThreatOps SOCaaS. Every alert, incident, detection rule, SLA record, and user is scoped to a tenant. Without a robust tenant management layer, a platform operator cannot onboard new customers, control their tier and SIEM configuration, or enforce isolation between organizations.

The Tenants module provides the data layer and API for creating, listing, reading, and updating tenant records. It enforces role-based access control — super admins see all tenants, while standard users see only their own tenant. A graceful fallback to realistic demo data ensures the frontend remains functional even when the database is unavailable. Tenant creation is integrated with the onboarding flow, allowing new tenants to be added directly from the UI.

What Was Proposed

What's Built

List tenants endpoint (RBAC: super_admin sees all, others see own)✓ Complete
Get tenant by ID endpoint (403 for cross-tenant access)✓ Complete
Create tenant endpoint (POST, 201)✓ Complete
Update tenant endpoint (PATCH, partial update)✓ Complete
Demo data fallback (3 realistic tenants when DB unavailable)✓ Complete
Tenant model (PostgreSQL table via SQLAlchemy async)✓ Complete
TenantTier enum (standard/premium/enterprise)✓ Complete
TenantStatus enum (onboarding/active/suspended/churned)✓ Complete
SIEMType enum (sentinel/splunk/elastic/chronicle/qradar)✓ Complete
JSON settings field (auto_triage, SLA targets, MFA enforcement)✓ Complete
Frontend table with tier badges, status badges, endpoint count, onboarding progress bar✓ Complete
Frontend row click navigates to /onboarding?tenant={slug}✓ Complete
Frontend "Add Tenant" button routes to /onboarding✓ Complete
Tenant middleware sets request.state.tenant_id for all routes✓ Complete

Architecture

Backend Router

File: app/routers/tenants.py — Prefix: /api/v1/tenants

The router uses async SQLAlchemy with AsyncSession injected via Depends(get_db). All queries use select(Tenant) with await db.execute(). A _demo_tenants() helper returns three pre-configured realistic tenants (Acme Financial Corp, GlobalHealth Systems, TechStart Inc) as a JSONResponse when any database exception is raised, allowing the frontend to function during development or database outages.

Role-Based Access Control

The list endpoint checks request.state.user_role (injected by the tenant middleware) against the value "super_admin". Super admins receive an unfiltered query over all tenant rows. All other roles receive a query filtered to Tenant.id == request.state.tenant_id, ensuring strict tenant isolation. The get-by-ID endpoint enforces the same check with an explicit 403 response rather than filtering.

Data Flow

Browser (Next.js)
  --> GET /api/v1/tenants/
  --> TenantMiddleware sets request.state.tenant_id + user_role
  --> tenants router: if user_role != "super_admin": filter by tenant_id
  --> SQLAlchemy async query against PostgreSQL `tenants` table
  --> On DB error: return _demo_tenants() as JSONResponse
  --> Frontend renders table with tier/status badges + progress bars

Tenant Middleware

File: app/middleware/tenant.py — Inspects the JWT bearer token on every request and sets request.state.tenant_id and request.state.user_role. These values are consumed by the tenants router, alerts router, incidents router, and all other tenant-scoped modules.

Frontend Page

File: src/app/tenants/page.tsx — Client component that calls getTenants() from src/lib/api.ts on mount. Falls back silently to five hardcoded demo tenants (California Dept of Technology, Acme Corp, City of Merced, Fresno COE, San Mateo County) if the API call throws. The page uses Next.js useRouter for programmatic navigation to /onboarding?tenant={slug} on row click and to /onboarding on the Add Tenant button.

API Endpoints

GET    /api/v1/tenants/
       # List tenants. super_admin sees all; others see only their own tenant.
       # Query params: skip=0 (int), limit=50 (int)
       # Returns: list[TenantResponse]
       # Fallback: JSONResponse with 3 demo tenants on DB error

GET    /api/v1/tenants/{tenant_id}
       # Get a single tenant by ID.
       # 403 if non-super-admin requests a different tenant's record.
       # 404 if tenant does not exist.
       # Returns: TenantResponse

POST   /api/v1/tenants/
       # Create a new tenant.
       # Body: TenantCreate { name, slug, tier?, primary_siem, status?, settings?, ... }
       # Returns: TenantResponse (HTTP 201)

PATCH  /api/v1/tenants/{tenant_id}
       # Partial update of a tenant record.
       # Body: TenantUpdate { any subset of Tenant fields }
       # 404 if tenant does not exist.
       # Returns: TenantResponse

Routing

LayerPathDescription
/tenantsFrontend route (Next.js App Router)Tenant management table page
/api/v1/tenantsAPI prefix (FastAPI router)All tenant CRUD endpoints
/onboardingFrontend route (Next.js App Router)Add Tenant / tenant detail — linked from tenants page

Data Model

Model: app/models/tenant.py — Table: tenants (SQLAlchemy declarative base)

FieldTypeDescription
idString(36) PKUUID primary key, auto-generated
nameString(255) NOT NULLFull organization display name (e.g. "Acme Financial Corp")
slugString(100) UNIQUE NOT NULLURL-safe identifier used in onboarding URLs (e.g. "acme-financial")
tierEnum(TenantTier)standard / premium / enterprise. Default: standard
primary_siemEnum(SIEMType)sentinel / splunk / elastic / chronicle / qradar
statusEnum(TenantStatus)onboarding / active / suspended / churned. Default: onboarding
settingsJSONFreeform tenant settings: auto_triage (bool), sla_critical_minutes (int), mfa_enforced (bool), etc.
subscription_startDateTime (TZ)Contract start date (nullable)
subscription_endDateTime (TZ)Contract end date (nullable)
endpoint_countintNumber of monitored endpoints for this tenant. Default: 0
created_atDateTime (TZ)Row creation timestamp (server default: now())
updated_atDateTime (TZ)Last modification timestamp (auto-updated on write)

Relationships

Demo Data (returned on DB failure)

TenantTierSIEMStatusEndpoints
Acme Financial Corpenterprisesentinelactive12,500
GlobalHealth Systemsstandardsplunkactive3,200
TechStart Incstartupcrowdstrikeactive450

Frontend Fallback Data

The frontend also has its own hardcoded fallback array displayed when the API call fails. This is a separate list from the backend demo data:

TenantTierSIEMStatusOnboarding %
California Dept of Technologyenterprisesentinelactive100%
Acme Corppremiumsentinelactive100%
City of Mercedstandardsentinelactive100%
Fresno COEstandardsplunkactive85%
San Mateo Countypremiumsentinelonboarding60%

Prerequisites

UI Layout

Page Structure

ColumnContentStyling
NameOrganization name (medium weight) + slug below (small, gray)Two-line cell
SIEMPrimary SIEM type (capitalized)slate-700
TierTier name as a pill badgestandard=gray, premium=orange, enterprise=amber
StatusStatus as a pill badgeactive=green, onboarding=amber, suspended=red
EndpointsEndpoint count (locale-formatted, right-aligned)slate-700
OnboardingProgress bar + percentage labelOrange fill bar on slate-200 track, 8px height

Each row is clickable (cursor-pointer, hover:bg-slate-50) and navigates to /onboarding?tenant={slug ?? id} using Next.js router.