Initial commit
Some checks failed
CI/CD Pipeline / Code Quality & Linting (push) Has been cancelled
CI/CD Pipeline / Policy Validation (push) Has been cancelled
CI/CD Pipeline / Test Suite (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-coverage) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-extract) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-firm-connectors) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-forms) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-hmrc) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-ingestion) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-kg) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-normalize-map) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-ocr) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-rag-indexer) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-rag-retriever) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-reason) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-rpa) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (ui-review) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (svc-coverage) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (svc-extract) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (svc-kg) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (svc-rag-retriever) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (ui-review) (push) Has been cancelled
CI/CD Pipeline / Generate SBOM (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Notifications (push) Has been cancelled

This commit is contained in:
harkon
2025-10-11 08:41:36 +01:00
commit b324ff09ef
276 changed files with 55220 additions and 0 deletions

26
libs/security/__init__.py Normal file
View File

@@ -0,0 +1,26 @@
"""Security utilities for authentication, authorization, and encryption."""
from .auth import AuthenticationHeaders
from .dependencies import (
get_current_tenant,
get_current_user,
get_tenant_id,
require_admin_role,
require_reviewer_role,
)
from .middleware import TrustedProxyMiddleware, create_trusted_proxy_middleware
from .utils import is_internal_request
from .vault import VaultTransitHelper
__all__ = [
"VaultTransitHelper",
"AuthenticationHeaders",
"TrustedProxyMiddleware",
"is_internal_request",
"require_admin_role",
"require_reviewer_role",
"get_current_tenant",
"get_current_user",
"get_tenant_id",
"create_trusted_proxy_middleware",
]

61
libs/security/auth.py Normal file
View File

@@ -0,0 +1,61 @@
"""Authentication headers parsing and validation."""
import structlog
from fastapi import HTTPException, Request, status
logger = structlog.get_logger()
class AuthenticationHeaders:
"""Parse and validate authentication headers from Traefik + Authentik"""
def __init__(self, request: Request):
self.request = request
self.headers = request.headers
@property
def authenticated_user(self) -> str | None:
"""Get authenticated user from headers"""
return self.headers.get("X-Authenticated-User")
@property
def authenticated_email(self) -> str | None:
"""Get authenticated email from headers"""
return self.headers.get("X-Authenticated-Email")
@property
def authenticated_groups(self) -> list[str]:
"""Get authenticated groups from headers"""
groups_header = self.headers.get("X-Authenticated-Groups", "")
return [g.strip() for g in groups_header.split(",") if g.strip()]
@property
def authorization_token(self) -> str | None:
"""Get JWT token from Authorization header"""
auth_header = self.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
return auth_header[7:]
return None
def has_role(self, role: str) -> bool:
"""Check if user has specific role"""
return role in self.authenticated_groups
def has_any_role(self, roles: list[str]) -> bool:
"""Check if user has any of the specified roles"""
return any(role in self.authenticated_groups for role in roles)
def require_role(self, role: str) -> None:
"""Require specific role or raise HTTPException"""
if not self.has_role(role):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=f"Role '{role}' required"
)
def require_any_role(self, roles: list[str]) -> None:
"""Require any of the specified roles or raise HTTPException"""
if not self.has_any_role(roles):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"One of roles {roles} required",
)

View File

@@ -0,0 +1,79 @@
"""FastAPI dependency functions for authentication and authorization."""
from collections.abc import Callable
from typing import Any
from fastapi import HTTPException, Request, status
def require_admin_role(request: Request) -> None:
"""Dependency to require admin role"""
auth = getattr(request.state, "auth", None)
if not auth:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required"
)
auth.require_role("admin")
def require_reviewer_role(request: Request) -> None:
"""Dependency to require reviewer role"""
auth = getattr(request.state, "auth", None)
if not auth:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required"
)
auth.require_any_role(["admin", "reviewer"])
def get_current_tenant(request: Request) -> str | None:
"""Extract tenant ID from user context or headers"""
# This could be extracted from JWT claims, user groups, or custom headers
# For now, we'll use a simple mapping from user to tenant
user = getattr(request.state, "user", None)
if not user:
return None
# Simple tenant extraction - in production this would be more sophisticated
# Could be from JWT claims, database lookup, or group membership
roles = getattr(request.state, "roles", [])
for role in roles:
if role.startswith("tenant:"):
return str(role.split(":", 1)[1])
# Default tenant for development
return "default"
# Dependency functions for FastAPI
def get_current_user() -> Callable[[Request], dict[str, Any]]:
"""FastAPI dependency to get current user"""
def _get_current_user(request: Request) -> dict[str, Any]:
user = getattr(request.state, "user", None)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
)
return {
"sub": user,
"email": getattr(request.state, "email", ""),
"roles": getattr(request.state, "roles", []),
}
return _get_current_user
def get_tenant_id() -> Callable[[Request], str]:
"""FastAPI dependency to get tenant ID"""
def _get_tenant_id(request: Request) -> str:
tenant_id = get_current_tenant(request)
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Tenant ID required"
)
return tenant_id
return _get_tenant_id

134
libs/security/middleware.py Normal file
View File

@@ -0,0 +1,134 @@
"""Trusted proxy middleware for authentication validation."""
from collections.abc import Callable
from typing import Any
import structlog
from fastapi import HTTPException, Request, status
from starlette.middleware.base import BaseHTTPMiddleware
from .auth import AuthenticationHeaders
from .utils import is_internal_request
logger = structlog.get_logger()
class TrustedProxyMiddleware(
BaseHTTPMiddleware
): # pylint: disable=too-few-public-methods
"""Middleware to validate requests from trusted proxy (Traefik)"""
def __init__(self, app: Any, internal_cidrs: list[str], disable_auth: bool = False):
super().__init__(app)
self.internal_cidrs = internal_cidrs
self.disable_auth = disable_auth
self.public_endpoints = {
"/healthz",
"/readyz",
"/livez",
"/metrics",
"/docs",
"/openapi.json",
}
async def dispatch(self, request: Request, call_next: Callable[..., Any]) -> Any:
"""Process request through middleware"""
# Get client IP (considering proxy headers)
client_ip = self._get_client_ip(request)
# Check if authentication is disabled (development mode)
if self.disable_auth:
# Set development state
request.state.user = "dev-user"
request.state.email = "dev@example.com"
request.state.roles = ["developers"]
request.state.auth_token = "dev-token"
logger.info(
"Development mode: authentication disabled", path=request.url.path
)
return await call_next(request)
# Check if this is a public endpoint
if request.url.path in self.public_endpoints:
# For metrics endpoint, still require internal network
if request.url.path == "/metrics":
if not is_internal_request(client_ip, self.internal_cidrs):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Metrics endpoint only accessible from internal network",
)
# Set minimal state for public endpoints
request.state.user = None
request.state.email = None
request.state.roles = []
return await call_next(request)
# For protected endpoints, validate authentication headers
auth_headers = AuthenticationHeaders(request)
# Require authentication headers
if not auth_headers.authenticated_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing X-Authenticated-User header",
)
if not auth_headers.authenticated_email:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing X-Authenticated-Email header",
)
if not auth_headers.authorization_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing Authorization header",
)
# Set request state
request.state.user = auth_headers.authenticated_user
request.state.email = auth_headers.authenticated_email
request.state.roles = auth_headers.authenticated_groups
request.state.auth_token = auth_headers.authorization_token
# Add authentication helper to request
request.state.auth = auth_headers
logger.info(
"Authenticated request",
user=auth_headers.authenticated_user,
email=auth_headers.authenticated_email,
roles=auth_headers.authenticated_groups,
path=request.url.path,
)
return await call_next(request)
def _get_client_ip(self, request: Request) -> str:
"""Get client IP considering proxy headers"""
# Check X-Forwarded-For header first (set by Traefik)
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
# Take the first IP in the chain
return forwarded_for.split(",")[0].strip()
# Check X-Real-IP header
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip
# Fall back to direct client IP
return request.client.host if request.client else "unknown"
def create_trusted_proxy_middleware(
internal_cidrs: list[str],
) -> Callable[[Any], TrustedProxyMiddleware]:
"""Factory function to create TrustedProxyMiddleware"""
def middleware_factory( # pylint: disable=unused-argument
app: Any,
) -> TrustedProxyMiddleware:
return TrustedProxyMiddleware(app, internal_cidrs)
return middleware_factory

20
libs/security/utils.py Normal file
View File

@@ -0,0 +1,20 @@
"""Security utility functions."""
import ipaddress
import structlog
logger = structlog.get_logger()
def is_internal_request(client_ip: str, internal_cidrs: list[str]) -> bool:
"""Check if request comes from internal network"""
try:
client_addr = ipaddress.ip_address(client_ip)
for cidr in internal_cidrs:
if client_addr in ipaddress.ip_network(cidr):
return True
return False
except ValueError:
logger.warning("Invalid client IP address", client_ip=client_ip)
return False

58
libs/security/vault.py Normal file
View File

@@ -0,0 +1,58 @@
"""Vault Transit encryption/decryption helpers."""
import base64
import hvac
import structlog
logger = structlog.get_logger()
class VaultTransitHelper:
"""Helper for Vault Transit encryption/decryption"""
def __init__(self, vault_client: hvac.Client, mount_point: str = "transit"):
self.vault_client = vault_client
self.mount_point = mount_point
def encrypt_field(self, key_name: str, plaintext: str) -> str:
"""Encrypt a field using Vault Transit"""
try:
# Ensure key exists
self._ensure_key_exists(key_name)
# Encrypt the data
response = self.vault_client.secrets.transit.encrypt_data(
mount_point=self.mount_point,
name=key_name,
plaintext=base64.b64encode(plaintext.encode()).decode(),
)
return str(response["data"]["ciphertext"])
except Exception as e:
logger.error("Failed to encrypt field", key_name=key_name, error=str(e))
raise
def decrypt_field(self, key_name: str, ciphertext: str) -> str:
"""Decrypt a field using Vault Transit"""
try:
response = self.vault_client.secrets.transit.decrypt_data(
mount_point=self.mount_point, name=key_name, ciphertext=ciphertext
)
return base64.b64decode(response["data"]["plaintext"]).decode()
except Exception as e:
logger.error("Failed to decrypt field", key_name=key_name, error=str(e))
raise
def _ensure_key_exists(self, key_name: str) -> None:
"""Ensure encryption key exists in Vault"""
try:
self.vault_client.secrets.transit.read_key(
mount_point=self.mount_point, name=key_name
)
# pylint: disable-next=broad-exception-caught
except Exception: # hvac.exceptions.InvalidPath
# Key doesn't exist, create it
self.vault_client.secrets.transit.create_key(
mount_point=self.mount_point, name=key_name, key_type="aes256-gcm96"
)
logger.info("Created new encryption key", key_name=key_name)