feat(auth): implement Keycloak authentication with RBAC and pagination
Some checks failed
ci/woodpecker/pr/lint Pipeline failed
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/type Pipeline was successful

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:
2026-05-02 00:43:10 +03:00
parent ddab62a883
commit 184b95969c
20 changed files with 1461 additions and 99 deletions

View File

@@ -0,0 +1,6 @@
"""Authentication infrastructure package."""
from app.infrastructure.auth.client import KeycloakAuthClient
from app.infrastructure.auth.models import KeycloakUser, TokenInfo
__all__ = ["KeycloakAuthClient", "KeycloakUser", "TokenInfo"]

View File

@@ -0,0 +1,127 @@
"""Keycloak authentication client."""
import time
import httpx
from app.infrastructure.auth.models import KeycloakUser, TokenInfo
from app.infrastructure.config.settings import Settings
class KeycloakAuthClient:
"""Client for Keycloak authentication operations."""
def __init__(self, settings: Settings) -> None:
"""Initialize Keycloak client with settings."""
self._settings = settings
self._base_url = f"{settings.kc.server_url}/realms/{settings.kc.realm}"
self._client_id = settings.kc.client_id
self._client_secret = settings.kc.client_secret
self._cache: dict[str, tuple[TokenInfo, float]] = {}
self._cache_ttl = settings.kc.token_cache_ttl
def _get_introspection_url(self) -> str:
"""Get token introspection endpoint URL."""
return f"{self._base_url}/protocol/openid-connect/token/introspection"
def _get_userinfo_url(self) -> str:
"""Get userinfo endpoint URL."""
return f"{self._base_url}/protocol/openid-connect/userinfo"
def _get_cached_token(self, token: str) -> TokenInfo | None:
"""Get cached token info if valid."""
if token not in self._cache:
return None
token_info, cached_at = self._cache[token]
if time.time() - cached_at > self._cache_ttl:
del self._cache[token]
return None
return token_info
def _cache_token(self, token: str, token_info: TokenInfo) -> None:
"""Cache token info."""
self._cache[token] = (token_info, time.time())
# Simple cleanup of old entries
current_time = time.time()
expired_keys = [
k for k, (_, t) in self._cache.items() if current_time - t > self._cache_ttl
]
for k in expired_keys:
del self._cache[k]
async def introspect_token(self, token: str) -> TokenInfo:
"""Introspect access token using Keycloak."""
# Check cache first
cached = self._get_cached_token(token)
if cached:
return cached
# Prepare introspection request
data = {
"token": token,
"client_id": self._client_id,
"client_secret": self._client_secret,
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(
self._get_introspection_url(),
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10.0,
)
response.raise_for_status()
result = response.json()
except httpx.HTTPError as e:
return TokenInfo(active=False, raw_claims={"error": str(e)})
if not result.get("active", False):
return TokenInfo(active=False, raw_claims=result)
# Extract roles from realm_access or resource_access
roles: list[str] = []
realm_access = result.get("realm_access", {})
if isinstance(realm_access, dict):
roles.extend(realm_access.get("roles", []))
token_info = TokenInfo(
active=True,
user_id=result.get("sub", ""),
username=result.get("preferred_username", ""),
email=result.get("email", ""),
roles=roles,
raw_claims=result,
)
# Cache valid token
self._cache_token(token, token_info)
return token_info
async def get_userinfo(self, token: str) -> KeycloakUser | None:
"""Get user information from Keycloak using access token."""
try:
async with httpx.AsyncClient() as client:
response = await client.get(
self._get_userinfo_url(),
headers={"Authorization": f"Bearer {token}"},
timeout=10.0,
)
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
return None
return KeycloakUser(
id=data.get("sub", ""),
username=data.get("preferred_username", ""),
email=data.get("email", ""),
first_name=data.get("given_name", ""),
last_name=data.get("family_name", ""),
roles=data.get("realm_access", {}).get("roles", [])
if isinstance(data.get("realm_access"), dict)
else [],
)

View File

@@ -0,0 +1,34 @@
"""Keycloak authentication models."""
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class TokenInfo:
"""Information about validated token from Keycloak."""
active: bool
user_id: str = ""
username: str = ""
email: str = ""
roles: list[str] = field(default_factory=list)
raw_claims: dict[str, Any] = field(default_factory=dict, repr=False)
@property
def is_valid(self) -> bool:
"""Check if token is valid and active."""
return self.active and bool(self.user_id)
@dataclass(frozen=True)
class KeycloakUser:
"""User information from Keycloak."""
id: str
username: str
email: str
first_name: str = ""
last_name: str = ""
roles: list[str] = field(default_factory=list)
is_active: bool = True

View File

@@ -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",
]

View File

@@ -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()

View File

@@ -24,7 +24,7 @@ def _get_database_url() -> str:
# Create async engine
engine: AsyncEngine = create_async_engine(
_get_database_url(),
echo=settings.database_echo,
echo=settings.db.echo,
future=True,
)

View File

@@ -15,6 +15,8 @@ from app.application import (
)
from app.application.interfaces import TransactionManager
from app.domain.repositories import PostRepository
from app.infrastructure.auth import KeycloakAuthClient
from app.infrastructure.config.settings import settings
from app.infrastructure.database.connection import AsyncSessionLocal, engine
from app.infrastructure.repositories.post import SQLAlchemyPostRepository
@@ -131,3 +133,12 @@ class UseCaseProvider(Provider):
post_repo=post_repo,
tx_manager=tx_manager,
)
class KeycloakProvider(Provider):
"""Provider for Keycloak authentication client."""
@provide(scope=Scope.APP)
def get_keycloak_client(self) -> KeycloakAuthClient:
"""Provide KeycloakAuthClient singleton."""
return KeycloakAuthClient(settings)

View File

@@ -105,27 +105,50 @@ class SQLAlchemyPostRepository(PostRepository):
orm = result.scalar_one_or_none()
return self._to_domain(orm) if orm else None
async def get_by_author(self, author_id: str) -> list[Post]:
async def get_by_author(
self,
author_id: str,
limit: int | None = None,
offset: int | None = None,
) -> list[Post]:
"""Get posts by author."""
result = await self._session.execute(
select(PostORM).where(PostORM.author_id == author_id)
)
query = select(PostORM).where(PostORM.author_id == author_id)
if limit is not None:
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
result = await self._session.execute(query)
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
async def get_published(self) -> list[Post]:
async def get_published(
self,
limit: int | None = None,
offset: int | None = None,
) -> list[Post]:
"""Get published posts."""
result = await self._session.execute(
select(PostORM).where(PostORM.published.is_(True))
)
query = select(PostORM).where(PostORM.published.is_(True))
if limit is not None:
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
result = await self._session.execute(query)
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
async def get_by_tag(self, tag: str) -> list[Post]:
async def get_by_tag(
self,
tag: str,
limit: int | None = None,
offset: int | None = None,
) -> list[Post]:
"""Get posts by tag."""
result = await self._session.execute(
select(PostORM).where(PostORM.tags.contains([tag]))
)
query = select(PostORM).where(PostORM.tags.contains([tag]))
if limit is not None:
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
result = await self._session.execute(query)
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
@@ -136,16 +159,24 @@ class SQLAlchemyPostRepository(PostRepository):
)
return result.scalar_one_or_none() is not None
async def search(self, query: str) -> list[Post]:
async def search(
self,
query: str,
limit: int | None = None,
offset: int | None = None,
) -> list[Post]:
"""Search posts."""
search_pattern = f"%{query}%"
result = await self._session.execute(
select(PostORM).where(
or_(
PostORM.title.ilike(search_pattern),
PostORM.content.ilike(search_pattern),
)
stmt = select(PostORM).where(
or_(
PostORM.title.ilike(search_pattern),
PostORM.content.ilike(search_pattern),
)
)
if limit is not None:
stmt = stmt.limit(limit)
if offset is not None:
stmt = stmt.offset(offset)
result = await self._session.execute(stmt)
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]