Phase 0 — Foundation: backend, data model, local auth, frontend, deploy + CI #1

Merged
justin merged 17 commits from phase-0-foundation into main 2026-06-06 11:32:31 -04:00
15 changed files with 497 additions and 31 deletions
Showing only changes of commit 00bfe8bfca - Show all commits
+32 -17
View File
@@ -1,33 +1,48 @@
"""Shared API dependencies."""
"""Shared API dependencies: DB session, the authenticated user, and the mailer."""
import uuid
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.db import get_session
from app.integrations.mailer.base import Mailer
from app.integrations.mailer.console import ConsoleMailer
from app.integrations.mailer.smtp import SMTPMailer
from app.models.user import User
from app.services.user_service import get_user
from app.services import auth_service
SessionDep = Annotated[AsyncSession, Depends(get_session)]
async def get_current_user(
session: SessionDep,
x_user_id: Annotated[uuid.UUID | None, Header()] = None,
) -> User:
"""TEMPORARY pre-auth shim: identifies the caller via the ``X-User-Id``
header. Replaced by the AuthProvider (sessions/tokens) in the auth slice.
The assistant principal will also be minted here, scoped to its user."""
if x_user_id is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, "X-User-Id header required (pre-auth)"
)
user = await get_user(session, x_user_id)
def extract_session_token(request: Request) -> str | None:
"""Bearer header (API clients) takes precedence over the session cookie
(browser)."""
authorization = request.headers.get("authorization")
if authorization and authorization.lower().startswith("bearer "):
return authorization[7:].strip()
return request.cookies.get(get_settings().cookie_name)
async def get_current_user(request: Request, session: SessionDep) -> User:
raw_token = extract_session_token(request)
if raw_token is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "authentication required")
user = await auth_service.resolve_session_user(session, raw_token=raw_token)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "unknown user")
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid or expired session")
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
def get_mailer() -> Mailer:
settings = get_settings()
if settings.mailer == "smtp" and settings.smtp_host:
return SMTPMailer(settings)
return ConsoleMailer()
MailerDep = Annotated[Mailer, Depends(get_mailer)]
+2 -1
View File
@@ -2,9 +2,10 @@
from fastapi import APIRouter
from app.api.v1 import persons, trees, users
from app.api.v1 import auth, persons, trees, users
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth.router)
api_router.include_router(users.router)
api_router.include_router(trees.router)
api_router.include_router(persons.router)
+81
View File
@@ -0,0 +1,81 @@
from fastapi import APIRouter, HTTPException, Request, Response, status
from app.api.deps import MailerDep, SessionDep, extract_session_token
from app.core.config import get_settings
from app.schemas.auth import (
LoginRequest,
PasswordResetConfirm,
PasswordResetRequest,
RegisterRequest,
SessionRead,
TokenRequest,
)
from app.schemas.user import UserRead
from app.services import auth_service
router = APIRouter(prefix="/auth", tags=["auth"])
def _set_session_cookie(response: Response, token: str) -> None:
settings = get_settings()
response.set_cookie(
settings.cookie_name,
token,
max_age=settings.session_ttl_days * 86400,
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
)
@router.post("/register", response_model=SessionRead, status_code=status.HTTP_201_CREATED)
async def register(
data: RegisterRequest, session: SessionDep, mailer: MailerDep, response: Response
) -> SessionRead:
user, token, expires_at = await auth_service.register(
session,
mailer,
email=data.email,
password=data.password,
display_name=data.display_name,
)
_set_session_cookie(response, token)
return SessionRead(user=UserRead.model_validate(user), token=token, expires_at=expires_at)
@router.post("/login", response_model=SessionRead)
async def login(data: LoginRequest, session: SessionDep, response: Response) -> SessionRead:
result = await auth_service.login(session, email=data.email, password=data.password)
if result is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
user, token, expires_at = result
_set_session_cookie(response, token)
return SessionRead(user=UserRead.model_validate(user), token=token, expires_at=expires_at)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(request: Request, session: SessionDep, response: Response) -> None:
raw_token = extract_session_token(request)
if raw_token:
await auth_service.logout(session, raw_token=raw_token)
response.delete_cookie(get_settings().cookie_name)
@router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT)
async def verify_email(data: TokenRequest, session: SessionDep) -> None:
await auth_service.verify_email(session, raw_token=data.token)
@router.post("/request-password-reset", status_code=status.HTTP_202_ACCEPTED)
async def request_password_reset(
data: PasswordResetRequest, session: SessionDep, mailer: MailerDep
) -> dict:
await auth_service.request_password_reset(session, mailer, email=data.email)
return {"status": "accepted"}
@router.post("/reset-password", status_code=status.HTTP_204_NO_CONTENT)
async def reset_password(data: PasswordResetConfirm, session: SessionDep) -> None:
await auth_service.reset_password(
session, raw_token=data.token, new_password=data.new_password
)
+3 -13
View File
@@ -1,21 +1,11 @@
from fastapi import APIRouter, status
from fastapi import APIRouter
from app.api.deps import CurrentUser, SessionDep
from app.schemas.user import UserCreate, UserRead
from app.services import user_service
from app.api.deps import CurrentUser
from app.schemas.user import UserRead
router = APIRouter(prefix="/users", tags=["users"])
@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def create_user(data: UserCreate, session: SessionDep) -> UserRead:
# Open dev bootstrap until the auth slice; lets us create tree owners.
user = await user_service.create_user(
session, email=data.email, display_name=data.display_name
)
return UserRead.model_validate(user)
@router.get("/me", response_model=UserRead)
async def read_me(current: CurrentUser) -> UserRead:
return UserRead.model_validate(current)
+24
View File
@@ -0,0 +1,24 @@
"""AuthProvider interface.
Operators enable any subset of providers (local, OIDC, social). A provider's
job is narrow: verify a credential and return the matching User (or None).
Session issuance, tokens, and registration live in the auth service and are
provider-agnostic, so adding OIDC/social later (Phase 5) is additive.
"""
from abc import ABC, abstractmethod
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
class AuthProvider(ABC):
name: str
@abstractmethod
async def authenticate(
self, session: AsyncSession, *, identifier: str, secret: str
) -> User | None:
"""Return the User if the credential is valid, else None."""
raise NotImplementedError
+27
View File
@@ -0,0 +1,27 @@
"""Local (email + password) auth provider."""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import verify_password
from app.integrations.auth.base import AuthProvider
from app.models.user import User
class LocalAuthProvider(AuthProvider):
name = "local"
async def authenticate(
self, session: AsyncSession, *, identifier: str, secret: str
) -> User | None:
email = identifier.strip().lower()
user = (
await session.execute(
select(User).where(User.email == email, User.deleted_at.is_(None))
)
).scalar_one_or_none()
if user is None or user.hashed_password is None:
return None
if not verify_password(user.hashed_password, secret):
return None
return user
+16
View File
@@ -0,0 +1,16 @@
"""Mailer interface for transactional email.
Implementations: ConsoleMailer (dev default — logs the link) and SMTPMailer
(operator-configured). Selected by config; resolved via app.api.deps.get_mailer.
Real deployments will move sending to the worker; for now it is inline.
"""
from abc import ABC, abstractmethod
class Mailer(ABC):
@abstractmethod
async def send_email_verification(self, *, to: str, link: str) -> None: ...
@abstractmethod
async def send_password_reset(self, *, to: str, link: str) -> None: ...
@@ -0,0 +1,16 @@
"""Development mailer: logs the would-be email (including the action link) to
stdout instead of sending. Never use in production."""
import logging
from app.integrations.mailer.base import Mailer
logger = logging.getLogger("provenance.mailer")
class ConsoleMailer(Mailer):
async def send_email_verification(self, *, to: str, link: str) -> None:
logger.info("[email] verify address for %s -> %s", to, link)
async def send_password_reset(self, *, to: str, link: str) -> None:
logger.info("[email] password reset for %s -> %s", to, link)
+43
View File
@@ -0,0 +1,43 @@
"""SMTP mailer using the standard library, run off the event loop. Configured
entirely from settings (host/port/credentials/from)."""
import asyncio
import smtplib
from email.message import EmailMessage
from app.core.config import Settings
from app.integrations.mailer.base import Mailer
class SMTPMailer(Mailer):
def __init__(self, settings: Settings) -> None:
self.settings = settings
def _send(self, *, to: str, subject: str, body: str) -> None:
msg = EmailMessage()
msg["From"] = self.settings.smtp_from
msg["To"] = to
msg["Subject"] = subject
msg.set_content(body)
with smtplib.SMTP(self.settings.smtp_host, self.settings.smtp_port) as smtp:
smtp.starttls()
if self.settings.smtp_username and self.settings.smtp_password:
smtp.login(self.settings.smtp_username, self.settings.smtp_password)
smtp.send_message(msg)
async def _send_async(self, *, to: str, subject: str, body: str) -> None:
await asyncio.to_thread(self._send, to=to, subject=subject, body=body)
async def send_email_verification(self, *, to: str, link: str) -> None:
await self._send_async(
to=to,
subject="Verify your Provenance email",
body=f"Confirm your email address:\n\n{link}\n",
)
async def send_password_reset(self, *, to: str, link: str) -> None:
await self._send_async(
to=to,
subject="Reset your Provenance password",
body=f"Reset your password:\n\n{link}\n",
)
+17
View File
@@ -5,6 +5,9 @@ OpenAPI contract. All domain logic lives in the service layer; the privacy
engine is the single enforcement point for reads.
"""
import logging
import sys
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
@@ -14,6 +17,19 @@ from app.core.config import get_settings
from app.services.exceptions import Conflict, Forbidden, NotFound
def _configure_logging() -> None:
"""Emit the app's own ``provenance.*`` logs at INFO to stdout (uvicorn only
configures its own loggers). The ConsoleMailer relies on this so self-hosters
can read verification/reset links from the logs."""
app_logger = logging.getLogger("provenance")
app_logger.setLevel(logging.INFO)
if not app_logger.handlers:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter("%(levelname)s [%(name)s] %(message)s"))
app_logger.addHandler(handler)
app_logger.propagate = False
def _register_error_handlers(app: FastAPI) -> None:
@app.exception_handler(NotFound)
async def _not_found(request: Request, exc: NotFound) -> JSONResponse:
@@ -29,6 +45,7 @@ def _register_error_handlers(app: FastAPI) -> None:
def create_app() -> FastAPI:
_configure_logging()
settings = get_settings()
app = FastAPI(
title=settings.app_name,
+35
View File
@@ -0,0 +1,35 @@
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.user import UserRead
class RegisterRequest(BaseModel):
email: str
password: str = Field(min_length=8)
display_name: str | None = None
class LoginRequest(BaseModel):
email: str
password: str
class TokenRequest(BaseModel):
token: str
class PasswordResetRequest(BaseModel):
email: str
class PasswordResetConfirm(BaseModel):
token: str
new_password: str = Field(min_length=8)
class SessionRead(BaseModel):
user: UserRead
token: str
expires_at: datetime
+201
View File
@@ -0,0 +1,201 @@
"""Authentication service: registration, login, sessions, email verification,
and password reset. Provider-agnostic — credential checking is delegated to an
AuthProvider; this module owns session/token issuance and the audit trail.
"""
from datetime import UTC, datetime, timedelta
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.security import generate_token, hash_password, hash_token
from app.integrations.auth.local import LocalAuthProvider
from app.integrations.mailer.base import Mailer
from app.models.auth import Session as SessionModel
from app.models.auth import UserToken
from app.models.enums import TokenPurpose
from app.models.user import User
from app.services.audit import record_audit
from app.services.exceptions import Conflict, NotFound
_local_provider = LocalAuthProvider()
def _now() -> datetime:
return datetime.now(UTC)
def _link(path: str, raw: str) -> str:
return f"{get_settings().app_base_url}{path}?token={raw}"
def _issue_session(session: AsyncSession, user: User) -> tuple[str, SessionModel]:
raw = generate_token()
record = SessionModel(
user_id=user.id,
token_hash=hash_token(raw),
expires_at=_now() + timedelta(days=get_settings().session_ttl_days),
)
session.add(record)
return raw, record
def _create_email_token(session: AsyncSession, user: User, purpose: TokenPurpose) -> str:
raw = generate_token()
session.add(
UserToken(
user_id=user.id,
purpose=purpose,
token_hash=hash_token(raw),
expires_at=_now() + timedelta(hours=get_settings().token_ttl_hours),
)
)
return raw
async def _consume_token(
session: AsyncSession, raw_token: str, purpose: TokenPurpose
) -> UserToken:
token = (
await session.execute(
select(UserToken).where(
UserToken.token_hash == hash_token(raw_token),
UserToken.purpose == purpose,
)
)
).scalar_one_or_none()
if token is None or token.used_at is not None or token.expires_at <= _now():
raise NotFound("invalid or expired token")
token.used_at = _now()
return token
async def register(
session: AsyncSession,
mailer: Mailer,
*,
email: str,
password: str,
display_name: str | None = None,
) -> tuple[User, str, datetime]:
email = email.strip().lower()
existing = (
await session.execute(select(User).where(User.email == email))
).scalar_one_or_none()
if existing is not None:
raise Conflict("email already registered")
user = User(email=email, display_name=display_name, hashed_password=hash_password(password))
session.add(user)
await session.flush()
verify_raw = _create_email_token(session, user, TokenPurpose.email_verify)
raw_token, record = _issue_session(session, user)
record_audit(
session,
action="register",
entity_type="User",
entity_id=user.id,
actor_user_id=user.id,
after={"email": email},
)
await session.commit()
await session.refresh(user)
await mailer.send_email_verification(to=email, link=_link("/auth/verify-email", verify_raw))
return user, raw_token, record.expires_at
async def login(
session: AsyncSession, *, email: str, password: str
) -> tuple[User, str, datetime] | None:
user = await _local_provider.authenticate(session, identifier=email, secret=password)
if user is None:
return None
raw_token, record = _issue_session(session, user)
record_audit(
session, action="login", entity_type="User", entity_id=user.id, actor_user_id=user.id
)
await session.commit()
return user, raw_token, record.expires_at
async def logout(session: AsyncSession, *, raw_token: str) -> None:
await session.execute(
update(SessionModel)
.where(
SessionModel.token_hash == hash_token(raw_token),
SessionModel.revoked_at.is_(None),
)
.values(revoked_at=_now())
)
await session.commit()
async def resolve_session_user(session: AsyncSession, *, raw_token: str) -> User | None:
record = (
await session.execute(
select(SessionModel).where(SessionModel.token_hash == hash_token(raw_token))
)
).scalar_one_or_none()
if record is None or record.revoked_at is not None or record.expires_at <= _now():
return None
return (
await session.execute(
select(User).where(User.id == record.user_id, User.deleted_at.is_(None))
)
).scalar_one_or_none()
async def verify_email(session: AsyncSession, *, raw_token: str) -> None:
token = await _consume_token(session, raw_token, TokenPurpose.email_verify)
await session.execute(
update(User).where(User.id == token.user_id).values(email_verified_at=_now())
)
record_audit(
session,
action="verify_email",
entity_type="User",
entity_id=token.user_id,
actor_user_id=token.user_id,
)
await session.commit()
async def request_password_reset(session: AsyncSession, mailer: Mailer, *, email: str) -> None:
email = email.strip().lower()
user = (
await session.execute(
select(User).where(User.email == email, User.deleted_at.is_(None))
)
).scalar_one_or_none()
# Always succeed to avoid leaking which emails are registered.
if user is None:
return
raw = _create_email_token(session, user, TokenPurpose.password_reset)
await session.commit()
await mailer.send_password_reset(to=email, link=_link("/auth/reset-password", raw))
async def reset_password(session: AsyncSession, *, raw_token: str, new_password: str) -> None:
token = await _consume_token(session, raw_token, TokenPurpose.password_reset)
await session.execute(
update(User)
.where(User.id == token.user_id)
.values(hashed_password=hash_password(new_password))
)
# Revoke all existing sessions — a reset invalidates prior logins.
await session.execute(
update(SessionModel)
.where(SessionModel.user_id == token.user_id, SessionModel.revoked_at.is_(None))
.values(revoked_at=_now())
)
record_audit(
session,
action="reset_password",
entity_type="User",
entity_id=token.user_id,
actor_user_id=token.user_id,
)
await session.commit()