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:
rayhpeng
2026-04-22 11:31:42 +08:00
parent a0ab3a3dd4
commit 0f82f8a3a2
47 changed files with 5516 additions and 0 deletions
@@ -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
)