Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3516376d92 | ||
|
|
ffa984e271 | ||
|
|
7d13be6052 | ||
|
|
faff45db90 | ||
|
|
892015fa59 | ||
|
|
ee93a73160 | ||
|
|
4f45f9bf37 |
@@ -5,6 +5,63 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.4.6] - 2026-01-01
|
||||
|
||||
### Changed
|
||||
|
||||
- Code cleanup: move inline `re` import to top of auth/service.py
|
||||
|
||||
## [1.4.5] - 2026-01-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Separate httpx and SQLAlchemy async contexts in bulk sync (fixes greenlet error)
|
||||
|
||||
## [1.4.4] - 2026-01-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Use `uuid` field instead of `pk` for Authentik user sync (pk is integer, uuid is proper UUID)
|
||||
- Skip internal_service_account type users during bulk sync
|
||||
|
||||
## [1.4.3] - 2026-01-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Manually extract and send session cookies for Authentik flow auth (fixes cross-domain cookie handling)
|
||||
|
||||
## [1.4.2] - 2026-01-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Use Authentik domain URL (auth.schweitz.net) instead of IP to fix cookie domain matching
|
||||
|
||||
## [1.4.1] - 2026-01-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Authentik config now uses AUTHENTIK_USERNAME/PASSWORD to match production env vars
|
||||
|
||||
## [1.4.0] - 2026-01-01
|
||||
|
||||
### Added
|
||||
|
||||
- **Authentication & User Management** - Authentik integration for user synchronization
|
||||
- `GET /auth/me` - Get current authenticated user info
|
||||
- `GET /auth/users` - List all users with search and pagination
|
||||
- `POST /auth/users/sync-from-authentik` - Bulk sync users from Authentik admin API
|
||||
- PostgreSQL database integration with async SQLAlchemy
|
||||
- Alembic database migrations for schema management
|
||||
- Database models: User, Role, UserPreferences, ApiKey
|
||||
- Token validation via Authentik userinfo endpoint
|
||||
- Role synchronization from Authentik groups
|
||||
- Health check now includes database connectivity status
|
||||
|
||||
### Changed
|
||||
|
||||
- Authentik configuration now uses username/password for admin API access
|
||||
- Health endpoint includes database status in diagnostics
|
||||
|
||||
## [1.3.1] - 2025-12-31
|
||||
|
||||
### Changed
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
# Alembic Configuration for Core-API
|
||||
#
|
||||
# Database migrations using async SQLAlchemy
|
||||
|
||||
[alembic]
|
||||
# Path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# Template for migration file names
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# Prepend sys.path with the project root
|
||||
prepend_sys_path = .
|
||||
|
||||
# Timezone for revision creation date
|
||||
timezone = UTC
|
||||
|
||||
# Max length of revision identifiers
|
||||
truncate_slug_length = 40
|
||||
|
||||
# Set to 'true' to run in offline mode
|
||||
revision_environment = false
|
||||
|
||||
# Set to 'true' for sqlalchemy.url to be from the environment
|
||||
# We use env.py to get the URL from config.py instead
|
||||
sqlalchemy.url =
|
||||
|
||||
[post_write_hooks]
|
||||
# Black formatting on generated migration files
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 100 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,21 @@
|
||||
Alembic Migrations for Core-API
|
||||
|
||||
This directory contains database migrations managed by Alembic.
|
||||
|
||||
Commands:
|
||||
# Generate a new migration (after changing models)
|
||||
alembic revision --autogenerate -m "description"
|
||||
|
||||
# Apply all pending migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Rollback last migration
|
||||
alembic downgrade -1
|
||||
|
||||
# View migration history
|
||||
alembic history
|
||||
|
||||
# View current revision
|
||||
alembic current
|
||||
|
||||
See https://alembic.sqlalchemy.org for more documentation.
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Alembic Environment Configuration
|
||||
|
||||
Async migration environment for SQLAlchemy 2.0 with asyncpg.
|
||||
"""
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Import our models and config
|
||||
from src.config import get_settings
|
||||
from src.db.database import Base
|
||||
|
||||
# Import all models to ensure they're registered with Base.metadata
|
||||
from src.db.models import User, Role, UserRole, UserPreferences, ApiKey # noqa: F401
|
||||
|
||||
# Alembic Config object
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Model metadata for autogenerate support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# Get database URL from our settings
|
||||
settings = get_settings()
|
||||
db_url = settings.database_url
|
||||
if db_url.startswith("postgresql://"):
|
||||
db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""
|
||||
Run migrations in 'offline' mode.
|
||||
|
||||
Generates SQL script without connecting to the database.
|
||||
"""
|
||||
context.configure(
|
||||
url=db_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""
|
||||
Run migrations with the given connection.
|
||||
"""
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""
|
||||
Run migrations in 'online' mode with async engine.
|
||||
"""
|
||||
configuration = config.get_section(config.config_ini_section) or {}
|
||||
configuration["sqlalchemy.url"] = db_url
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""
|
||||
Run migrations in 'online' mode.
|
||||
"""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Create auth tables
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-01-01
|
||||
|
||||
Creates the initial authentication and authorization tables:
|
||||
- users: User accounts synced from Authentik
|
||||
- roles: Domain-scoped permission roles
|
||||
- user_roles: User-Role association table
|
||||
- user_preferences: User settings and preferences
|
||||
- api_keys: API key authentication
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Users table
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("authentik_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("avatar_url", sa.String(500), nullable=True),
|
||||
sa.Column("api_keys_enabled", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("last_login", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_users_authentik_id", "users", ["authentik_id"], unique=True)
|
||||
op.create_index("ix_users_email", "users", ["email"], unique=True)
|
||||
|
||||
# Roles table
|
||||
op.create_table(
|
||||
"roles",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("name", sa.String(100), nullable=False, comment="Role name in format domain:action"),
|
||||
sa.Column("domain", sa.String(50), nullable=False, comment="Permission domain"),
|
||||
sa.Column("action", sa.String(20), nullable=False, comment="Permission action"),
|
||||
sa.Column("authentik_group", sa.String(255), nullable=True, comment="Corresponding Authentik group name"),
|
||||
)
|
||||
op.create_index("ix_roles_name", "roles", ["name"], unique=True)
|
||||
op.create_index("ix_roles_domain", "roles", ["domain"])
|
||||
op.create_index("ix_roles_authentik_group", "roles", ["authentik_group"], unique=True)
|
||||
|
||||
# User-Role association table
|
||||
op.create_table(
|
||||
"user_roles",
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("role_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
# User preferences table
|
||||
op.create_table(
|
||||
"user_preferences",
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("theme", sa.String(20), nullable=False, server_default="system", comment="Theme preference"),
|
||||
sa.Column("default_room", sa.String(50), nullable=False, server_default="front-hall", comment="Default room"),
|
||||
sa.Column("preferences_json", postgresql.JSONB(), nullable=False, server_default="{}"),
|
||||
)
|
||||
|
||||
# API keys table
|
||||
op.create_table(
|
||||
"api_keys",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False, comment="Human-readable key name"),
|
||||
sa.Column("key_hash", sa.String(255), nullable=False, comment="SHA-256 hash of the API key"),
|
||||
sa.Column("key_prefix", sa.String(8), nullable=False, comment="First 8 chars for identification"),
|
||||
sa.Column("scopes", postgresql.ARRAY(sa.String()), nullable=True, comment="Optional scope restriction"),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"])
|
||||
|
||||
# Seed initial roles (domain:action combinations)
|
||||
roles_table = sa.table(
|
||||
"roles",
|
||||
sa.column("id", postgresql.UUID),
|
||||
sa.column("name", sa.String),
|
||||
sa.column("domain", sa.String),
|
||||
sa.column("action", sa.String),
|
||||
sa.column("authentik_group", sa.String),
|
||||
)
|
||||
|
||||
domains = [
|
||||
"control-room",
|
||||
"library",
|
||||
"media",
|
||||
"ai",
|
||||
"housekeeper",
|
||||
"developer",
|
||||
"documents",
|
||||
"gaming",
|
||||
"admin",
|
||||
]
|
||||
actions = ["viewer", "user", "editor", "admin"]
|
||||
|
||||
roles_data = []
|
||||
for domain in domains:
|
||||
for action in actions:
|
||||
role_name = f"{domain}:{action}"
|
||||
authentik_group = f"tatlock-{domain}-{action}"
|
||||
roles_data.append({
|
||||
"id": sa.text("gen_random_uuid()"),
|
||||
"name": role_name,
|
||||
"domain": domain,
|
||||
"action": action,
|
||||
"authentik_group": authentik_group,
|
||||
})
|
||||
|
||||
# Insert roles using raw SQL for UUID generation
|
||||
for role in roles_data:
|
||||
op.execute(
|
||||
f"""
|
||||
INSERT INTO roles (id, name, domain, action, authentik_group)
|
||||
VALUES (gen_random_uuid(), '{role["name"]}', '{role["domain"]}', '{role["action"]}', '{role["authentik_group"]}')
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("api_keys")
|
||||
op.drop_table("user_preferences")
|
||||
op.drop_table("user_roles")
|
||||
op.drop_table("roles")
|
||||
op.drop_table("users")
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "core-api"
|
||||
version = "1.3.4"
|
||||
version = "1.4.6"
|
||||
description = "Core Code API - Infrastructure management and tools API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+14
-8
@@ -1,12 +1,13 @@
|
||||
# FastAPI and ASGI server
|
||||
fastapi~=0.115.0
|
||||
uvicorn[standard]>=0.34.0 # Updated for google-adk compatibility
|
||||
pydantic>=2.11.1,<3.0.0 # Required for google-cloud-aiplatform[agent-engines]
|
||||
fastapi>=0.115.0
|
||||
starlette>=0.49.1 # CVE-2025-54121, CVE-2025-62727
|
||||
uvicorn[standard]>=0.34.0
|
||||
pydantic>=2.11.1,<3.0.0
|
||||
pydantic-settings>=2.10.1
|
||||
|
||||
# HTTP client
|
||||
httpx>=0.28.0 # Required for google-adk
|
||||
python-socketio[asyncio_client]~=5.11.0
|
||||
httpx>=0.28.0
|
||||
python-socketio[asyncio_client]>=5.14.0 # CVE-2025-61765
|
||||
|
||||
# Web scraping
|
||||
beautifulsoup4~=4.12.0
|
||||
@@ -22,6 +23,11 @@ pytz~=2024.1
|
||||
dnspython~=2.7.0
|
||||
|
||||
# Authentication & Security
|
||||
PyJWT[crypto]~=2.9.0
|
||||
python-jose[cryptography]~=3.3.0
|
||||
cryptography~=43.0.0
|
||||
PyJWT[crypto]>=2.9.0
|
||||
python-jose[cryptography]>=3.4.0 # CVE PYSEC-2024-232, PYSEC-2024-233
|
||||
cryptography>=44.0.1 # CVE-2024-12797
|
||||
|
||||
# Database
|
||||
sqlalchemy[asyncio]~=2.0.0
|
||||
asyncpg>=0.30.0
|
||||
alembic~=1.13.0
|
||||
|
||||
@@ -3,3 +3,25 @@ Authentication module for core-api
|
||||
|
||||
Provides OIDC/OAuth2 authentication via Authentik
|
||||
"""
|
||||
from src.auth.oidc import (
|
||||
get_current_user,
|
||||
get_admin_user,
|
||||
get_optional_user,
|
||||
get_forward_auth_user,
|
||||
get_forward_auth_admin,
|
||||
oidc_config,
|
||||
)
|
||||
from src.auth.service import AuthService, get_auth_service
|
||||
from src.auth.controller import auth_controller
|
||||
|
||||
__all__ = [
|
||||
"get_current_user",
|
||||
"get_admin_user",
|
||||
"get_optional_user",
|
||||
"get_forward_auth_user",
|
||||
"get_forward_auth_admin",
|
||||
"oidc_config",
|
||||
"AuthService",
|
||||
"get_auth_service",
|
||||
"auth_controller",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Authentication Controller
|
||||
|
||||
Provides authentication endpoints for OIDC token sync and user management.
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.controllers.base import BaseController
|
||||
from src.logging_config import get_logger
|
||||
from src.db import get_async_session
|
||||
from src.auth.schemas import AuthSyncRequest, AuthSyncResponse, UsersListResponse, BulkSyncResultSchema
|
||||
from src.auth.service import AuthService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AuthController(BaseController):
|
||||
"""
|
||||
Controller for authentication operations
|
||||
|
||||
Provides endpoints for:
|
||||
- Token synchronization (login)
|
||||
- User profile retrieval
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/auth", tags=["Authentication"])
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
"""Create and configure the router"""
|
||||
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||
|
||||
@router.post(
|
||||
"/sync",
|
||||
summary="Sync user from OIDC token",
|
||||
response_model=AuthSyncResponse,
|
||||
responses={
|
||||
200: {"description": "User synced successfully"},
|
||||
401: {"description": "Invalid or expired token"},
|
||||
503: {"description": "Authentication service unavailable"},
|
||||
},
|
||||
)
|
||||
async def sync_user(
|
||||
request: AuthSyncRequest,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> AuthSyncResponse:
|
||||
"""
|
||||
Synchronize user from OIDC access token
|
||||
|
||||
This endpoint should be called after the client obtains an access token
|
||||
from Authentik. It:
|
||||
1. Validates the token via Authentik's userinfo endpoint
|
||||
2. Creates or updates the user in the database
|
||||
3. Syncs roles from Authentik groups
|
||||
4. Returns the user profile with roles and preferences
|
||||
|
||||
The client should store the returned user info for local use.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
try:
|
||||
# Validate token with Authentik
|
||||
token_info = await service.validate_token(request.access_token)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Token validation failed: {e}")
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
|
||||
# Sync user to database
|
||||
user, is_new = await service.sync_user(token_info)
|
||||
|
||||
# Sync roles from groups
|
||||
roles = await service.sync_roles(user, token_info.groups)
|
||||
|
||||
# Commit the transaction
|
||||
await session.commit()
|
||||
|
||||
# Refresh to get relationships
|
||||
await session.refresh(user, ["preferences"])
|
||||
|
||||
# Build response
|
||||
return AuthSyncResponse(
|
||||
user=service.user_to_schema(user),
|
||||
roles=service.roles_to_schema(roles),
|
||||
preferences=service.preferences_to_schema(user.preferences),
|
||||
is_new_user=is_new,
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/users",
|
||||
summary="List all users",
|
||||
response_model=UsersListResponse,
|
||||
responses={
|
||||
200: {"description": "List of users"},
|
||||
},
|
||||
)
|
||||
async def list_users(
|
||||
search: Optional[str] = Query(None, description="Search by name or email"),
|
||||
offset: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Maximum records to return"),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> UsersListResponse:
|
||||
"""
|
||||
List all users who have logged in via Authentik
|
||||
|
||||
Returns paginated list of users with their roles.
|
||||
Supports search filtering by name or email.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
items, total = await service.list_users(
|
||||
search=search,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
return UsersListResponse(items=items, total=total)
|
||||
|
||||
@router.post(
|
||||
"/users/sync-from-authentik",
|
||||
summary="Bulk sync users from Authentik",
|
||||
response_model=BulkSyncResultSchema,
|
||||
responses={
|
||||
200: {"description": "Sync completed"},
|
||||
401: {"description": "Authentik API token invalid"},
|
||||
503: {"description": "Authentik service unavailable"},
|
||||
},
|
||||
)
|
||||
async def sync_users_from_authentik(
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> BulkSyncResultSchema:
|
||||
"""
|
||||
Fetch all users from Authentik and sync to local database
|
||||
|
||||
This endpoint uses the Authentik admin API to fetch all users
|
||||
and create/update them in the local database. Requires
|
||||
AUTHENTIK_CORE_API_TOKEN to be configured.
|
||||
|
||||
Use this to initially populate users or to re-sync after
|
||||
changes in Authentik.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
try:
|
||||
result = await service.bulk_sync_from_authentik()
|
||||
logger.info(
|
||||
f"Bulk sync completed: {result.created} created, "
|
||||
f"{result.updated} updated, {result.failed} failed"
|
||||
)
|
||||
return result
|
||||
except ValueError as e:
|
||||
logger.error(f"Bulk sync failed: {e}")
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
summary="Get current user profile",
|
||||
response_model=AuthSyncResponse,
|
||||
responses={
|
||||
200: {"description": "User profile"},
|
||||
401: {"description": "Not authenticated"},
|
||||
},
|
||||
)
|
||||
async def get_me(
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Get the current authenticated user's profile
|
||||
|
||||
Note: This endpoint requires a valid session or API key.
|
||||
For now, returns 501 Not Implemented until session management is added.
|
||||
"""
|
||||
# TODO: Implement with get_current_user dependency
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Not implemented - use /auth/sync with access token",
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# Create controller instance
|
||||
auth_controller = AuthController()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Authentication Schemas
|
||||
|
||||
Pydantic models for auth request/response payloads.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import Field
|
||||
|
||||
from src.base_schema import BaseSchema
|
||||
|
||||
|
||||
class AuthSyncRequest(BaseSchema):
|
||||
"""
|
||||
Request payload for POST /auth/sync
|
||||
|
||||
The client sends this after obtaining an OIDC token from Authentik.
|
||||
The access_token is validated against Authentik's userinfo endpoint.
|
||||
"""
|
||||
|
||||
access_token: str = Field(
|
||||
...,
|
||||
description="OIDC access token from Authentik",
|
||||
)
|
||||
|
||||
|
||||
class RoleSchema(BaseSchema):
|
||||
"""Role information in domain:action format"""
|
||||
|
||||
name: str = Field(..., description="Role name (e.g., 'control-room:admin')")
|
||||
domain: str = Field(..., description="Permission domain (e.g., 'control-room')")
|
||||
action: str = Field(..., description="Permission action (e.g., 'admin')")
|
||||
|
||||
|
||||
class UserPreferencesSchema(BaseSchema):
|
||||
"""User preferences"""
|
||||
|
||||
theme: str = Field(default="system", description="Theme preference: system, light, dark")
|
||||
default_room: str = Field(default="front-hall", description="Default room for housekeeping")
|
||||
preferences_json: dict = Field(default_factory=dict, description="Extended preferences")
|
||||
|
||||
|
||||
class UserSchema(BaseSchema):
|
||||
"""User information returned from sync"""
|
||||
|
||||
id: uuid.UUID = Field(..., description="Internal user ID")
|
||||
authentik_id: uuid.UUID = Field(..., description="Authentik user ID")
|
||||
email: str = Field(..., description="User email")
|
||||
name: str = Field(..., description="Display name")
|
||||
avatar_url: Optional[str] = Field(None, description="Profile picture URL")
|
||||
created_at: datetime = Field(..., description="Account creation timestamp")
|
||||
last_login: Optional[datetime] = Field(None, description="Last login timestamp")
|
||||
|
||||
|
||||
class AuthSyncResponse(BaseSchema):
|
||||
"""
|
||||
Response from POST /auth/sync
|
||||
|
||||
Contains the synced user profile, roles, and preferences.
|
||||
"""
|
||||
|
||||
user: UserSchema = Field(..., description="User profile")
|
||||
roles: list[RoleSchema] = Field(..., description="User's permission roles")
|
||||
preferences: UserPreferencesSchema = Field(..., description="User preferences")
|
||||
is_new_user: bool = Field(..., description="True if user was just created")
|
||||
|
||||
|
||||
class TokenInfoSchema(BaseSchema):
|
||||
"""
|
||||
Token information from Authentik userinfo endpoint
|
||||
|
||||
This is what Authentik returns when validating an access token.
|
||||
"""
|
||||
|
||||
sub: str = Field(..., description="Subject (Authentik user ID)")
|
||||
email: str = Field(..., description="User email")
|
||||
name: Optional[str] = Field(None, description="Display name")
|
||||
preferred_username: Optional[str] = Field(None, description="Username")
|
||||
groups: list[str] = Field(default_factory=list, description="Group memberships")
|
||||
picture: Optional[str] = Field(None, description="Profile picture URL")
|
||||
|
||||
|
||||
class UserListItemSchema(BaseSchema):
|
||||
"""User item for list display"""
|
||||
|
||||
id: uuid.UUID = Field(..., description="Internal user ID")
|
||||
email: str = Field(..., description="User email")
|
||||
name: str = Field(..., description="Display name")
|
||||
avatar_url: Optional[str] = Field(None, description="Profile picture URL")
|
||||
created_at: datetime = Field(..., description="Account creation timestamp")
|
||||
last_login: Optional[datetime] = Field(None, description="Last login timestamp")
|
||||
roles: list[str] = Field(default_factory=list, description="Role names")
|
||||
|
||||
|
||||
class UsersListResponse(BaseSchema):
|
||||
"""Response from GET /auth/users"""
|
||||
|
||||
items: list[UserListItemSchema] = Field(..., description="List of users")
|
||||
total: int = Field(..., description="Total count of users")
|
||||
|
||||
|
||||
class BulkSyncResultSchema(BaseSchema):
|
||||
"""Result from bulk sync operation"""
|
||||
|
||||
created: int = Field(..., description="Number of users created")
|
||||
updated: int = Field(..., description="Number of users updated")
|
||||
failed: int = Field(..., description="Number of users that failed to sync")
|
||||
total_in_authentik: int = Field(..., description="Total users in Authentik")
|
||||
errors: list[str] = Field(default_factory=list, description="Error messages for failed syncs")
|
||||
@@ -0,0 +1,477 @@
|
||||
"""
|
||||
Authentication Service
|
||||
|
||||
Business logic for user synchronization from Authentik.
|
||||
"""
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.config import get_settings
|
||||
from src.logging_config import get_logger
|
||||
from src.db.models import User, Role, UserPreferences
|
||||
from src.auth.schemas import TokenInfoSchema, UserSchema, RoleSchema, UserPreferencesSchema, UserListItemSchema, BulkSyncResultSchema
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""
|
||||
Service for authentication and user synchronization
|
||||
|
||||
Handles:
|
||||
- Token validation via Authentik userinfo endpoint
|
||||
- User creation/update from OIDC claims
|
||||
- Role synchronization from Authentik groups
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
"""
|
||||
Initialize auth service
|
||||
|
||||
Args:
|
||||
session: Async database session
|
||||
"""
|
||||
self.session = session
|
||||
self.userinfo_url = f"{settings.authentik_url}/application/o/userinfo/"
|
||||
|
||||
async def validate_token(self, access_token: str) -> TokenInfoSchema:
|
||||
"""
|
||||
Validate access token via Authentik userinfo endpoint
|
||||
|
||||
Args:
|
||||
access_token: OIDC access token
|
||||
|
||||
Returns:
|
||||
Token info containing user claims
|
||||
|
||||
Raises:
|
||||
ValueError: If token is invalid or expired
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
self.userinfo_url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise ValueError("Invalid or expired token")
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
logger.debug(f"Userinfo response: {data}")
|
||||
|
||||
return TokenInfoSchema(
|
||||
sub=data.get("sub"),
|
||||
email=data.get("email"),
|
||||
name=data.get("name") or data.get("preferred_username"),
|
||||
preferred_username=data.get("preferred_username"),
|
||||
groups=data.get("groups", []),
|
||||
picture=data.get("picture"),
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Authentik userinfo request failed: {e}")
|
||||
raise ValueError(f"Token validation failed: {e.response.status_code}")
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Authentik userinfo request error: {e}")
|
||||
raise ValueError("Authentication service unavailable")
|
||||
|
||||
async def sync_user(self, token_info: TokenInfoSchema) -> tuple[User, bool]:
|
||||
"""
|
||||
Create or update user from OIDC token info
|
||||
|
||||
Args:
|
||||
token_info: Validated token information
|
||||
|
||||
Returns:
|
||||
Tuple of (User, is_new_user)
|
||||
"""
|
||||
authentik_id = uuid.UUID(token_info.sub)
|
||||
|
||||
# Try to find existing user
|
||||
stmt = (
|
||||
select(User)
|
||||
.options(selectinload(User.roles), selectinload(User.preferences))
|
||||
.where(User.authentik_id == authentik_id)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
is_new = user is None
|
||||
|
||||
if is_new:
|
||||
# Create new user
|
||||
user = User(
|
||||
authentik_id=authentik_id,
|
||||
email=token_info.email,
|
||||
name=token_info.name or token_info.email,
|
||||
avatar_url=token_info.picture,
|
||||
last_login=datetime.now(timezone.utc),
|
||||
)
|
||||
self.session.add(user)
|
||||
await self.session.flush() # Get the user ID
|
||||
|
||||
# Create default preferences
|
||||
preferences = UserPreferences(user_id=user.id)
|
||||
self.session.add(preferences)
|
||||
|
||||
logger.info(f"Created new user: {token_info.email}")
|
||||
else:
|
||||
# Update existing user
|
||||
user.email = token_info.email
|
||||
user.name = token_info.name or token_info.email
|
||||
user.avatar_url = token_info.picture
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
|
||||
logger.info(f"Updated existing user: {token_info.email}")
|
||||
|
||||
await self.session.flush()
|
||||
return user, is_new
|
||||
|
||||
async def sync_roles(self, user: User, groups: list[str]) -> list[Role]:
|
||||
"""
|
||||
Synchronize user roles from Authentik groups
|
||||
|
||||
Maps Authentik groups (e.g., 'tatlock-control-room-admin')
|
||||
to application roles (e.g., 'control-room:admin').
|
||||
|
||||
Args:
|
||||
user: User to sync roles for
|
||||
groups: List of Authentik group names
|
||||
|
||||
Returns:
|
||||
List of synced Role objects
|
||||
"""
|
||||
# Get all roles that match the user's Authentik groups
|
||||
stmt = select(Role).where(Role.authentik_group.in_(groups))
|
||||
result = await self.session.execute(stmt)
|
||||
matching_roles = list(result.scalars().all())
|
||||
|
||||
# Clear existing roles and set new ones
|
||||
user.roles = matching_roles
|
||||
|
||||
role_names = [r.name for r in matching_roles]
|
||||
logger.info(f"Synced roles for {user.email}: {role_names}")
|
||||
|
||||
return matching_roles
|
||||
|
||||
def user_to_schema(self, user: User) -> UserSchema:
|
||||
"""Convert User model to schema"""
|
||||
return UserSchema(
|
||||
id=user.id,
|
||||
authentik_id=user.authentik_id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
avatar_url=user.avatar_url,
|
||||
created_at=user.created_at,
|
||||
last_login=user.last_login,
|
||||
)
|
||||
|
||||
def roles_to_schema(self, roles: list[Role]) -> list[RoleSchema]:
|
||||
"""Convert Role models to schemas"""
|
||||
return [
|
||||
RoleSchema(name=r.name, domain=r.domain, action=r.action)
|
||||
for r in roles
|
||||
]
|
||||
|
||||
def preferences_to_schema(self, preferences: Optional[UserPreferences]) -> UserPreferencesSchema:
|
||||
"""Convert UserPreferences model to schema"""
|
||||
if preferences is None:
|
||||
return UserPreferencesSchema()
|
||||
|
||||
return UserPreferencesSchema(
|
||||
theme=preferences.theme,
|
||||
default_room=preferences.default_room,
|
||||
preferences_json=preferences.preferences_json or {},
|
||||
)
|
||||
|
||||
async def list_users(
|
||||
self,
|
||||
search: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[UserListItemSchema], int]:
|
||||
"""
|
||||
List all users with optional search and pagination
|
||||
|
||||
Args:
|
||||
search: Optional search query (matches name or email)
|
||||
offset: Number of records to skip
|
||||
limit: Maximum number of records to return
|
||||
|
||||
Returns:
|
||||
Tuple of (list of user schemas, total count)
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
# Base query with roles loaded
|
||||
base_query = select(User).options(selectinload(User.roles))
|
||||
|
||||
# Apply search filter if provided
|
||||
if search:
|
||||
search_filter = f"%{search}%"
|
||||
base_query = base_query.where(
|
||||
(User.name.ilike(search_filter)) | (User.email.ilike(search_filter))
|
||||
)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total_result = await self.session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Apply pagination and ordering
|
||||
query = base_query.order_by(User.name).offset(offset).limit(limit)
|
||||
result = await self.session.execute(query)
|
||||
users = list(result.scalars().all())
|
||||
|
||||
# Convert to schemas
|
||||
items = [
|
||||
UserListItemSchema(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
avatar_url=user.avatar_url,
|
||||
created_at=user.created_at,
|
||||
last_login=user.last_login,
|
||||
roles=[role.name for role in user.roles],
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
|
||||
return items, total
|
||||
|
||||
def _extract_cookie(self, headers: httpx.Headers, cookie_name: str) -> str:
|
||||
"""Extract a specific cookie value from Set-Cookie headers"""
|
||||
for header in headers.get_list('set-cookie'):
|
||||
if header.startswith(f'{cookie_name}='):
|
||||
match = re.match(rf'{cookie_name}=([^;]+)', header)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return ""
|
||||
|
||||
async def _authentik_session_login(self, client: httpx.AsyncClient) -> str:
|
||||
"""
|
||||
Authenticate with Authentik using the flow API to establish a session
|
||||
|
||||
Authentik's flow API requires:
|
||||
1. Cookie persistence between requests (manually handled due to domain restrictions)
|
||||
2. X-authentik-CSRF header set to the authentik_csrf cookie value
|
||||
3. Multi-stage flow handling (identification -> password -> done)
|
||||
|
||||
Args:
|
||||
client: httpx client
|
||||
|
||||
Returns:
|
||||
Session cookie value for subsequent API calls
|
||||
|
||||
Raises:
|
||||
ValueError: If authentication fails
|
||||
"""
|
||||
flow_url = f"{settings.authentik_url}/api/v3/flows/executor/default-authentication-flow/"
|
||||
|
||||
# Step 1: Get the initial flow challenge (this sets the session and csrf cookies)
|
||||
resp = await client.get(flow_url, headers={"Accept": "application/json"})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# Extract cookies manually from Set-Cookie headers (bypasses domain restrictions)
|
||||
session_cookie = self._extract_cookie(resp.headers, "authentik_session")
|
||||
csrf_cookie = self._extract_cookie(resp.headers, "authentik_csrf")
|
||||
|
||||
logger.debug(f"Flow initial: component={data.get('component')}, session={bool(session_cookie)}, csrf={bool(csrf_cookie)}")
|
||||
|
||||
# Build headers with manual cookie and CSRF token
|
||||
def build_headers():
|
||||
hdrs = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Cookie": f"authentik_session={session_cookie}",
|
||||
}
|
||||
if csrf_cookie:
|
||||
hdrs["Cookie"] += f"; authentik_csrf={csrf_cookie}"
|
||||
hdrs["X-authentik-CSRF"] = csrf_cookie
|
||||
return hdrs
|
||||
|
||||
# Step 2: Handle identification stage - submit username
|
||||
if data.get("component") == "ak-stage-identification":
|
||||
resp = await client.post(
|
||||
flow_url,
|
||||
json={"uid_field": settings.authentik_username},
|
||||
headers=build_headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# Update session cookie if new one received
|
||||
new_session = self._extract_cookie(resp.headers, "authentik_session")
|
||||
if new_session:
|
||||
session_cookie = new_session
|
||||
|
||||
logger.debug(f"After username: component={data.get('component')}")
|
||||
|
||||
# Step 3: Handle password stage if required
|
||||
if data.get("component") == "ak-stage-password":
|
||||
resp = await client.post(
|
||||
flow_url,
|
||||
json={"password": settings.authentik_password},
|
||||
headers=build_headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# Update session cookie if new one received
|
||||
new_session = self._extract_cookie(resp.headers, "authentik_session")
|
||||
if new_session:
|
||||
session_cookie = new_session
|
||||
|
||||
logger.debug(f"After password: component={data.get('component')}")
|
||||
|
||||
# Check for access denied
|
||||
if data.get("component") == "ak-stage-access-denied":
|
||||
raise ValueError("Authentik authentication failed: access denied")
|
||||
|
||||
# Check for redirect (successful auth)
|
||||
if data.get("component") == "xak-flow-redirect" or data.get("to"):
|
||||
logger.info("Successfully authenticated with Authentik via flow")
|
||||
return session_cookie
|
||||
|
||||
# If we're still in identification stage, the username might be wrong
|
||||
if data.get("component") == "ak-stage-identification":
|
||||
response_errors = data.get("response_errors", {})
|
||||
raise ValueError(f"Authentication stuck at identification stage: {response_errors}")
|
||||
|
||||
logger.info(f"Authentik flow completed with component: {data.get('component')}")
|
||||
return session_cookie
|
||||
|
||||
async def bulk_sync_from_authentik(self) -> BulkSyncResultSchema:
|
||||
"""
|
||||
Fetch all users from Authentik admin API and sync to local database
|
||||
|
||||
Returns:
|
||||
BulkSyncResultSchema with counts of created/updated/failed users
|
||||
"""
|
||||
if not settings.authentik_username or not settings.authentik_password:
|
||||
raise ValueError("AUTHENTIK_USERNAME and AUTHENTIK_PASSWORD must be configured")
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
failed = 0
|
||||
errors = []
|
||||
total_in_authentik = 0
|
||||
|
||||
# Step 1: Fetch all user data from Authentik API
|
||||
authentik_users = []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
# Authenticate with Authentik to get session cookie
|
||||
session_cookie = await self._authentik_session_login(client)
|
||||
|
||||
# Fetch users from Authentik admin API using session cookie
|
||||
response = await client.get(
|
||||
f"{settings.authentik_url}/api/v3/core/users/",
|
||||
params={"page_size": 500},
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Cookie": f"authentik_session={session_cookie}",
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise ValueError("Authentik API token is invalid or expired")
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
authentik_users = data.get("results", [])
|
||||
total_in_authentik = data.get("pagination", {}).get("count", len(authentik_users))
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise ValueError(f"Authentik API error: {e.response.status_code}")
|
||||
except httpx.RequestError as e:
|
||||
raise ValueError(f"Failed to connect to Authentik: {str(e)}")
|
||||
|
||||
# Step 2: Sync users to database (outside of httpx context to avoid greenlet issues)
|
||||
for auth_user in authentik_users:
|
||||
try:
|
||||
# Skip service accounts and inactive users
|
||||
if auth_user.get("type") in ("service_account", "internal_service_account"):
|
||||
continue
|
||||
if not auth_user.get("is_active", True):
|
||||
continue
|
||||
|
||||
# Extract user data from Authentik
|
||||
authentik_id = uuid.UUID(auth_user["uuid"])
|
||||
email = auth_user.get("email") or f"{auth_user['username']}@local"
|
||||
name = auth_user.get("name") or auth_user.get("username", "Unknown")
|
||||
avatar_url = auth_user.get("avatar")
|
||||
|
||||
# Get user's groups for role mapping
|
||||
groups = []
|
||||
groups_summary = auth_user.get("groups_obj", [])
|
||||
for group in groups_summary:
|
||||
groups.append(group.get("name", ""))
|
||||
|
||||
# Check if user exists
|
||||
stmt = select(User).where(User.authentik_id == authentik_id)
|
||||
result = await self.session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
# Create new user
|
||||
user = User(
|
||||
authentik_id=authentik_id,
|
||||
email=email,
|
||||
name=name,
|
||||
avatar_url=avatar_url,
|
||||
)
|
||||
self.session.add(user)
|
||||
await self.session.flush()
|
||||
|
||||
# Create default preferences
|
||||
preferences = UserPreferences(user_id=user.id)
|
||||
self.session.add(preferences)
|
||||
created += 1
|
||||
logger.info(f"Created user from Authentik: {email}")
|
||||
else:
|
||||
# Update existing user
|
||||
user.email = email
|
||||
user.name = name
|
||||
user.avatar_url = avatar_url
|
||||
updated += 1
|
||||
logger.info(f"Updated user from Authentik: {email}")
|
||||
|
||||
# Sync roles from groups
|
||||
await self.sync_roles(user, groups)
|
||||
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
error_msg = f"Failed to sync user {auth_user.get('username', 'unknown')}: {str(e)}"
|
||||
errors.append(error_msg)
|
||||
logger.warning(error_msg)
|
||||
|
||||
# Commit all changes
|
||||
await self.session.commit()
|
||||
|
||||
return BulkSyncResultSchema(
|
||||
created=created,
|
||||
updated=updated,
|
||||
failed=failed,
|
||||
total_in_authentik=total_in_authentik,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
# Factory function for dependency injection
|
||||
def get_auth_service(session: AsyncSession) -> AuthService:
|
||||
"""Create AuthService instance with database session"""
|
||||
return AuthService(session)
|
||||
+9
-5
@@ -50,11 +50,9 @@ class Settings(BaseSettings):
|
||||
ollama_timeout: int = 300 # 5 minutes
|
||||
|
||||
# Model Configuration
|
||||
default_model: str = "mistral-tools:7b"
|
||||
agent_model: str = "gemma2:9b-instruct-q5_K_M" # Must support tool calling with ADK (~4GB VRAM)
|
||||
lightweight_models: str = "gemma3-tools:1b,phi3:mini"
|
||||
heavy_models: str = "mistral:7b,gemma2:9b,gemma3:12b,mixtral:8x7b"
|
||||
code_models: str = "codestral:latest,codegemma:latest"
|
||||
default_model: str = "mistral-nemo-large:latest"
|
||||
agent_model: str = "mistral-nemo-large:latest" # Must support tool calling with ADK (~4GB VRAM)
|
||||
code_models: str = "mistral-nemo-large:latest"
|
||||
# Previous config (gemma3:12b used ~10GB VRAM)
|
||||
# default_model: str = "gemma3:12b"
|
||||
# agent_model: str = "gemma3:12b"
|
||||
@@ -122,6 +120,12 @@ class Settings(BaseSettings):
|
||||
oidc_issuer: str = "https://auth.schweitz.net/application/o/core-api/"
|
||||
oidc_audience: str = "core-api"
|
||||
|
||||
# Authentik API (for token validation and user management)
|
||||
# Must use domain name (not IP) when AUTHENTIK_COOKIE_DOMAIN is set
|
||||
authentik_url: str = "https://auth.schweitz.net" # Authentik base URL
|
||||
authentik_username: str = "" # Admin username for API access (AUTHENTIK_USERNAME env var)
|
||||
authentik_password: str = "" # Admin password for API access (AUTHENTIK_PASSWORD env var)
|
||||
|
||||
@property
|
||||
def model_aliases(self) -> dict:
|
||||
"""Computed property for model aliases"""
|
||||
|
||||
@@ -10,6 +10,7 @@ from src.controllers.base import BaseController
|
||||
from src.config import get_settings
|
||||
from src.logging_config import get_logger
|
||||
from src.models.ollama_client import get_ollama_client
|
||||
from src.db import get_database
|
||||
|
||||
|
||||
|
||||
@@ -59,18 +60,15 @@ class HealthController(BaseController):
|
||||
)
|
||||
async def health_check():
|
||||
"""
|
||||
Simple health check endpoint for container orchestration
|
||||
Fast health check endpoint for container orchestration
|
||||
|
||||
Returns a 200 OK status when the service is running properly.
|
||||
Used by Docker, Kubernetes, and load balancers.
|
||||
Returns a 200 OK immediately if the service is running.
|
||||
Does NOT check backend connectivity (use /health/full for that).
|
||||
Used by Docker, Kubernetes, and load balancers for liveness probes.
|
||||
"""
|
||||
ollama_client = get_ollama_client()
|
||||
ollama_healthy = await ollama_client.health_check()
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": settings.app_version,
|
||||
"ollama_connected": ollama_healthy
|
||||
"version": settings.app_version
|
||||
}
|
||||
|
||||
@router.get(
|
||||
@@ -131,7 +129,18 @@ class HealthController(BaseController):
|
||||
ollama_error = str(e)
|
||||
logger.warning(f"Ollama health check failed: {ollama_error}")
|
||||
|
||||
is_healthy = ollama_healthy
|
||||
# Check 2: Database connection
|
||||
database = get_database()
|
||||
db_healthy = False
|
||||
db_error = None
|
||||
|
||||
try:
|
||||
db_healthy = await database.health_check()
|
||||
except Exception as e:
|
||||
db_error = str(e)
|
||||
logger.warning(f"Database health check failed: {db_error}")
|
||||
|
||||
is_healthy = ollama_healthy and db_healthy
|
||||
|
||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||
status_code = 200 if is_healthy else 503
|
||||
@@ -149,6 +158,10 @@ class HealthController(BaseController):
|
||||
"available": False
|
||||
},
|
||||
"error": ollama_error
|
||||
},
|
||||
"database": {
|
||||
"status": "✅ healthy" if db_healthy else "❌ unhealthy",
|
||||
"error": db_error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Database package for Core-API
|
||||
|
||||
Provides async PostgreSQL database connectivity using SQLAlchemy 2.0.
|
||||
"""
|
||||
from src.db.database import (
|
||||
get_async_session,
|
||||
get_database,
|
||||
Database,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_async_session",
|
||||
"get_database",
|
||||
"Database",
|
||||
]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Database Connection Module
|
||||
|
||||
Provides async PostgreSQL connectivity using SQLAlchemy 2.0 with asyncpg driver.
|
||||
Follows the existing singleton pattern used throughout core-api.
|
||||
"""
|
||||
from typing import AsyncGenerator, Optional
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
AsyncEngine,
|
||||
create_async_engine,
|
||||
async_sessionmaker,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from src.config import get_settings
|
||||
from src.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""
|
||||
SQLAlchemy declarative base for all models
|
||||
|
||||
All database models should inherit from this class.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Database:
|
||||
"""
|
||||
Async database connection manager
|
||||
|
||||
Provides async engine and session factory for PostgreSQL connections.
|
||||
Uses asyncpg driver for optimal async performance.
|
||||
"""
|
||||
|
||||
def __init__(self, database_url: Optional[str] = None):
|
||||
"""
|
||||
Initialize database connection manager
|
||||
|
||||
Args:
|
||||
database_url: PostgreSQL connection URL (default from settings)
|
||||
"""
|
||||
# Convert postgresql:// to postgresql+asyncpg:// for async driver
|
||||
url = database_url or settings.database_url
|
||||
if url.startswith("postgresql://"):
|
||||
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
self._url = url
|
||||
self._engine: Optional[AsyncEngine] = None
|
||||
self._session_factory: Optional[async_sessionmaker[AsyncSession]] = None
|
||||
|
||||
@property
|
||||
def engine(self) -> AsyncEngine:
|
||||
"""
|
||||
Get or create the async database engine
|
||||
|
||||
Returns:
|
||||
AsyncEngine instance
|
||||
"""
|
||||
if self._engine is None:
|
||||
self._engine = create_async_engine(
|
||||
self._url,
|
||||
echo=settings.debug, # Log SQL in debug mode
|
||||
poolclass=NullPool, # Disable connection pooling for serverless compatibility
|
||||
)
|
||||
logger.info(f"Database engine created for {self._url.split('@')[-1]}")
|
||||
return self._engine
|
||||
|
||||
@property
|
||||
def session_factory(self) -> async_sessionmaker[AsyncSession]:
|
||||
"""
|
||||
Get or create the async session factory
|
||||
|
||||
Returns:
|
||||
Session factory for creating database sessions
|
||||
"""
|
||||
if self._session_factory is None:
|
||||
self._session_factory = async_sessionmaker(
|
||||
bind=self.engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
return self._session_factory
|
||||
|
||||
async def create_tables(self) -> None:
|
||||
"""
|
||||
Create all database tables
|
||||
|
||||
Should only be used for development/testing.
|
||||
Use Alembic migrations for production.
|
||||
"""
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
logger.info("Database tables created")
|
||||
|
||||
async def drop_tables(self) -> None:
|
||||
"""
|
||||
Drop all database tables
|
||||
|
||||
WARNING: Destroys all data. Use with caution.
|
||||
"""
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
logger.warning("Database tables dropped")
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""
|
||||
Check if database connection is healthy
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
async with self.session_factory() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Database health check failed: {e}")
|
||||
return False
|
||||
|
||||
async def close(self) -> None:
|
||||
"""
|
||||
Close database connections and dispose of engine
|
||||
"""
|
||||
if self._engine is not None:
|
||||
await self._engine.dispose()
|
||||
self._engine = None
|
||||
self._session_factory = None
|
||||
logger.info("Database connections closed")
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_database: Optional[Database] = None
|
||||
|
||||
|
||||
def get_database() -> Database:
|
||||
"""
|
||||
Get singleton database instance
|
||||
|
||||
Returns:
|
||||
Database instance
|
||||
"""
|
||||
global _database
|
||||
if _database is None:
|
||||
_database = Database()
|
||||
return _database
|
||||
|
||||
|
||||
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
FastAPI dependency for database sessions
|
||||
|
||||
Yields an async session that is automatically closed after the request.
|
||||
|
||||
Usage:
|
||||
@router.get("/items")
|
||||
async def get_items(session: AsyncSession = Depends(get_async_session)):
|
||||
result = await session.execute(select(Item))
|
||||
return result.scalars().all()
|
||||
|
||||
Yields:
|
||||
AsyncSession instance
|
||||
"""
|
||||
database = get_database()
|
||||
async with database.session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
SQLAlchemy Models for Core-API
|
||||
|
||||
Database models for authentication, authorization, and user management.
|
||||
"""
|
||||
from src.db.models.user import User
|
||||
from src.db.models.role import Role, UserRole
|
||||
from src.db.models.user_preferences import UserPreferences
|
||||
from src.db.models.api_key import ApiKey
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Role",
|
||||
"UserRole",
|
||||
"UserPreferences",
|
||||
"ApiKey",
|
||||
]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
API Key Model
|
||||
|
||||
Provides API key authentication as fallback for OIDC.
|
||||
Keys are tied to user accounts and inherit user permissions.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, ForeignKey, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID, ARRAY
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.database import Base
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.db.models.user import User
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""
|
||||
API Key model for programmatic access
|
||||
|
||||
API keys provide an alternative to OIDC for:
|
||||
- Local development without SSO
|
||||
- Service-to-service communication
|
||||
- Scripts and automation
|
||||
|
||||
Keys inherit the user's roles but can optionally
|
||||
be restricted to a subset of scopes.
|
||||
"""
|
||||
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
comment="Human-readable key name (e.g., 'Dev Laptop', 'CI/CD')",
|
||||
)
|
||||
key_hash: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="SHA-256 hash of the API key",
|
||||
)
|
||||
key_prefix: Mapped[str] = mapped_column(
|
||||
String(8),
|
||||
nullable=False,
|
||||
comment="First 8 chars of key for identification (e.g., 'cak_abc1')",
|
||||
)
|
||||
scopes: Mapped[List[str] | None] = mapped_column(
|
||||
ARRAY(String),
|
||||
nullable=True,
|
||||
comment="Optional scope restriction (subset of user roles)",
|
||||
)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Optional expiration timestamp",
|
||||
)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Last time this key was used",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="api_keys",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ApiKey {self.key_prefix}... ({self.name})>"
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the API key has expired"""
|
||||
if self.expires_at is None:
|
||||
return False
|
||||
return datetime.now(self.expires_at.tzinfo) > self.expires_at
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Role Models
|
||||
|
||||
Defines domain-scoped permissions mapped from Authentik groups.
|
||||
Format: {domain}:{action} (e.g., control-room:admin, media:viewer)
|
||||
"""
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlalchemy import String, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.db.models.user import User
|
||||
|
||||
|
||||
class Role(Base):
|
||||
"""
|
||||
Role model for domain-scoped permissions
|
||||
|
||||
Roles are seeded from configuration, not user-editable.
|
||||
Each role maps to an Authentik group (e.g., tatlock-control-room-admin).
|
||||
|
||||
Domains: control-room, library, media, ai, housekeeper, developer, documents, gaming, admin
|
||||
Actions: viewer, user, editor, admin (hierarchical)
|
||||
"""
|
||||
|
||||
__tablename__ = "roles"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Role name in format domain:action (e.g., control-room:admin)",
|
||||
)
|
||||
domain: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Permission domain (e.g., control-room, media, ai)",
|
||||
)
|
||||
action: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
comment="Permission action (viewer, user, editor, admin)",
|
||||
)
|
||||
authentik_group: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
unique=True,
|
||||
comment="Corresponding Authentik group name (e.g., tatlock-control-room-admin)",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
users: Mapped[List["User"]] = relationship(
|
||||
"User",
|
||||
secondary="user_roles",
|
||||
back_populates="roles",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Role {self.name}>"
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""
|
||||
Association table for User-Role many-to-many relationship
|
||||
|
||||
Synced from Authentik groups during user authentication.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
role_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("roles.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
User Model
|
||||
|
||||
Represents users synced from Authentik SSO.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.db.models.role import Role
|
||||
from src.db.models.user_preferences import UserPreferences
|
||||
from src.db.models.api_key import ApiKey
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""
|
||||
User model synced from Authentik
|
||||
|
||||
Users are created/updated when they authenticate via OIDC.
|
||||
The authentik_id links to the Authentik user record.
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
authentik_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
email: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
avatar_url: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
)
|
||||
api_keys_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
last_login: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
roles: Mapped[List["Role"]] = relationship(
|
||||
"Role",
|
||||
secondary="user_roles",
|
||||
back_populates="users",
|
||||
lazy="selectin",
|
||||
)
|
||||
preferences: Mapped["UserPreferences"] = relationship(
|
||||
"UserPreferences",
|
||||
back_populates="user",
|
||||
uselist=False,
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
api_keys: Mapped[List["ApiKey"]] = relationship(
|
||||
"ApiKey",
|
||||
back_populates="user",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.email}>"
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
User Preferences Model
|
||||
|
||||
Stores user-specific settings like theme and default room.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import String, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.database import Base
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.db.models.user import User
|
||||
|
||||
|
||||
class UserPreferences(Base):
|
||||
"""
|
||||
User preferences model
|
||||
|
||||
Stores user-specific settings that persist across sessions.
|
||||
Extended settings stored in preferences_json for flexibility.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_preferences"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
theme: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="system",
|
||||
nullable=False,
|
||||
comment="Theme preference: system, light, dark",
|
||||
)
|
||||
default_room: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="front-hall",
|
||||
nullable=False,
|
||||
comment="Default room for housekeeping features",
|
||||
)
|
||||
preferences_json: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
comment="Extended preferences as JSON",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="preferences",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<UserPreferences user_id={self.user_id}>"
|
||||
+12
@@ -9,11 +9,13 @@ from contextlib import asynccontextmanager
|
||||
from src.config import get_settings
|
||||
from src.logging_config import setup_logging, get_logger
|
||||
from src.models.ollama_client import get_ollama_client, close_ollama_client
|
||||
from src.db import get_database
|
||||
from src.controllers.infrastructure_controller import infrastructure_controller
|
||||
from src.controllers.tools_controller import tools_controller
|
||||
from src.controllers.health_controller import health_controller
|
||||
from src.controllers.static_controller import static_controller
|
||||
from src.controllers.housekeeping_controller import housekeeping_controller
|
||||
from src.auth.controller import auth_controller
|
||||
from src.security import initialize_oidc
|
||||
|
||||
# Initialize settings
|
||||
@@ -48,6 +50,14 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
logger.warning("✗ Ollama connection failed - AI features may not work")
|
||||
|
||||
# Check database connectivity
|
||||
database = get_database()
|
||||
db_healthy = await database.health_check()
|
||||
if db_healthy:
|
||||
logger.info("✓ Database connection successful")
|
||||
else:
|
||||
logger.warning("✗ Database connection failed - auth features may not work")
|
||||
|
||||
# Initialize security (OIDC authentication)
|
||||
initialize_oidc(settings)
|
||||
|
||||
@@ -56,6 +66,7 @@ async def lifespan(app: FastAPI):
|
||||
# Shutdown
|
||||
logger.info("Shutting down application")
|
||||
await close_ollama_client()
|
||||
await database.close()
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
@@ -96,6 +107,7 @@ app.add_middleware(
|
||||
|
||||
# Include controller routers
|
||||
app.include_router(health_controller.router) # / and /health
|
||||
app.include_router(auth_controller.router) # /auth/*
|
||||
app.include_router(tools_controller.router) # /tools/*
|
||||
app.include_router(infrastructure_controller.router) # /infrastructure/*
|
||||
app.include_router(housekeeping_controller.router) # /housekeeping/*
|
||||
|
||||
Reference in New Issue
Block a user