feat: auth & admin
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
"""ORM models package.
|
||||
|
||||
Importing this package registers every model on ``Base.metadata`` so Alembic
|
||||
autogenerate and ``create_all`` (tests) see the full schema. ``alembic/env.py``
|
||||
imports it for exactly this side effect.
|
||||
"""
|
||||
|
||||
from app.infrastructure.db.models.user import RefreshTokenModel, UserModel
|
||||
|
||||
__all__ = ["RefreshTokenModel", "UserModel"]
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Reusable mapped-column mixins for ORM models."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class UUIDPrimaryKeyMixin:
|
||||
"""``id`` UUID primary key, generated application-side.
|
||||
|
||||
Generating in Python (not a DB default) keeps ids available before flush —
|
||||
important because ``track.id`` is the client-facing ``content_id``.
|
||||
"""
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""``created_at`` / ``updated_at``, server-managed.
|
||||
|
||||
Present on every user-mutable entity for future delta-sync (plan §4).
|
||||
"""
|
||||
|
||||
created_at: Mapped[dt.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[dt.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""ORM models for users and refresh tokens."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.infrastructure.db.base import Base
|
||||
from app.infrastructure.db.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class UserModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
# Admin is a single flag in Phase 1 — no role system (plan §3.5).
|
||||
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
|
||||
class RefreshTokenModel(UUIDPrimaryKeyMixin, Base):
|
||||
"""A persisted, revocable refresh token (offline-first sessions).
|
||||
|
||||
Stores only a *hash* of the token, never the raw JWT. Rotated on every
|
||||
refresh (old jti revoked, new row added); logout revokes the current jti.
|
||||
"""
|
||||
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
jti: Mapped[uuid.UUID] = mapped_column(unique=True, index=True, nullable=False)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
expires_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
revoked_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[dt.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: dt.datetime.now(dt.UTC),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""SQLAlchemy repository adapters implementing the domain ports."""
|
||||
|
||||
from app.infrastructure.db.repositories.refresh_token_repository import (
|
||||
SqlAlchemyRefreshTokenRepository,
|
||||
)
|
||||
from app.infrastructure.db.repositories.user_repository import SqlAlchemyUserRepository
|
||||
|
||||
__all__ = ["SqlAlchemyRefreshTokenRepository", "SqlAlchemyUserRepository"]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Refresh-token repository — adapter implementing
|
||||
``app.domain.ports.RefreshTokenRepository``.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.infrastructure.db.models import RefreshTokenModel
|
||||
|
||||
|
||||
class SqlAlchemyRefreshTokenRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def add(
|
||||
self,
|
||||
*,
|
||||
jti: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
token_hash: str,
|
||||
expires_at: dt.datetime,
|
||||
) -> None:
|
||||
self._session.add(
|
||||
RefreshTokenModel(
|
||||
jti=jti,
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
await self._session.flush()
|
||||
|
||||
async def is_valid(self, jti: uuid.UUID) -> bool:
|
||||
row = (
|
||||
await self._session.execute(
|
||||
select(RefreshTokenModel).where(RefreshTokenModel.jti == jti)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None or row.revoked_at is not None:
|
||||
return False
|
||||
return row.expires_at > dt.datetime.now(dt.UTC)
|
||||
|
||||
async def revoke(self, jti: uuid.UUID) -> None:
|
||||
await self._session.execute(
|
||||
update(RefreshTokenModel)
|
||||
.where(RefreshTokenModel.jti == jti, RefreshTokenModel.revoked_at.is_(None))
|
||||
.values(revoked_at=dt.datetime.now(dt.UTC))
|
||||
)
|
||||
|
||||
async def revoke_all_for_user(self, user_id: uuid.UUID) -> None:
|
||||
await self._session.execute(
|
||||
update(RefreshTokenModel)
|
||||
.where(
|
||||
RefreshTokenModel.user_id == user_id,
|
||||
RefreshTokenModel.revoked_at.is_(None),
|
||||
)
|
||||
.values(revoked_at=dt.datetime.now(dt.UTC))
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""User repository — adapter over ``AsyncSession`` implementing
|
||||
``app.domain.ports.UserRepository``.
|
||||
|
||||
Translates between ORM rows (``UserModel``) and domain entities (``User`` /
|
||||
``Credentials``). The domain never sees ORM objects.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities import Credentials, User
|
||||
from app.domain.errors import NotFoundError
|
||||
from app.infrastructure.db.models import UserModel
|
||||
|
||||
|
||||
def _to_entity(row: UserModel) -> User:
|
||||
return User(
|
||||
id=row.id,
|
||||
username=row.username,
|
||||
is_superuser=row.is_superuser,
|
||||
is_active=row.is_active,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class SqlAlchemyUserRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def _get_row(self, user_id: uuid.UUID) -> UserModel:
|
||||
row = await self._session.get(UserModel, user_id)
|
||||
if row is None:
|
||||
raise NotFoundError("User not found.")
|
||||
return row
|
||||
|
||||
async def get_by_id(self, user_id: uuid.UUID) -> User | None:
|
||||
row = await self._session.get(UserModel, user_id)
|
||||
return _to_entity(row) if row is not None else None
|
||||
|
||||
async def get_credentials_by_username(self, username: str) -> Credentials | None:
|
||||
row = (
|
||||
await self._session.execute(select(UserModel).where(UserModel.username == username))
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return Credentials(user=_to_entity(row), password_hash=row.password_hash)
|
||||
|
||||
async def add(self, *, username: str, password_hash: str, is_superuser: bool) -> User:
|
||||
row = UserModel(
|
||||
username=username,
|
||||
password_hash=password_hash,
|
||||
is_superuser=is_superuser,
|
||||
is_active=True,
|
||||
)
|
||||
self._session.add(row)
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def list(self, *, limit: int, offset: int) -> list[User]:
|
||||
rows = (
|
||||
await self._session.execute(
|
||||
select(UserModel).order_by(UserModel.created_at).limit(limit).offset(offset)
|
||||
)
|
||||
).scalars()
|
||||
return [_to_entity(row) for row in rows]
|
||||
|
||||
async def set_password_hash(self, user_id: uuid.UUID, password_hash: str) -> None:
|
||||
row = await self._get_row(user_id)
|
||||
row.password_hash = password_hash
|
||||
await self._session.flush()
|
||||
|
||||
async def set_superuser(self, user_id: uuid.UUID, is_superuser: bool) -> User:
|
||||
row = await self._get_row(user_id)
|
||||
row.is_superuser = is_superuser
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def set_active(self, user_id: uuid.UUID, is_active: bool) -> User:
|
||||
row = await self._get_row(user_id)
|
||||
row.is_active = is_active
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def count(self) -> int:
|
||||
return (
|
||||
await self._session.execute(select(func.count()).select_from(UserModel))
|
||||
).scalar_one()
|
||||
Reference in New Issue
Block a user