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 @@
"""FastAPI middleware and dependencies for auth, tenant context, and RBAC."""

View File

@@ -0,0 +1,48 @@
"""Rate limiting middleware using slowapi with Redis backend.
Per-route rate limits only -- no global limits to avoid blocking the
Go poller, NATS subscribers, and health check endpoints.
Rate limit data uses Redis DB 1 (separate from app data in DB 0).
"""
from fastapi import FastAPI
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from app.config import settings
def _get_redis_url() -> str:
"""Return Redis URL pointing to DB 1 for rate limit storage.
Keeps rate limit counters separate from application data in DB 0.
"""
url = settings.REDIS_URL
if url.endswith("/0"):
return url[:-2] + "/1"
# If no DB specified or different DB, append /1
if url.rstrip("/").split("/")[-1].isdigit():
# Replace existing DB number
parts = url.rsplit("/", 1)
return parts[0] + "/1"
return url.rstrip("/") + "/1"
limiter = Limiter(
key_func=get_remote_address,
storage_uri=_get_redis_url(),
default_limits=[], # No global limits -- per-route only
)
def setup_rate_limiting(app: FastAPI) -> None:
"""Register the rate limiter on the FastAPI app.
This sets app.state.limiter (required by slowapi) and registers
the 429 exception handler. It does NOT add middleware -- the
@limiter.limit() decorators handle actual limiting per-route.
"""
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

View File

@@ -0,0 +1,186 @@
"""
Role-Based Access Control (RBAC) middleware.
Provides dependency factories for enforcing role-based access control
on FastAPI routes. Roles are hierarchical:
super_admin > tenant_admin > operator > viewer
Role permissions per plan TENANT-04/05/06:
- viewer: GET endpoints only (read-only)
- operator: GET + device/config management endpoints
- tenant_admin: full access within their tenant
- super_admin: full access across all tenants
"""
from typing import Callable
from fastapi import Depends, HTTPException, Request, status
from fastapi.params import Depends as DependsClass
from app.middleware.tenant_context import CurrentUser, get_current_user
# Role hierarchy (higher index = more privilege)
# api_key is at operator level for RBAC checks; fine-grained access controlled by scopes.
ROLE_HIERARCHY = {
"viewer": 0,
"api_key": 1,
"operator": 1,
"tenant_admin": 2,
"super_admin": 3,
}
def _get_role_level(role: str) -> int:
"""Return numeric privilege level for a role string."""
return ROLE_HIERARCHY.get(role, -1)
def require_role(*allowed_roles: str) -> Callable:
"""
FastAPI dependency factory that checks the current user's role.
Usage:
@router.post("/items", dependencies=[Depends(require_role("tenant_admin", "super_admin"))])
Args:
*allowed_roles: Role strings that are permitted to access the endpoint
Returns:
FastAPI dependency that raises 403 if the role is insufficient
"""
async def dependency(
current_user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied. Required roles: {', '.join(allowed_roles)}. "
f"Your role: {current_user.role}",
)
return current_user
return dependency
def require_min_role(min_role: str) -> Callable:
"""
Dependency factory that allows any role at or above the minimum level.
Usage:
@router.get("/items", dependencies=[Depends(require_min_role("operator"))])
# Allows: operator, tenant_admin, super_admin
# Denies: viewer
"""
min_level = _get_role_level(min_role)
async def dependency(
current_user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
user_level = _get_role_level(current_user.role)
if user_level < min_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied. Minimum required role: {min_role}. "
f"Your role: {current_user.role}",
)
return current_user
return dependency
def require_write_access() -> Callable:
"""
Dependency that enforces viewer read-only restriction.
Viewers are NOT allowed on POST/PUT/PATCH/DELETE endpoints.
Call this on any mutating endpoint to deny viewers.
"""
async def dependency(
request: Request,
current_user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
if current_user.role == "viewer":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Viewers have read-only access. "
"Contact your administrator to request elevated permissions.",
)
return current_user
return dependency
def require_scope(scope: str) -> DependsClass:
"""FastAPI dependency that checks API key scopes.
No-op for regular users (JWT auth) -- scopes only apply to API keys.
For API key users: checks that the required scope is in the key's scope list.
Returns a Depends() instance so it can be used in dependency lists:
@router.get("/items", dependencies=[require_scope("devices:read")])
Args:
scope: Required scope string (e.g. "devices:read", "config:write").
Raises:
HTTPException 403 if the API key is missing the required scope.
"""
async def _check_scope(
current_user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
if current_user.role == "api_key":
if not current_user.scopes or scope not in current_user.scopes:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"API key missing required scope: {scope}",
)
return current_user
return Depends(_check_scope)
# Pre-built convenience dependencies
async def require_super_admin(
current_user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
"""Require super_admin role (portal-wide admin)."""
if current_user.role != "super_admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied. Super admin role required.",
)
return current_user
async def require_tenant_admin_or_above(
current_user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
"""Require tenant_admin or super_admin role."""
if current_user.role not in ("tenant_admin", "super_admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied. Tenant admin or higher role required.",
)
return current_user
async def require_operator_or_above(
current_user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
"""Require operator, tenant_admin, or super_admin role."""
if current_user.role not in ("operator", "tenant_admin", "super_admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied. Operator or higher role required.",
)
return current_user
async def require_authenticated(
current_user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
"""Require any authenticated user (viewer and above)."""
return current_user

View File

@@ -0,0 +1,67 @@
"""Request ID middleware for structured logging context.
Generates or extracts a request ID for every incoming request and binds it
(along with tenant_id from JWT) to structlog's contextvars so that all log
lines emitted during the request include these correlation fields.
"""
import uuid
import structlog
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
class RequestIDMiddleware(BaseHTTPMiddleware):
"""Middleware that binds request_id and tenant_id to structlog context."""
async def dispatch(self, request: Request, call_next):
# CRITICAL: Clear stale context from previous request to prevent leaks
structlog.contextvars.clear_contextvars()
# Generate or extract request ID
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
# Best-effort tenant_id extraction from JWT (does not fail if no token)
tenant_id = self._extract_tenant_id(request)
# Bind to structlog context -- all subsequent log calls include these fields
structlog.contextvars.bind_contextvars(
request_id=request_id,
tenant_id=tenant_id,
)
response: Response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
def _extract_tenant_id(self, request: Request) -> str | None:
"""Best-effort extraction of tenant_id from JWT.
Looks in cookies first (access_token), then Authorization header.
Returns None if no valid token is found -- this is fine for
unauthenticated endpoints like /login.
"""
token = request.cookies.get("access_token")
if not token:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if not token:
return None
try:
from jose import jwt as jose_jwt
from app.config import settings
payload = jose_jwt.decode(
token,
settings.JWT_SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM],
)
return payload.get("tenant_id")
except Exception:
return None

View File

@@ -0,0 +1,79 @@
"""Security response headers middleware.
Adds standard security headers to all API responses:
- X-Content-Type-Options: nosniff (prevent MIME sniffing)
- X-Frame-Options: DENY (prevent clickjacking)
- Referrer-Policy: strict-origin-when-cross-origin
- Cache-Control: no-store (prevent browser caching of API responses)
- Strict-Transport-Security (HSTS, production only -- breaks plain HTTP dev)
- Content-Security-Policy (strict in production, relaxed for dev HMR)
CSP directives:
- script-src 'self' (production) blocks inline scripts -- XSS mitigation
- style-src 'unsafe-inline' required for Tailwind, Framer Motion, Radix, Sonner
- connect-src includes wss:/ws: for SSE and WebSocket connections
- Dev mode adds 'unsafe-inline' and 'unsafe-eval' for Vite HMR
"""
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
# Production CSP: strict -- no inline scripts allowed
_CSP_PRODUCTION = "; ".join([
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self'",
"connect-src 'self' wss: ws:",
"worker-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
])
# Development CSP: relaxed for Vite HMR (hot module replacement)
_CSP_DEV = "; ".join([
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self'",
"connect-src 'self' http://localhost:* ws://localhost:* wss:",
"worker-src 'self' blob:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
])
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers to every API response."""
def __init__(self, app, environment: str = "dev"):
super().__init__(app)
self.is_production = environment != "dev"
async def dispatch(self, request: Request, call_next) -> Response:
response = await call_next(request)
# Always-on security headers
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Cache-Control"] = "no-store"
# Content-Security-Policy (environment-aware)
if self.is_production:
response.headers["Content-Security-Policy"] = _CSP_PRODUCTION
else:
response.headers["Content-Security-Policy"] = _CSP_DEV
# HSTS only in production (plain HTTP in dev would be blocked)
if self.is_production:
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
return response

View File

@@ -0,0 +1,177 @@
"""
Tenant context middleware and current user dependency.
Extracts JWT from Authorization header (Bearer token) or httpOnly cookie,
validates it, and provides current user context for request handlers.
For tenant-scoped users: sets SET LOCAL app.current_tenant on the DB session.
For super_admin: uses special 'super_admin' context that grants cross-tenant access.
"""
import uuid
from typing import Annotated, Optional
from fastapi import Cookie, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db, set_tenant_context
from app.services.auth import verify_token
# Optional HTTP Bearer scheme (won't raise 403 automatically — we handle auth ourselves)
bearer_scheme = HTTPBearer(auto_error=False)
class CurrentUser:
"""Represents the currently authenticated user extracted from JWT or API key."""
def __init__(
self,
user_id: uuid.UUID,
tenant_id: Optional[uuid.UUID],
role: str,
scopes: Optional[list[str]] = None,
) -> None:
self.user_id = user_id
self.tenant_id = tenant_id
self.role = role
self.scopes = scopes
@property
def is_super_admin(self) -> bool:
return self.role == "super_admin"
@property
def is_api_key(self) -> bool:
return self.role == "api_key"
def __repr__(self) -> str:
return f"<CurrentUser user_id={self.user_id} role={self.role} tenant_id={self.tenant_id}>"
def _extract_token(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials],
access_token: Optional[str],
) -> Optional[str]:
"""
Extract JWT token from Authorization header or httpOnly cookie.
Priority: Authorization header > cookie.
"""
if credentials and credentials.scheme.lower() == "bearer":
return credentials.credentials
if access_token:
return access_token
return None
async def get_current_user(
request: Request,
credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(bearer_scheme)] = None,
access_token: Annotated[Optional[str], Cookie()] = None,
db: AsyncSession = Depends(get_db),
) -> CurrentUser:
"""
FastAPI dependency that extracts and validates the current user from JWT.
Supports both Bearer token (Authorization header) and httpOnly cookie.
Sets the tenant context on the database session for RLS enforcement.
Raises:
HTTPException 401: If no token provided or token is invalid
"""
token = _extract_token(request, credentials, access_token)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
# API key authentication: detect mktp_ prefix and validate via api_key_service
if token.startswith("mktp_"):
from app.services.api_key_service import validate_api_key
key_data = await validate_api_key(token)
if not key_data:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid, expired, or revoked API key",
headers={"WWW-Authenticate": "Bearer"},
)
tenant_id = key_data["tenant_id"]
# Set tenant context on the request-scoped DB session for RLS
await set_tenant_context(db, str(tenant_id))
return CurrentUser(
user_id=key_data["user_id"],
tenant_id=tenant_id,
role="api_key",
scopes=key_data["scopes"],
)
# Decode and validate the JWT
payload = verify_token(token, expected_type="access")
user_id_str = payload.get("sub")
tenant_id_str = payload.get("tenant_id")
role = payload.get("role")
if not user_id_str or not role:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
headers={"WWW-Authenticate": "Bearer"},
)
try:
user_id = uuid.UUID(user_id_str)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
)
tenant_id: Optional[uuid.UUID] = None
if tenant_id_str:
try:
tenant_id = uuid.UUID(tenant_id_str)
except ValueError:
pass
# Set the tenant context on the database session for RLS enforcement
if role == "super_admin":
# super_admin uses special context that grants cross-tenant access
await set_tenant_context(db, "super_admin")
elif tenant_id:
await set_tenant_context(db, str(tenant_id))
else:
# Non-super_admin without tenant — deny access
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token: no tenant context",
)
return CurrentUser(
user_id=user_id,
tenant_id=tenant_id,
role=role,
)
async def get_optional_current_user(
request: Request,
credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(bearer_scheme)] = None,
access_token: Annotated[Optional[str], Cookie()] = None,
db: AsyncSession = Depends(get_db),
) -> Optional[CurrentUser]:
"""Same as get_current_user but returns None instead of raising 401."""
try:
return await get_current_user(request, credentials, access_token, db)
except HTTPException:
return None