feat: add authentication and user management with Authentik integration
Build and Push / build (release) Successful in 1m16s
Build and Push / build (release) Successful in 1m16s
- Add PostgreSQL database with async SQLAlchemy - Add Alembic migrations for schema management - Add User, Role, UserPreferences, ApiKey models - Add auth endpoints: /auth/me, /auth/users, /auth/users/sync-from-authentik - Add token validation via Authentik userinfo endpoint - Add bulk user sync from Authentik admin API - Add database health check to diagnostics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
cdb6344013
commit
4f45f9bf37
@@ -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}>"
|
||||
Reference in New Issue
Block a user