""" Tenant management endpoints. GET /api/tenants — list tenants (super_admin: all; tenant_admin: own only) POST /api/tenants — create tenant (super_admin only) GET /api/tenants/{id} — get tenant detail PUT /api/tenants/{id} — update tenant (super_admin only) DELETE /api/tenants/{id} — delete tenant (super_admin only) """ import uuid from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy import func, select, text from sqlalchemy.ext.asyncio import AsyncSession from app.middleware.rate_limit import limiter from app.database import get_admin_db, get_db from app.middleware.rbac import require_super_admin, require_tenant_admin_or_above from app.middleware.tenant_context import CurrentUser from app.models.device import Device from app.models.tenant import Tenant from app.models.user import User from app.schemas.tenant import TenantCreate, TenantResponse, TenantUpdate router = APIRouter(prefix="/tenants", tags=["tenants"]) async def _get_tenant_response( tenant: Tenant, db: AsyncSession, ) -> TenantResponse: """Build a TenantResponse with user and device counts.""" user_count_result = await db.execute( select(func.count(User.id)).where(User.tenant_id == tenant.id) ) user_count = user_count_result.scalar_one() or 0 device_count_result = await db.execute( select(func.count(Device.id)).where(Device.tenant_id == tenant.id) ) device_count = device_count_result.scalar_one() or 0 return TenantResponse( id=tenant.id, name=tenant.name, description=tenant.description, contact_email=tenant.contact_email, user_count=user_count, device_count=device_count, created_at=tenant.created_at, ) @router.get("", response_model=list[TenantResponse], summary="List tenants") async def list_tenants( current_user: CurrentUser = Depends(require_tenant_admin_or_above), db: AsyncSession = Depends(get_admin_db), ) -> list[TenantResponse]: """ List tenants. - super_admin: sees all tenants - tenant_admin: sees only their own tenant """ if current_user.is_super_admin: result = await db.execute(select(Tenant).order_by(Tenant.name)) tenants = result.scalars().all() else: if not current_user.tenant_id: return [] result = await db.execute( select(Tenant).where(Tenant.id == current_user.tenant_id) ) tenants = result.scalars().all() return [await _get_tenant_response(tenant, db) for tenant in tenants] @router.post("", response_model=TenantResponse, status_code=status.HTTP_201_CREATED, summary="Create a tenant") @limiter.limit("20/minute") async def create_tenant( request: Request, data: TenantCreate, current_user: CurrentUser = Depends(require_super_admin), db: AsyncSession = Depends(get_admin_db), ) -> TenantResponse: """Create a new tenant (super_admin only).""" # Check for name uniqueness existing = await db.execute(select(Tenant).where(Tenant.name == data.name)) if existing.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Tenant with name '{data.name}' already exists", ) tenant = Tenant(name=data.name, description=data.description, contact_email=data.contact_email) db.add(tenant) await db.commit() await db.refresh(tenant) # Seed default alert rules for new tenant default_rules = [ ("High CPU Usage", "cpu_load", "gt", 90, 5, "warning"), ("High Memory Usage", "memory_used_pct", "gt", 90, 5, "warning"), ("High Disk Usage", "disk_used_pct", "gt", 85, 3, "warning"), ("Device Offline", "device_offline", "eq", 1, 1, "critical"), ] for name, metric, operator, threshold, duration, sev in default_rules: await db.execute(text(""" INSERT INTO alert_rules (id, tenant_id, name, metric, operator, threshold, duration_polls, severity, enabled, is_default) VALUES (gen_random_uuid(), CAST(:tenant_id AS uuid), :name, :metric, :operator, :threshold, :duration, :severity, TRUE, TRUE) """), { "tenant_id": str(tenant.id), "name": name, "metric": metric, "operator": operator, "threshold": threshold, "duration": duration, "severity": sev, }) await db.commit() # Seed starter config templates for new tenant await _seed_starter_templates(db, tenant.id) await db.commit() # Provision OpenBao Transit key for the new tenant (non-blocking) try: from app.config import settings from app.services.key_service import provision_tenant_key if settings.OPENBAO_ADDR: await provision_tenant_key(db, tenant.id) await db.commit() except Exception as exc: import logging logging.getLogger(__name__).warning( "OpenBao key provisioning failed for tenant %s (will be provisioned on next startup): %s", tenant.id, exc, ) return await _get_tenant_response(tenant, db) @router.get("/{tenant_id}", response_model=TenantResponse, summary="Get tenant detail") async def get_tenant( tenant_id: uuid.UUID, current_user: CurrentUser = Depends(require_tenant_admin_or_above), db: AsyncSession = Depends(get_admin_db), ) -> TenantResponse: """Get tenant detail. Tenant admins can only view their own tenant.""" # Enforce tenant_admin can only see their own tenant if not current_user.is_super_admin and current_user.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this tenant", ) result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) tenant = result.scalar_one_or_none() if not tenant: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found", ) return await _get_tenant_response(tenant, db) @router.put("/{tenant_id}", response_model=TenantResponse, summary="Update a tenant") @limiter.limit("20/minute") async def update_tenant( request: Request, tenant_id: uuid.UUID, data: TenantUpdate, current_user: CurrentUser = Depends(require_super_admin), db: AsyncSession = Depends(get_admin_db), ) -> TenantResponse: """Update tenant (super_admin only).""" result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) tenant = result.scalar_one_or_none() if not tenant: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found", ) if data.name is not None: # Check name uniqueness name_check = await db.execute( select(Tenant).where(Tenant.name == data.name, Tenant.id != tenant_id) ) if name_check.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Tenant with name '{data.name}' already exists", ) tenant.name = data.name if data.description is not None: tenant.description = data.description if data.contact_email is not None: tenant.contact_email = data.contact_email await db.commit() await db.refresh(tenant) return await _get_tenant_response(tenant, db) @router.delete("/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a tenant") @limiter.limit("5/minute") async def delete_tenant( request: Request, tenant_id: uuid.UUID, current_user: CurrentUser = Depends(require_super_admin), db: AsyncSession = Depends(get_admin_db), ) -> None: """Delete tenant (super_admin only). Cascades to all users and devices.""" result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) tenant = result.scalar_one_or_none() if not tenant: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found", ) await db.delete(tenant) await db.commit() # --------------------------------------------------------------------------- # Starter template seeding # --------------------------------------------------------------------------- _STARTER_TEMPLATES = [ { "name": "Basic Router", "description": "Complete SOHO/branch router setup: WAN on ether1, LAN bridge, DHCP, DNS, NAT, basic firewall", "content": """/interface bridge add name=bridge-lan comment="LAN bridge" /interface bridge port add bridge=bridge-lan interface=ether2 /interface bridge port add bridge=bridge-lan interface=ether3 /interface bridge port add bridge=bridge-lan interface=ether4 /interface bridge port add bridge=bridge-lan interface=ether5 # WAN — DHCP client on ether1 /ip dhcp-client add interface={{ wan_interface }} disabled=no comment="WAN uplink" # LAN address /ip address add address={{ lan_gateway }}/{{ lan_cidr }} interface=bridge-lan # DNS /ip dns set servers={{ dns_servers }} allow-remote-requests=yes # DHCP server for LAN /ip pool add name=lan-pool ranges={{ dhcp_start }}-{{ dhcp_end }} /ip dhcp-server network add address={{ lan_network }}/{{ lan_cidr }} gateway={{ lan_gateway }} dns-server={{ lan_gateway }} /ip dhcp-server add name=lan-dhcp interface=bridge-lan address-pool=lan-pool disabled=no # NAT masquerade /ip firewall nat add chain=srcnat out-interface={{ wan_interface }} action=masquerade # Firewall — input chain /ip firewall filter add chain=input connection-state=established,related action=accept add chain=input connection-state=invalid action=drop add chain=input in-interface={{ wan_interface }} action=drop comment="Drop all other WAN input" # Firewall — forward chain add chain=forward connection-state=established,related action=accept add chain=forward connection-state=invalid action=drop add chain=forward in-interface=bridge-lan out-interface={{ wan_interface }} action=accept comment="Allow LAN to WAN" add chain=forward action=drop comment="Drop everything else" # NTP /system ntp client set enabled=yes servers={{ ntp_server }} # Identity /system identity set name={{ device.hostname }}""", "variables": [ {"name": "wan_interface", "type": "string", "default": "ether1", "description": "WAN-facing interface"}, {"name": "lan_gateway", "type": "ip", "default": "192.168.88.1", "description": "LAN gateway IP"}, {"name": "lan_cidr", "type": "integer", "default": "24", "description": "LAN subnet mask bits"}, {"name": "lan_network", "type": "ip", "default": "192.168.88.0", "description": "LAN network address"}, {"name": "dhcp_start", "type": "ip", "default": "192.168.88.100", "description": "DHCP pool start"}, {"name": "dhcp_end", "type": "ip", "default": "192.168.88.254", "description": "DHCP pool end"}, {"name": "dns_servers", "type": "string", "default": "8.8.8.8,8.8.4.4", "description": "Upstream DNS servers"}, {"name": "ntp_server", "type": "string", "default": "pool.ntp.org", "description": "NTP server"}, ], }, { "name": "Basic Firewall", "description": "Standard firewall ruleset with WAN protection and LAN forwarding", "content": """/ip firewall filter add chain=input connection-state=established,related action=accept add chain=input connection-state=invalid action=drop add chain=input in-interface={{ wan_interface }} protocol=tcp dst-port=8291 action=drop comment="Block Winbox from WAN" add chain=input in-interface={{ wan_interface }} protocol=tcp dst-port=22 action=drop comment="Block SSH from WAN" add chain=forward connection-state=established,related action=accept add chain=forward connection-state=invalid action=drop add chain=forward src-address={{ allowed_network }} action=accept add chain=forward action=drop""", "variables": [ {"name": "wan_interface", "type": "string", "default": "ether1", "description": "WAN-facing interface"}, {"name": "allowed_network", "type": "subnet", "default": "192.168.88.0/24", "description": "Allowed source network"}, ], }, { "name": "DHCP Server Setup", "description": "Configure DHCP server with address pool, DNS, and gateway", "content": """/ip pool add name=dhcp-pool ranges={{ pool_start }}-{{ pool_end }} /ip dhcp-server network add address={{ gateway }}/24 gateway={{ gateway }} dns-server={{ dns_server }} /ip dhcp-server add name=dhcp1 interface={{ interface }} address-pool=dhcp-pool disabled=no""", "variables": [ {"name": "pool_start", "type": "ip", "default": "192.168.88.100", "description": "DHCP pool start address"}, {"name": "pool_end", "type": "ip", "default": "192.168.88.254", "description": "DHCP pool end address"}, {"name": "gateway", "type": "ip", "default": "192.168.88.1", "description": "Default gateway"}, {"name": "dns_server", "type": "ip", "default": "8.8.8.8", "description": "DNS server address"}, {"name": "interface", "type": "string", "default": "bridge-lan", "description": "Interface to serve DHCP on"}, ], }, { "name": "Wireless AP Config", "description": "Configure wireless access point with WPA2 security", "content": """/interface wireless security-profiles add name=portal-wpa2 mode=dynamic-keys authentication-types=wpa2-psk wpa2-pre-shared-key={{ password }} /interface wireless set wlan1 mode=ap-bridge ssid={{ ssid }} security-profile=portal-wpa2 frequency={{ frequency }} channel-width={{ channel_width }} disabled=no""", "variables": [ {"name": "ssid", "type": "string", "default": "MikroTik-AP", "description": "Wireless network name"}, {"name": "password", "type": "string", "default": "", "description": "WPA2 pre-shared key (min 8 characters)"}, {"name": "frequency", "type": "integer", "default": "2412", "description": "Wireless frequency in MHz"}, {"name": "channel_width", "type": "string", "default": "20/40mhz-XX", "description": "Channel width setting"}, ], }, { "name": "Initial Device Setup", "description": "Set device identity, NTP, DNS, and disable unused services", "content": """/system identity set name={{ device.hostname }} /system ntp client set enabled=yes servers={{ ntp_server }} /ip dns set servers={{ dns_servers }} allow-remote-requests=no /ip service disable telnet,ftp,www,api-ssl /ip service set ssh port=22 /ip service set winbox port=8291""", "variables": [ {"name": "ntp_server", "type": "ip", "default": "pool.ntp.org", "description": "NTP server address"}, {"name": "dns_servers", "type": "string", "default": "8.8.8.8,8.8.4.4", "description": "Comma-separated DNS servers"}, ], }, ] async def _seed_starter_templates(db, tenant_id) -> None: """Insert starter config templates for a newly created tenant.""" import json as _json for tmpl in _STARTER_TEMPLATES: await db.execute(text(""" INSERT INTO config_templates (id, tenant_id, name, description, content, variables) VALUES (gen_random_uuid(), CAST(:tid AS uuid), :name, :desc, :content, CAST(:vars AS jsonb)) """), { "tid": str(tenant_id), "name": tmpl["name"], "desc": tmpl["description"], "content": tmpl["content"], "vars": _json.dumps(tmpl["variables"]), })