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:
Jason Staack
2026-03-08 17:46:37 -05:00
commit b840047e19
511 changed files with 106948 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
"""Pydantic schemas for request/response validation."""
from app.schemas.auth import LoginRequest, TokenResponse, RefreshRequest, UserMeResponse
from app.schemas.tenant import TenantCreate, TenantResponse, TenantUpdate
from app.schemas.user import UserCreate, UserResponse, UserUpdate
__all__ = [
"LoginRequest",
"TokenResponse",
"RefreshRequest",
"UserMeResponse",
"TenantCreate",
"TenantResponse",
"TenantUpdate",
"UserCreate",
"UserResponse",
"UserUpdate",
]

123
backend/app/schemas/auth.py Normal file
View File

@@ -0,0 +1,123 @@
"""Authentication request/response schemas."""
import uuid
from typing import Optional
from pydantic import BaseModel, EmailStr
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
auth_upgrade_required: bool = False # True when bcrypt user needs SRP registration
class RefreshRequest(BaseModel):
refresh_token: str
class UserMeResponse(BaseModel):
id: uuid.UUID
email: str
name: str
role: str
tenant_id: Optional[uuid.UUID] = None
auth_version: int = 1
model_config = {"from_attributes": True}
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
# SRP users must provide re-derived credentials
new_srp_salt: Optional[str] = None
new_srp_verifier: Optional[str] = None
# Re-wrapped key bundle (SRP users re-encrypt with new AUK)
encrypted_private_key: Optional[str] = None
private_key_nonce: Optional[str] = None
encrypted_vault_key: Optional[str] = None
vault_key_nonce: Optional[str] = None
public_key: Optional[str] = None
pbkdf2_salt: Optional[str] = None
hkdf_salt: Optional[str] = None
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
new_password: str
class MessageResponse(BaseModel):
message: str
# --- SRP Zero-Knowledge Authentication Schemas ---
class SRPInitRequest(BaseModel):
"""Step 1 request: client sends email to begin SRP handshake."""
email: EmailStr
class SRPInitResponse(BaseModel):
"""Step 1 response: server returns ephemeral B and key derivation salts."""
salt: str # hex-encoded SRP salt
server_public: str # hex-encoded server ephemeral B
session_id: str # Redis session key nonce
pbkdf2_salt: str # base64-encoded, from user_key_sets (needed for 2SKD before SRP verify)
hkdf_salt: str # base64-encoded, from user_key_sets (needed for 2SKD before SRP verify)
class SRPVerifyRequest(BaseModel):
"""Step 2 request: client sends proof M1 to complete handshake."""
email: EmailStr
session_id: str
client_public: str # hex-encoded client ephemeral A
client_proof: str # hex-encoded client proof M1
class SRPVerifyResponse(BaseModel):
"""Step 2 response: server returns tokens and proof M2."""
access_token: str
refresh_token: str
token_type: str = "bearer"
server_proof: str # hex-encoded server proof M2
encrypted_key_set: Optional[dict] = None # Key bundle for client-side decryption
class SRPRegisterRequest(BaseModel):
"""Used during registration to store SRP verifier and key set."""
srp_salt: str # hex-encoded
srp_verifier: str # hex-encoded
encrypted_private_key: str # base64-encoded
private_key_nonce: str # base64-encoded
encrypted_vault_key: str # base64-encoded
vault_key_nonce: str # base64-encoded
public_key: str # base64-encoded
pbkdf2_salt: str # base64-encoded
hkdf_salt: str # base64-encoded
# --- Account Self-Service Schemas ---
class DeleteAccountRequest(BaseModel):
"""Request body for account self-deletion. User must type 'DELETE' to confirm."""
confirmation: str # Must be "DELETE" to confirm
class DeleteAccountResponse(BaseModel):
"""Response after successful account deletion."""
message: str
deleted: bool

View File

@@ -0,0 +1,78 @@
"""Pydantic request/response schemas for the Internal Certificate Authority."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict
# ---------------------------------------------------------------------------
# Request schemas
# ---------------------------------------------------------------------------
class CACreateRequest(BaseModel):
"""Request to generate a new root CA for the tenant."""
common_name: str = "Portal Root CA"
validity_years: int = 10 # Default 10 years for CA
class CertSignRequest(BaseModel):
"""Request to sign a per-device certificate using the tenant CA."""
device_id: UUID
validity_days: int = 730 # Default 2 years for device certs
class BulkCertDeployRequest(BaseModel):
"""Request to deploy certificates to multiple devices."""
device_ids: list[UUID]
# ---------------------------------------------------------------------------
# Response schemas
# ---------------------------------------------------------------------------
class CAResponse(BaseModel):
"""Public details of a tenant's Certificate Authority (no private key)."""
id: UUID
tenant_id: UUID
common_name: str
fingerprint_sha256: str
serial_number: str
not_valid_before: datetime
not_valid_after: datetime
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class DeviceCertResponse(BaseModel):
"""Public details of a device certificate (no private key)."""
id: UUID
tenant_id: UUID
device_id: UUID
ca_id: UUID
common_name: str
fingerprint_sha256: str
serial_number: str
not_valid_before: datetime
not_valid_after: datetime
status: str
deployed_at: datetime | None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class CertDeployResponse(BaseModel):
"""Result of a single device certificate deployment attempt."""
success: bool
device_id: UUID
cert_name_on_device: str | None = None
error: str | None = None

View File

@@ -0,0 +1,271 @@
"""Pydantic schemas for Device, DeviceGroup, and DeviceTag endpoints."""
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, field_validator
# ---------------------------------------------------------------------------
# Device schemas
# ---------------------------------------------------------------------------
class DeviceCreate(BaseModel):
"""Schema for creating a new device."""
hostname: str
ip_address: str
api_port: int = 8728
api_ssl_port: int = 8729
username: str
password: str
class DeviceUpdate(BaseModel):
"""Schema for updating an existing device. All fields optional."""
hostname: Optional[str] = None
ip_address: Optional[str] = None
api_port: Optional[int] = None
api_ssl_port: Optional[int] = None
username: Optional[str] = None
password: Optional[str] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
tls_mode: Optional[str] = None
@field_validator("tls_mode")
@classmethod
def validate_tls_mode(cls, v: Optional[str]) -> Optional[str]:
"""Validate tls_mode is one of the allowed values."""
if v is None:
return v
allowed = {"auto", "insecure", "plain", "portal_ca"}
if v not in allowed:
raise ValueError(f"tls_mode must be one of: {', '.join(sorted(allowed))}")
return v
class DeviceTagRef(BaseModel):
"""Minimal tag info embedded in device responses."""
id: uuid.UUID
name: str
color: Optional[str] = None
model_config = {"from_attributes": True}
class DeviceGroupRef(BaseModel):
"""Minimal group info embedded in device responses."""
id: uuid.UUID
name: str
model_config = {"from_attributes": True}
class DeviceResponse(BaseModel):
"""Device response schema. NEVER includes credential fields."""
id: uuid.UUID
hostname: str
ip_address: str
api_port: int
api_ssl_port: int
model: Optional[str] = None
serial_number: Optional[str] = None
firmware_version: Optional[str] = None
routeros_version: Optional[str] = None
routeros_major_version: Optional[int] = None
uptime_seconds: Optional[int] = None
last_seen: Optional[datetime] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
status: str
tls_mode: str = "auto"
tags: list[DeviceTagRef] = []
groups: list[DeviceGroupRef] = []
created_at: datetime
model_config = {"from_attributes": True}
class DeviceListResponse(BaseModel):
"""Paginated device list response."""
items: list[DeviceResponse]
total: int
page: int
page_size: int
# ---------------------------------------------------------------------------
# Subnet scan schemas
# ---------------------------------------------------------------------------
class SubnetScanRequest(BaseModel):
"""Request body for a subnet scan."""
cidr: str
@field_validator("cidr")
@classmethod
def validate_cidr(cls, v: str) -> str:
"""Validate that the value is a valid CIDR notation and RFC 1918 private range."""
import ipaddress
try:
network = ipaddress.ip_network(v, strict=False)
except ValueError as e:
raise ValueError(f"Invalid CIDR notation: {e}") from e
# Only allow private IP ranges (RFC 1918: 10/8, 172.16/12, 192.168/16)
if not network.is_private:
raise ValueError(
"Only private IP ranges can be scanned (RFC 1918: "
"10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)"
)
# Reject ranges larger than /20 (4096 IPs) to prevent abuse
if network.num_addresses > 4096:
raise ValueError(
f"CIDR range too large ({network.num_addresses} addresses). "
"Maximum allowed: /20 (4096 addresses)."
)
return v
class SubnetScanResult(BaseModel):
"""A single discovered host from a subnet scan."""
ip_address: str
hostname: Optional[str] = None
api_port_open: bool = False
api_ssl_port_open: bool = False
class SubnetScanResponse(BaseModel):
"""Response for a subnet scan operation."""
cidr: str
discovered: list[SubnetScanResult]
total_scanned: int
total_discovered: int
# ---------------------------------------------------------------------------
# Bulk add from scan
# ---------------------------------------------------------------------------
class BulkDeviceAdd(BaseModel):
"""One device entry within a bulk-add request."""
ip_address: str
hostname: Optional[str] = None
api_port: int = 8728
api_ssl_port: int = 8729
username: Optional[str] = None
password: Optional[str] = None
class BulkAddRequest(BaseModel):
"""
Bulk-add devices selected from a scan result.
shared_username / shared_password are used for all devices that do not
provide their own credentials.
"""
devices: list[BulkDeviceAdd]
shared_username: Optional[str] = None
shared_password: Optional[str] = None
class BulkAddResult(BaseModel):
"""Summary result of a bulk-add operation."""
added: list[DeviceResponse]
failed: list[dict] # {ip_address, error}
# ---------------------------------------------------------------------------
# DeviceGroup schemas
# ---------------------------------------------------------------------------
class DeviceGroupCreate(BaseModel):
"""Schema for creating a device group."""
name: str
description: Optional[str] = None
class DeviceGroupUpdate(BaseModel):
"""Schema for updating a device group."""
name: Optional[str] = None
description: Optional[str] = None
class DeviceGroupResponse(BaseModel):
"""Device group response schema."""
id: uuid.UUID
name: str
description: Optional[str] = None
device_count: int = 0
created_at: datetime
model_config = {"from_attributes": True}
# ---------------------------------------------------------------------------
# DeviceTag schemas
# ---------------------------------------------------------------------------
class DeviceTagCreate(BaseModel):
"""Schema for creating a device tag."""
name: str
color: Optional[str] = None
@field_validator("color")
@classmethod
def validate_color(cls, v: Optional[str]) -> Optional[str]:
"""Validate hex color format if provided."""
if v is None:
return v
import re
if not re.match(r"^#[0-9A-Fa-f]{6}$", v):
raise ValueError("Color must be a valid 6-digit hex color (e.g. #FF5733)")
return v
class DeviceTagUpdate(BaseModel):
"""Schema for updating a device tag."""
name: Optional[str] = None
color: Optional[str] = None
@field_validator("color")
@classmethod
def validate_color(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
import re
if not re.match(r"^#[0-9A-Fa-f]{6}$", v):
raise ValueError("Color must be a valid 6-digit hex color (e.g. #FF5733)")
return v
class DeviceTagResponse(BaseModel):
"""Device tag response schema."""
id: uuid.UUID
name: str
color: Optional[str] = None
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,31 @@
"""Tenant request/response schemas."""
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class TenantCreate(BaseModel):
name: str
description: Optional[str] = None
contact_email: Optional[str] = None
class TenantUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
contact_email: Optional[str] = None
class TenantResponse(BaseModel):
id: uuid.UUID
name: str
description: Optional[str] = None
contact_email: Optional[str] = None
user_count: int = 0
device_count: int = 0
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,53 @@
"""User request/response schemas."""
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr, field_validator
from app.models.user import UserRole
class UserCreate(BaseModel):
name: str
email: EmailStr
password: str
role: UserRole = UserRole.VIEWER
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
return v
@field_validator("role")
@classmethod
def validate_role(cls, v: UserRole) -> UserRole:
"""Tenant admins can only create operator/viewer roles; super_admin via separate flow."""
allowed_tenant_roles = {UserRole.TENANT_ADMIN, UserRole.OPERATOR, UserRole.VIEWER}
if v not in allowed_tenant_roles:
raise ValueError(
f"Role must be one of: {', '.join(r.value for r in allowed_tenant_roles)}"
)
return v
class UserResponse(BaseModel):
id: uuid.UUID
name: str
email: str
role: str
tenant_id: Optional[uuid.UUID] = None
is_active: bool
last_login: Optional[datetime] = None
created_at: datetime
model_config = {"from_attributes": True}
class UserUpdate(BaseModel):
name: Optional[str] = None
role: Optional[UserRole] = None
is_active: Optional[bool] = None

View File

@@ -0,0 +1,91 @@
"""Pydantic schemas for WireGuard VPN management."""
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
# ── VPN Config (server-side) ──
class VpnSetupRequest(BaseModel):
"""Request to enable VPN for a tenant."""
endpoint: Optional[str] = None # public hostname:port — if blank, devices must be configured manually
class VpnConfigResponse(BaseModel):
"""VPN server configuration (never exposes private key)."""
model_config = {"from_attributes": True}
id: uuid.UUID
tenant_id: uuid.UUID
server_public_key: str
subnet: str
server_port: int
server_address: str
endpoint: Optional[str]
is_enabled: bool
peer_count: int = 0
created_at: datetime
class VpnConfigUpdate(BaseModel):
"""Update VPN configuration."""
endpoint: Optional[str] = None
is_enabled: Optional[bool] = None
# ── VPN Peers ──
class VpnPeerCreate(BaseModel):
"""Add a device as a VPN peer."""
device_id: uuid.UUID
additional_allowed_ips: Optional[str] = None # comma-separated subnets for site-to-site routing
class VpnPeerResponse(BaseModel):
"""VPN peer info (never exposes private key)."""
model_config = {"from_attributes": True}
id: uuid.UUID
device_id: uuid.UUID
device_hostname: str = ""
device_ip: str = ""
peer_public_key: str
assigned_ip: str
is_enabled: bool
last_handshake: Optional[datetime]
created_at: datetime
# ── VPN Onboarding (combined device + peer creation) ──
class VpnOnboardRequest(BaseModel):
"""Combined device creation + VPN peer onboarding."""
hostname: str
username: str
password: str
class VpnOnboardResponse(BaseModel):
"""Response from onboarding — device, peer, and RouterOS commands."""
device_id: uuid.UUID
peer_id: uuid.UUID
hostname: str
assigned_ip: str
routeros_commands: list[str]
class VpnPeerConfig(BaseModel):
"""Full peer config for display/export — includes private key for device setup."""
peer_private_key: str
peer_public_key: str
assigned_ip: str
server_public_key: str
server_endpoint: str
allowed_ips: str
routeros_commands: list[str]