Major changes: - Add Keycloak integration via token introspection endpoint - Implement RBAC system with roles: admin, user, guest - Add role-based permissions for post operations - Add pagination support (default limit: 10) to list endpoints - Add published_only filter with admin-only override for unpublished posts Security improvements: - Remove hardcoded default secrets (SECRET_KEY, KEYCLOAK_CLIENT_SECRET) - Update .env.example with proper security placeholders - Add comprehensive RBAC unit tests Infrastructure: - Add httpx dependency for HTTP client - Add KeycloakAuthClient with token caching (TTL: 60s) - Add role-based dependencies (RequireAdmin, RequireUser, etc.) - Update DI container with Keycloak provider Endpoints updated: - GET /posts: filter by published status (admin can see all) - Add pagination params (limit, offset) to list endpoints - Enforce RBAC on post operations Tests: - Add 16 auth infrastructure tests - Add 13 RBAC role tests - Update existing tests for new required settings Breaking changes: - SECRET_KEY and KEYCLOAK_CLIENT_SECRET now required (no defaults)
174 lines
4.8 KiB
Python
174 lines
4.8 KiB
Python
"""Application settings with composition pattern."""
|
|
|
|
from enum import Enum
|
|
from functools import cached_property
|
|
|
|
from pydantic import Field, PostgresDsn, field_validator
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
|
|
class Environment(str, Enum):
|
|
"""Application environment modes."""
|
|
|
|
DEV = "dev"
|
|
PROD = "prod"
|
|
|
|
|
|
class AppConfig(BaseSettings):
|
|
"""Application configuration."""
|
|
|
|
name: str = "Blog API"
|
|
debug: bool = False
|
|
host: str = "0.0.0.0"
|
|
port: int = 8000
|
|
|
|
model_config = SettingsConfigDict(
|
|
env_prefix="APP_",
|
|
env_file_encoding="utf-8",
|
|
case_sensitive=False,
|
|
)
|
|
|
|
|
|
class DBConfig(BaseSettings):
|
|
"""Database configuration."""
|
|
|
|
# For dev: sqlite+aiosqlite:///./blog.db
|
|
# For prod: postgresql+asyncpg://user:pass@host:port/db
|
|
url: str | None = None
|
|
echo: bool = False
|
|
|
|
# PostgreSQL-specific settings (used in prod)
|
|
host: str = "localhost"
|
|
port: int = 5432
|
|
user: str = "postgres"
|
|
password: str = "postgres"
|
|
name: str = "blog"
|
|
|
|
model_config = SettingsConfigDict(
|
|
env_prefix="DB_",
|
|
env_file_encoding="utf-8",
|
|
case_sensitive=False,
|
|
)
|
|
|
|
@field_validator("url")
|
|
@classmethod
|
|
def validate_url(cls, v: str | None) -> str | None:
|
|
"""Validate database URL if provided."""
|
|
if v is None:
|
|
return v
|
|
if not any(v.startswith(prefix) for prefix in ("sqlite+", "postgresql+")):
|
|
raise ValueError("Database URL must start with 'sqlite+' or 'postgresql+'")
|
|
return v
|
|
|
|
|
|
class KCConfig(BaseSettings):
|
|
"""Keycloak configuration."""
|
|
|
|
server_url: str = "http://localhost:8080"
|
|
realm: str = "blog"
|
|
client_id: str = "blog-api"
|
|
client_secret: str = Field(
|
|
default="",
|
|
description="Keycloak client secret - must be set via env in production",
|
|
)
|
|
token_cache_ttl: int = 60 # seconds
|
|
|
|
model_config = SettingsConfigDict(
|
|
env_prefix="KC_",
|
|
env_file_encoding="utf-8",
|
|
case_sensitive=False,
|
|
)
|
|
|
|
@property
|
|
def is_configured(self) -> bool:
|
|
"""Check if Keycloak is properly configured."""
|
|
return bool(self.client_secret)
|
|
|
|
|
|
class SecurityConfig(BaseSettings):
|
|
"""Security configuration."""
|
|
|
|
secret_key: str = Field(
|
|
default="", description="Secret key for JWT - must be set via env in production"
|
|
)
|
|
access_token_expire_minutes: int = 30
|
|
|
|
model_config = SettingsConfigDict(
|
|
env_prefix="SECURITY_",
|
|
env_file_encoding="utf-8",
|
|
case_sensitive=False,
|
|
)
|
|
|
|
@property
|
|
def is_configured(self) -> bool:
|
|
"""Check if security is properly configured."""
|
|
return bool(self.secret_key)
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
"""Application configuration settings with composition."""
|
|
|
|
# Environment mode
|
|
environment: Environment = Environment.DEV
|
|
|
|
# Sub-configurations
|
|
app: AppConfig = Field(default_factory=AppConfig)
|
|
db: DBConfig = Field(default_factory=DBConfig)
|
|
kc: KCConfig = Field(default_factory=KCConfig)
|
|
security: SecurityConfig = Field(default_factory=SecurityConfig)
|
|
|
|
model_config = SettingsConfigDict(
|
|
env_file=".env",
|
|
env_file_encoding="utf-8",
|
|
case_sensitive=False,
|
|
env_nested_delimiter="__",
|
|
)
|
|
|
|
def model_post_init(self, __context: object) -> None:
|
|
"""Validate settings after initialization."""
|
|
if self.is_prod:
|
|
if not self.security.is_configured:
|
|
raise ValueError("SECURITY_SECRET_KEY must be set in production mode")
|
|
if not self.kc.is_configured:
|
|
raise ValueError("KC_CLIENT_SECRET must be set in production mode")
|
|
|
|
@cached_property
|
|
def database_url(self) -> str:
|
|
"""Get database URL based on environment.
|
|
|
|
- In dev: uses SQLite if no URL provided
|
|
- In prod: uses PostgreSQL if no URL provided
|
|
"""
|
|
if self.db.url:
|
|
return self.db.url
|
|
|
|
if self.environment == Environment.PROD:
|
|
# Build PostgreSQL URL from components
|
|
return str(
|
|
PostgresDsn.build(
|
|
scheme="postgresql+asyncpg",
|
|
username=self.db.user,
|
|
password=self.db.password,
|
|
host=self.db.host,
|
|
port=self.db.port,
|
|
path=self.db.name,
|
|
)
|
|
)
|
|
|
|
# Default dev SQLite URL
|
|
return "sqlite+aiosqlite:///./blog.db"
|
|
|
|
@property
|
|
def is_dev(self) -> bool:
|
|
"""Check if running in development mode."""
|
|
return self.environment == Environment.DEV
|
|
|
|
@property
|
|
def is_prod(self) -> bool:
|
|
"""Check if running in production mode."""
|
|
return self.environment == Environment.PROD
|
|
|
|
|
|
# Global settings instance
|
|
settings = Settings()
|