feat(app): add plugin system with auth plugin and static assets
Add new application structure: - app/main.py - application entry point - app/plugins/ - plugin system with auth plugin: - api/ - REST API endpoints and schemas - authorization/ - auth policies, providers, hooks - domain/ - business logic (service, models, jwt, password) - injection/ - route injection and guards - ops/ - operational utilities - runtime/ - runtime configuration - security/ - middleware, CSRF, dependencies - storage/ - user repositories and models - app/static/ - static assets (scalar.js for API docs) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
"""Auth plugin storage package.
|
||||
|
||||
This package owns auth-specific ORM models and repositories while
|
||||
continuing to use the application's shared persistence infrastructure.
|
||||
"""
|
||||
|
||||
from app.plugins.auth.storage.contracts import User, UserCreate, UserRepositoryProtocol
|
||||
from app.plugins.auth.storage.models import User as UserModel
|
||||
from app.plugins.auth.storage.repositories import DbUserRepository
|
||||
|
||||
__all__ = [
|
||||
"DbUserRepository",
|
||||
"User",
|
||||
"UserCreate",
|
||||
"UserModel",
|
||||
"UserRepositoryProtocol",
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
def _new_user_id() -> str:
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str = Field(default_factory=_new_user_id)
|
||||
email: str
|
||||
password_hash: str | None = None
|
||||
system_role: str = "user"
|
||||
oauth_provider: str | None = None
|
||||
oauth_id: str | None = None
|
||||
needs_setup: bool = False
|
||||
token_version: int = 0
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str
|
||||
email: str
|
||||
password_hash: str | None
|
||||
system_role: str
|
||||
oauth_provider: str | None
|
||||
oauth_id: str | None
|
||||
needs_setup: bool
|
||||
token_version: int
|
||||
created_time: datetime
|
||||
updated_time: datetime | None
|
||||
|
||||
|
||||
class UserRepositoryProtocol(Protocol):
|
||||
async def create_user(self, data: UserCreate) -> User: ...
|
||||
|
||||
async def get_user_by_id(self, user_id: str) -> User | None: ...
|
||||
|
||||
async def get_user_by_email(self, email: str) -> User | None: ...
|
||||
|
||||
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None: ...
|
||||
|
||||
async def update_user(self, data: User) -> User: ...
|
||||
|
||||
async def count_users(self) -> int: ...
|
||||
|
||||
async def count_admin_users(self) -> int: ...
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from store.persistence.base_model import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Application user table."""
|
||||
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("oauth_provider", "oauth_id", name="uq_users_oauth_identity"),
|
||||
{"comment": "Application user table."},
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255), default=None)
|
||||
system_role: Mapped[str] = mapped_column(String(16), default="user", index=True)
|
||||
oauth_provider: Mapped[str | None] = mapped_column(String(64), default=None)
|
||||
oauth_id: Mapped[str | None] = mapped_column(String(255), default=None)
|
||||
needs_setup: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
token_version: Mapped[int] = mapped_column(Integer, default=0)
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.auth.storage.contracts import User, UserCreate, UserRepositoryProtocol
|
||||
from app.plugins.auth.storage.models import User as UserModel
|
||||
|
||||
|
||||
def _to_user(model: UserModel) -> User:
|
||||
return User(
|
||||
id=model.id,
|
||||
email=model.email,
|
||||
password_hash=model.password_hash,
|
||||
system_role=model.system_role,
|
||||
oauth_provider=model.oauth_provider,
|
||||
oauth_id=model.oauth_id,
|
||||
needs_setup=model.needs_setup,
|
||||
token_version=model.token_version,
|
||||
created_time=model.created_time,
|
||||
updated_time=model.updated_time,
|
||||
)
|
||||
|
||||
|
||||
class DbUserRepository(UserRepositoryProtocol):
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def create_user(self, data: UserCreate) -> User:
|
||||
model = UserModel(
|
||||
id=data.id,
|
||||
email=data.email,
|
||||
password_hash=data.password_hash,
|
||||
system_role=data.system_role,
|
||||
oauth_provider=data.oauth_provider,
|
||||
oauth_id=data.oauth_id,
|
||||
needs_setup=data.needs_setup,
|
||||
token_version=data.token_version,
|
||||
)
|
||||
self._session.add(model)
|
||||
try:
|
||||
await self._session.flush()
|
||||
except IntegrityError as exc:
|
||||
await self._session.rollback()
|
||||
raise ValueError("User already exists") from exc
|
||||
await self._session.refresh(model)
|
||||
return _to_user(model)
|
||||
|
||||
async def get_user_by_id(self, user_id: str) -> User | None:
|
||||
model = await self._session.get(UserModel, user_id)
|
||||
return _to_user(model) if model else None
|
||||
|
||||
async def get_user_by_email(self, email: str) -> User | None:
|
||||
result = await self._session.execute(select(UserModel).where(UserModel.email == email))
|
||||
model = result.scalar_one_or_none()
|
||||
return _to_user(model) if model else None
|
||||
|
||||
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
|
||||
result = await self._session.execute(
|
||||
select(UserModel).where(
|
||||
UserModel.oauth_provider == provider,
|
||||
UserModel.oauth_id == oauth_id,
|
||||
)
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
return _to_user(model) if model else None
|
||||
|
||||
async def update_user(self, data: User) -> User:
|
||||
model = await self._session.get(UserModel, data.id)
|
||||
if model is None:
|
||||
raise LookupError(f"User {data.id} not found")
|
||||
model.email = data.email
|
||||
model.password_hash = data.password_hash
|
||||
model.system_role = data.system_role
|
||||
model.oauth_provider = data.oauth_provider
|
||||
model.oauth_id = data.oauth_id
|
||||
model.needs_setup = data.needs_setup
|
||||
model.token_version = data.token_version
|
||||
try:
|
||||
await self._session.flush()
|
||||
except IntegrityError as exc:
|
||||
await self._session.rollback()
|
||||
raise ValueError("User already exists") from exc
|
||||
await self._session.refresh(model)
|
||||
return _to_user(model)
|
||||
|
||||
async def count_users(self) -> int:
|
||||
return await self._session.scalar(select(func.count()).select_from(UserModel)) or 0
|
||||
|
||||
async def count_admin_users(self) -> int:
|
||||
return (
|
||||
await self._session.scalar(
|
||||
select(func.count()).select_from(UserModel).where(UserModel.system_role == "admin")
|
||||
)
|
||||
or 0
|
||||
)
|
||||
Reference in New Issue
Block a user