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:
1
backend/app/middleware/__init__.py
Normal file
1
backend/app/middleware/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""FastAPI middleware and dependencies for auth, tenant context, and RBAC."""
|
||||
48
backend/app/middleware/rate_limit.py
Normal file
48
backend/app/middleware/rate_limit.py
Normal 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)
|
||||
186
backend/app/middleware/rbac.py
Normal file
186
backend/app/middleware/rbac.py
Normal 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
|
||||
67
backend/app/middleware/request_id.py
Normal file
67
backend/app/middleware/request_id.py
Normal 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
|
||||
79
backend/app/middleware/security_headers.py
Normal file
79
backend/app/middleware/security_headers.py
Normal 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
|
||||
177
backend/app/middleware/tenant_context.py
Normal file
177
backend/app/middleware/tenant_context.py
Normal 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
|
||||
Reference in New Issue
Block a user