feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
367
backend/app/routers/tenants.py
Normal file
367
backend/app/routers/tenants.py
Normal file
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
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"]),
|
||||
})
|
||||
Reference in New Issue
Block a user