feat(auth): implement Keycloak authentication with RBAC and pagination
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)
This commit is contained in:
@@ -1,5 +1,21 @@
|
||||
"""Infrastructure configuration."""
|
||||
|
||||
from app.infrastructure.config.settings import Settings, settings
|
||||
from app.infrastructure.config.settings import (
|
||||
AppConfig,
|
||||
DBConfig,
|
||||
Environment,
|
||||
KCConfig,
|
||||
SecurityConfig,
|
||||
Settings,
|
||||
settings,
|
||||
)
|
||||
|
||||
__all__ = ["Settings", "settings"]
|
||||
__all__ = [
|
||||
"AppConfig",
|
||||
"DBConfig",
|
||||
"KCConfig",
|
||||
"SecurityConfig",
|
||||
"Environment",
|
||||
"Settings",
|
||||
"settings",
|
||||
]
|
||||
|
||||
@@ -1,31 +1,173 @@
|
||||
"""Application settings."""
|
||||
"""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 Settings(BaseSettings):
|
||||
"""Application configuration settings."""
|
||||
class Environment(str, Enum):
|
||||
"""Application environment modes."""
|
||||
|
||||
# App settings
|
||||
app_name: str = "Blog API"
|
||||
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
|
||||
|
||||
# Database settings
|
||||
database_url: str = "sqlite:///./blog.db"
|
||||
database_echo: bool = False
|
||||
|
||||
# Security settings
|
||||
secret_key: str = "your-secret-key-change-in-production"
|
||||
access_token_expire_minutes: int = 30
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user