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
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:
26
libs/security/__init__.py
Normal file
26
libs/security/__init__.py
Normal 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
61
libs/security/auth.py
Normal 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",
|
||||
)
|
||||
79
libs/security/dependencies.py
Normal file
79
libs/security/dependencies.py
Normal 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
134
libs/security/middleware.py
Normal 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
20
libs/security/utils.py
Normal 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
58
libs/security/vault.py
Normal 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)
|
||||
Reference in New Issue
Block a user