feat: magic-link auth with access requests and RBAC

This commit is contained in:
Jonas Miederer 2026-09-11 17:27:58 +02:00
parent c2125f063b
commit c8280f7078
13 changed files with 1058 additions and 2 deletions

View file

@ -0,0 +1 @@
"""Magic-link authentication + access-request approval (SMTP invite)."""

173
backend/app/auth/api.py Normal file
View file

@ -0,0 +1,173 @@
"""Access + auth HTTP API (magic-link login, session cookie)."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from sqlmodel import Session
from app.models import User
from . import mail, service
from .deps import (
SESSION_COOKIE,
SESSION_MAX_AGE,
get_current_user,
get_db,
require_admin,
)
from .schemas import (
AccessDecisionRead,
AccessRequestCreate,
AccessRequestRead,
AdminBootstrapRequest,
MagicConsumeRequest,
MeRead,
MessageRead,
)
access_router = APIRouter(prefix="/api/access", tags=["access"])
auth_router = APIRouter(prefix="/api/auth", tags=["auth"])
def _read_request(r) -> AccessRequestRead:
return AccessRequestRead(
id=r.id,
name=r.name,
email=r.email,
reason=r.reason,
status=r.status,
created_at=r.created_at,
decided_at=r.decided_at,
decided_by=r.decided_by,
)
def _read_me(user: User) -> MeRead:
return MeRead(
id=user.id,
email=user.email,
name=user.name,
role=user.role,
tenant_id=user.tenant_id,
)
def _set_session_cookie(response: Response, session_id: str) -> None:
# localhost is a secure context, so Secure is safe for local dev too.
response.set_cookie(
SESSION_COOKIE,
session_id,
max_age=SESSION_MAX_AGE,
path="/",
httponly=True,
secure=True,
samesite="lax",
)
@access_router.post("/requests", response_model=AccessRequestRead, status_code=201)
def create_request(data: AccessRequestCreate, session: Session = Depends(get_db)):
try:
request = service.create_access_request(
session, data.name, data.email, data.reason
)
except service.RateLimitedError as exc:
raise HTTPException(status_code=429, detail=str(exc)) from exc
mail.send_access_notification(str(request.id), request.name, request.email)
return _read_request(request)
@access_router.get("/requests", response_model=list[AccessRequestRead])
def list_requests(
status: str | None = Query(default=None),
session: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
return [_read_request(r) for r in service.list_access_requests(session, status)]
@access_router.post("/requests/{request_id}/approve", response_model=AccessDecisionRead)
def approve_request(
request_id: UUID,
session: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
try:
request, _user, token = service.approve_access_request(
session, request_id, admin.email
)
except service.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except service.ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
# The token travels by mail only — never in an API response.
email_sent = mail.send_magic_link(request.email, mail.magic_link(token))
return AccessDecisionRead(request=_read_request(request), email_sent=email_sent)
@access_router.post("/requests/{request_id}/reject", response_model=AccessRequestRead)
def reject_request(
request_id: UUID,
session: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
try:
request = service.reject_access_request(session, request_id, admin.email)
except service.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except service.ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return _read_request(request)
@auth_router.post("/magic/consume", response_model=MeRead)
def consume_magic(
data: MagicConsumeRequest,
response: Response,
session: Session = Depends(get_db),
):
try:
user_session = service.consume_magic_token(session, data.token)
except (
service.InvalidTokenError,
service.ExpiredTokenError,
service.UsedTokenError,
) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
user = service.get_user(session, user_session.user_id)
if user is None: # pragma: no cover - consume already validated the user
raise HTTPException(status_code=400, detail="unknown token")
_set_session_cookie(response, user_session.id)
return _read_me(user)
@auth_router.get("/me", response_model=MeRead)
def read_me(user: User = Depends(get_current_user)):
return _read_me(user)
@auth_router.post("/logout", response_model=MessageRead)
def logout(
request: Request,
response: Response,
session: Session = Depends(get_db),
):
# Revoke the server-side row when the cookie maps to a live session;
# always clear the client cookie.
session_id = request.cookies.get(SESSION_COOKIE)
if session_id:
service.revoke_session(session, session_id)
response.delete_cookie(SESSION_COOKIE, path="/")
return MessageRead(message="logged out")
@auth_router.post("/admin-bootstrap", response_model=MessageRead, status_code=201)
def admin_bootstrap(data: AdminBootstrapRequest, session: Session = Depends(get_db)):
try:
_user, token = service.bootstrap_admin(session, data.email)
except service.ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except service.ForbiddenError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
mail.send_magic_link(service.normalize_email(data.email), mail.magic_link(token))
return MessageRead(message="admin created, magic link sent")

57
backend/app/auth/deps.py Normal file
View file

@ -0,0 +1,57 @@
"""Session-cookie dependencies. Handlers stay thin; DB via get_session."""
from __future__ import annotations
from collections.abc import Iterator
from fastapi import Depends, HTTPException, Request
from sqlmodel import Session
from app.db import get_session
from app.models import User
from . import service
SESSION_COOKIE = "kirche_session"
SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds())
def get_db() -> Iterator[Session]:
yield from get_session()
def get_optional_user(
request: Request, session: Session = Depends(get_db)
) -> User | None:
session_id = request.cookies.get(SESSION_COOKIE)
if not session_id:
return None
record = service.get_valid_session(session, session_id)
if record is None:
return None
return service.get_user(session, record.user_id)
def get_current_user(
request: Request, session: Session = Depends(get_db)
) -> User:
user = get_optional_user(request, session)
if user is None:
raise HTTPException(status_code=401, detail="not authenticated")
return user
def require_admin(user: User = Depends(get_current_user)) -> User:
if user.role != "admin":
raise HTTPException(status_code=403, detail="admin only")
return user
def resolve_tenant(
user: User | None, explicit: str | None, default: str = "default"
) -> str:
"""Tenant comes from the session; the body/query param is a deprecated
fallback for unauthenticated callers."""
if user is not None:
return user.tenant_id
return explicit or default

66
backend/app/auth/mail.py Normal file
View file

@ -0,0 +1,66 @@
"""Magic-link delivery. SMTP when configured, dev log fallback otherwise."""
from __future__ import annotations
import logging
import smtplib
from email.message import EmailMessage
from app.settings import settings
logger = logging.getLogger(__name__)
def magic_link(token: str) -> str:
return f"{settings.frontend_url.rstrip('/')}/login?token={token}"
def send_magic_link(to_email: str, link: str) -> bool:
"""Mail the login link. Returns False (logs instead) when SMTP is unset.
The plaintext link only ever appears here: service/api logging must
never include tokens or session ids (see test_auth no-leak test).
"""
if not settings.smtp_host:
logger.info("SMTP not configured; magic link for %s: %s", to_email, link)
return False
message = EmailMessage()
message["Subject"] = "Your kirche-einfach login link"
message["To"] = to_email
message["From"] = settings.mail_from or settings.contact_to or to_email
if settings.contact_to:
message["Reply-To"] = settings.contact_to
message.set_content(
f"Hello,\n\nUse this link to log in (valid 15 minutes, single use):\n{link}\n"
)
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
smtp.starttls()
if settings.smtp_username:
smtp.login(settings.smtp_username, settings.smtp_password)
smtp.send_message(message)
logger.info("magic link mailed to %s", to_email)
return True
def send_access_notification(request_id: str, name: str, email: str) -> bool:
"""Notify CONTACT_TO about a new access request (best effort)."""
if not settings.smtp_host or not settings.contact_to:
logger.info("new access request %s from %s <%s>", request_id, name, email)
return False
message = EmailMessage()
message["Subject"] = f"New access request: {name}"
message["To"] = settings.contact_to
message["From"] = settings.mail_from or settings.contact_to
message.set_content(
f"Name: {name}\nEmail: {email}\nRequest: {request_id}\n"
)
try:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
smtp.starttls()
if settings.smtp_username:
smtp.login(settings.smtp_username, settings.smtp_password)
smtp.send_message(message)
except OSError:
logger.warning("access-request notification failed for %s", request_id)
return False
return True

View file

@ -0,0 +1,49 @@
"""Pydantic contracts for the access/auth API."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class AccessRequestCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
email: str = Field(min_length=3, max_length=320)
reason: str = Field(default="", max_length=2000)
class AccessRequestRead(BaseModel):
id: UUID
name: str
email: str
reason: str
status: str
created_at: datetime
decided_at: datetime | None
decided_by: str | None
class AccessDecisionRead(BaseModel):
request: AccessRequestRead
# False when SMTP is unset (dev): the magic link is logged, not mailed.
email_sent: bool
class MagicConsumeRequest(BaseModel):
token: str = Field(min_length=1, max_length=200)
class MeRead(BaseModel):
id: UUID
email: str
name: str
role: str
tenant_id: str
class AdminBootstrapRequest(BaseModel):
email: str = Field(min_length=3, max_length=320)
class MessageRead(BaseModel):
message: str

277
backend/app/auth/service.py Normal file
View file

@ -0,0 +1,277 @@
"""Access requests, magic-link tokens, and sessions (DB via function args).
Keep API handlers thin: all persistence lives here. Tokens and session ids
are bearer secrets — log user/request ids only, never secret values.
"""
from __future__ import annotations
import hashlib
import logging
import secrets
from datetime import datetime, timedelta, timezone
from uuid import UUID
from sqlmodel import Session, func, select
from app.models import AccessRequest, MagicLinkToken, User, UserSession
from app.settings import settings
logger = logging.getLogger(__name__)
MAGIC_LINK_TTL = timedelta(minutes=15)
SESSION_TTL = timedelta(days=30)
# Public request endpoint: at most this many requests per email per hour.
REQUESTS_PER_EMAIL_PER_HOUR = 5
class AuthError(Exception):
pass
class NotFoundError(AuthError):
pass
class ConflictError(AuthError):
pass
class ForbiddenError(AuthError):
pass
class InvalidTokenError(AuthError):
pass
class ExpiredTokenError(AuthError):
pass
class UsedTokenError(AuthError):
pass
class RateLimitedError(AuthError):
pass
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def normalize_email(email: str) -> str:
return email.strip().lower()
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def is_admin_email(email: str) -> bool:
return normalize_email(email) in settings.admin_email_list
def role_for(email: str) -> str:
return "admin" if is_admin_email(email) else "member"
def get_user(session: Session, user_id: UUID) -> User | None:
return session.get(User, user_id)
def get_user_by_email(session: Session, email: str) -> User | None:
return session.exec(
select(User).where(User.email == normalize_email(email))
).first()
def user_count(session: Session) -> int:
return session.exec(select(func.count()).select_from(User)).one()
def recent_request_count(session: Session, email: str, hours: int = 1) -> int:
since = utcnow() - timedelta(hours=hours)
return session.exec(
select(func.count())
.select_from(AccessRequest)
.where(
AccessRequest.email == normalize_email(email),
AccessRequest.created_at >= since,
)
).one()
def create_access_request(
session: Session, name: str, email: str, reason: str = ""
) -> AccessRequest:
email = normalize_email(email)
if recent_request_count(session, email) >= REQUESTS_PER_EMAIL_PER_HOUR:
raise RateLimitedError("too many requests from this email, try again later")
request = AccessRequest(name=name.strip(), email=email, reason=reason.strip())
session.add(request)
session.commit()
session.refresh(request)
logger.info("access request %s from %s", request.id, email)
return request
def get_access_request(session: Session, request_id: UUID) -> AccessRequest:
request = session.get(AccessRequest, request_id)
if request is None:
raise NotFoundError(f"access request {request_id} not found")
return request
def list_access_requests(session: Session, status: str | None = None) -> list[AccessRequest]:
query = select(AccessRequest).order_by(AccessRequest.created_at.desc())
if status is not None:
query = query.where(AccessRequest.status == status)
return list(session.exec(query).all())
def _get_or_create_user(session: Session, name: str, email: str) -> User:
user = get_user_by_email(session, email)
if user is None:
user = User(name=name.strip(), email=email, role=role_for(email))
session.add(user)
session.flush()
logger.info("user %s created for %s", user.id, email)
else:
if not user.name and name.strip():
user.name = name.strip()
new_role = role_for(email)
if user.role != new_role:
user.role = new_role
session.add(user)
session.flush()
return user
def issue_magic_token(session: Session, user: User) -> str:
token = secrets.token_urlsafe(32)
session.add(
MagicLinkToken(
user_id=user.id,
token_hash=hash_token(token),
expires_at=utcnow() + MAGIC_LINK_TTL,
)
)
session.commit()
logger.info("magic token issued for user %s", user.id)
return token
def approve_access_request(
session: Session, request_id: UUID, decided_by: str
) -> tuple[AccessRequest, User, str]:
"""Approve → upsert User + fresh magic-link token (plaintext, mail it)."""
request = get_access_request(session, request_id)
if request.status != "pending":
raise ConflictError(f"access request {request_id} already {request.status}")
user = _get_or_create_user(session, request.name, request.email)
request.status = "approved"
request.decided_at = utcnow()
request.decided_by = normalize_email(decided_by)
session.add(request)
session.commit()
session.refresh(request)
token = issue_magic_token(session, user)
logger.info(
"access request %s approved by %s; user %s",
request.id,
request.decided_by,
user.id,
)
return request, user, token
def reject_access_request(
session: Session, request_id: UUID, decided_by: str
) -> AccessRequest:
request = get_access_request(session, request_id)
if request.status != "pending":
raise ConflictError(f"access request {request_id} already {request.status}")
request.status = "rejected"
request.decided_at = utcnow()
request.decided_by = normalize_email(decided_by)
session.add(request)
session.commit()
session.refresh(request)
logger.info("access request %s rejected by %s", request.id, request.decided_by)
return request
def _aware(value: datetime) -> datetime:
# SQLite returns naive datetimes; stored values are UTC.
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
def consume_magic_token(session: Session, token: str) -> UserSession:
"""Single-use consume → session row. used_at is set before creation."""
record = session.exec(
select(MagicLinkToken).where(
MagicLinkToken.token_hash == hash_token(token.strip())
)
).first()
if record is None:
raise InvalidTokenError("unknown token")
if record.used_at is not None:
raise UsedTokenError("token already used")
if _aware(record.expires_at) <= utcnow():
raise ExpiredTokenError("token expired")
user = session.get(User, record.user_id)
if user is None:
raise InvalidTokenError("unknown token")
# Mark used first: a reused token can never mint a second session.
record.used_at = utcnow()
session.add(record)
user_session = UserSession(
id=secrets.token_urlsafe(32),
user_id=user.id,
expires_at=utcnow() + SESSION_TTL,
)
session.add(user_session)
session.commit()
session.refresh(user_session)
logger.info("magic token consumed for user %s", user.id)
return user_session
def get_valid_session(session: Session, session_id: str) -> UserSession | None:
record = session.get(UserSession, session_id)
if record is None or record.revoked_at is not None:
return None
if _aware(record.expires_at) <= utcnow():
return None
return record
def revoke_session(session: Session, session_id: str) -> None:
record = session.get(UserSession, session_id)
if record is not None and record.revoked_at is None:
record.revoked_at = utcnow()
session.add(record)
session.commit()
def bootstrap_admin(session: Session, email: str) -> tuple[User, str]:
"""First-admin setup: only when the user table is empty AND the email
is in ADMIN_EMAILS. Issues a magic-link token like approve does."""
email = normalize_email(email)
if user_count(session) > 0:
raise ConflictError("users already exist, bootstrap is closed")
if not is_admin_email(email):
raise ForbiddenError("email is not an admin email")
user = User(name="", email=email, role="admin")
session.add(user)
session.flush()
session.commit()
session.refresh(user)
token = issue_magic_token(session, user)
logger.info("admin bootstrapped for user %s", user.id)
return user, token

View file

@ -13,7 +13,9 @@ from fastapi.responses import StreamingResponse
from sqlmodel import Session from sqlmodel import Session
from app.db import get_engine, get_session from app.db import get_engine, get_session
from app.models import User
from app.settings import settings from app.settings import settings
from app.auth.deps import get_optional_user, resolve_tenant
from . import runner, store from . import runner, store
from .deploy import import_from_langflow from .deploy import import_from_langflow
@ -225,7 +227,11 @@ async def run_flow(
stream: bool = Query(default=False), stream: bool = Query(default=False),
session: Session = Depends(get_db), session: Session = Depends(get_db),
lfx: LfxClient = Depends(get_lfx), lfx: LfxClient = Depends(get_lfx),
user: User | None = Depends(get_optional_user),
): ):
# Tenant comes from the session; the body param stays as a deprecated
# fallback for unauthenticated callers.
data = data.model_copy(update={"tenant_id": resolve_tenant(user, data.tenant_id)})
try: try:
version = _resolve_version(session, flow_id, data.version_no) version = _resolve_version(session, flow_id, data.version_no)
except store.NotFoundError as exc: except store.NotFoundError as exc:
@ -316,7 +322,12 @@ def list_runs(
flow_id: UUID, flow_id: UUID,
tenant_id: str | None = Query(default=None), tenant_id: str | None = Query(default=None),
session: Session = Depends(get_db), session: Session = Depends(get_db),
user: User | None = Depends(get_optional_user),
): ):
# Authenticated callers are scoped to their session tenant unless they
# pass an explicit tenant (deprecated fallback for unauthenticated use).
if user is not None and tenant_id is None:
tenant_id = user.tenant_id
try: try:
return [_read_run(r) for r in store.list_runs(session, flow_id, tenant_id)] return [_read_run(r) for r in store.list_runs(session, flow_id, tenant_id)]
except store.NotFoundError as exc: except store.NotFoundError as exc:
@ -380,8 +391,11 @@ def get_run(
run_id: UUID, run_id: UUID,
tenant_id: str | None = Query(default=None), tenant_id: str | None = Query(default=None),
session: Session = Depends(get_db), session: Session = Depends(get_db),
user: User | None = Depends(get_optional_user),
): ):
"""Read a run. With tenant_id, cross-tenant reads 404 (v1 scoping).""" """Read a run. With tenant_id, cross-tenant reads 404 (v1 scoping)."""
if user is not None and tenant_id is None:
tenant_id = user.tenant_id
try: try:
run = store.get_run(session, run_id) run = store.get_run(session, run_id)
except store.NotFoundError as exc: except store.NotFoundError as exc:
@ -408,7 +422,10 @@ def create_approval(data: ApprovalCreate, session: Session = Depends(get_db)):
def list_approvals( def list_approvals(
tenant_id: str | None = Query(default=None), tenant_id: str | None = Query(default=None),
session: Session = Depends(get_db), session: Session = Depends(get_db),
user: User | None = Depends(get_optional_user),
): ):
if user is not None and tenant_id is None:
tenant_id = user.tenant_id
return [_read_task(t) for t in store.list_approval_tasks(session, tenant_id)] return [_read_task(t) for t in store.list_approval_tasks(session, tenant_id)]

View file

@ -7,6 +7,7 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from .flows.api import approvals_router, flows_router from .flows.api import approvals_router, flows_router
from .auth.api import access_router, auth_router
from .flows.sync import run_sync_blocking, watch_forever from .flows.sync import run_sync_blocking, watch_forever
from .settings import settings from .settings import settings
@ -65,6 +66,8 @@ app = FastAPI(
app.include_router(flows_router) app.include_router(flows_router)
app.include_router(approvals_router) app.include_router(approvals_router)
app.include_router(access_router)
app.include_router(auth_router)
@app.get("/") @app.get("/")

View file

@ -1,3 +1,13 @@
from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
from .user import AccessRequest, MagicLinkToken, User, UserSession
__all__ = ["ApprovalTask", "FlowDefinition", "FlowRun", "FlowVersion"] __all__ = [
"AccessRequest",
"ApprovalTask",
"FlowDefinition",
"FlowRun",
"FlowVersion",
"MagicLinkToken",
"User",
"UserSession",
]

View file

@ -0,0 +1,72 @@
"""Access-management domain models (SQLModel tables)."""
from datetime import datetime, timezone
from uuid import UUID, uuid4
from sqlalchemy import Column, DateTime
from sqlmodel import Field, SQLModel
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def _ts_column() -> Column:
return Column(DateTime(timezone=True))
class User(SQLModel, table=True):
"""A person with API access. Role is member or admin."""
__tablename__ = "users"
id: UUID = Field(default_factory=uuid4, primary_key=True)
email: str = Field(index=True, unique=True, max_length=320)
name: str = Field(default="", max_length=200)
# member | admin
role: str = Field(default="member", max_length=20)
tenant_id: str = Field(default="default", max_length=200)
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
class AccessRequest(SQLModel, table=True):
"""A public request for access; an admin approves or rejects it."""
__tablename__ = "access_requests"
id: UUID = Field(default_factory=uuid4, primary_key=True)
name: str = Field(max_length=200)
email: str = Field(index=True, max_length=320)
reason: str = Field(default="", max_length=2000)
# pending | approved | rejected
status: str = Field(default="pending", index=True, max_length=20)
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
decided_at: datetime | None = Field(default=None, sa_column=_ts_column())
decided_by: str | None = Field(default=None, max_length=320)
class MagicLinkToken(SQLModel, table=True):
"""A single-use 15-minute login token. Only the sha256 is stored."""
__tablename__ = "magic_link_tokens"
id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="users.id", index=True)
token_hash: str = Field(index=True, unique=True, max_length=64)
expires_at: datetime = Field(sa_column=_ts_column())
# Set before the session row is created, so a reused token can never
# mint a second session.
used_at: datetime | None = Field(default=None, sa_column=_ts_column())
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
class UserSession(SQLModel, table=True):
"""An opaque session id in the DB (cookie value). Revocable, expirable."""
__tablename__ = "user_sessions"
id: str = Field(primary_key=True, max_length=128)
user_id: UUID = Field(foreign_key="users.id", index=True)
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
expires_at: datetime = Field(sa_column=_ts_column())
revoked_at: datetime | None = Field(default=None, sa_column=_ts_column())

View file

@ -14,7 +14,9 @@ class Settings(BaseSettings):
debug: bool = False debug: bool = False
# App database (SQLite file). Override via DATABASE_URL for other engines. # App database (SQLite file). Override via DATABASE_URL for other engines.
database_url: str = str(REPO_ROOT / "backend" / "data" / "kirche.db") # Must be a URL (not a bare path): db.py and Alembic hand it to
# create_engine, which rejects bare paths.
database_url: str = f"sqlite:///{REPO_ROOT / 'backend' / 'data' / 'kirche.db'}"
# Canonical flow definitions (source of truth): one Langflow export per file. # Canonical flow definitions (source of truth): one Langflow export per file.
flows_dir: str = str(REPO_ROOT / "backend" / "flows") flows_dir: str = str(REPO_ROOT / "backend" / "flows")
@ -30,5 +32,24 @@ class Settings(BaseSettings):
# FROM the LFX process (compose: http://api:8000). # FROM the LFX process (compose: http://api:8000).
callback_base_url: str = "http://localhost:8000" callback_base_url: str = "http://localhost:8000"
# --- access management (magic-link auth, see app/auth) ---
# Public frontend URL (magic links point at /login?token= there).
frontend_url: str = "http://localhost:3000"
# Comma-separated emails that become admins (role assignment + bootstrap).
admin_emails: str = ""
# Reply-To on outgoing mail; also receives access-request notifications.
contact_to: str = ""
mail_from: str = ""
smtp_host: str = ""
smtp_port: int = 587
smtp_username: str = ""
smtp_password: str = ""
@property
def admin_email_list(self) -> list[str]:
return [
e.strip().lower() for e in self.admin_emails.split(",") if e.strip()
]
settings = Settings() settings = Settings()

View file

@ -0,0 +1,82 @@
"""auth tables (users, access requests, magic links, sessions)
Revision ID: a3f1c9e2b4d7
Revises: d932b6993f9a
Create Date: 2026-09-11 15:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types
# revision identifiers, used by Alembic.
revision: str = 'a3f1c9e2b4d7'
down_revision: Union[str, Sequence[str], None] = 'd932b6993f9a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table('users',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=320), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column('role', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_table('access_requests',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=320), nullable=False),
sa.Column('reason', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('decided_by', sqlmodel.sql.sqltypes.AutoString(length=320), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_access_requests_email'), 'access_requests', ['email'], unique=False)
op.create_index(op.f('ix_access_requests_status'), 'access_requests', ['status'], unique=False)
op.create_table('magic_link_tokens',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('token_hash', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_magic_link_tokens_token_hash'), 'magic_link_tokens', ['token_hash'], unique=True)
op.create_index(op.f('ix_magic_link_tokens_user_id'), 'magic_link_tokens', ['user_id'], unique=False)
op.create_table('user_sessions',
sa.Column('id', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_sessions_user_id'), 'user_sessions', ['user_id'], unique=False)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(op.f('ix_user_sessions_user_id'), table_name='user_sessions')
op.drop_table('user_sessions')
op.drop_index(op.f('ix_magic_link_tokens_user_id'), table_name='magic_link_tokens')
op.drop_index(op.f('ix_magic_link_tokens_token_hash'), table_name='magic_link_tokens')
op.drop_table('magic_link_tokens')
op.drop_index(op.f('ix_access_requests_status'), table_name='access_requests')
op.drop_index(op.f('ix_access_requests_email'), table_name='access_requests')
op.drop_table('access_requests')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')

228
backend/tests/test_auth.py Normal file
View file

@ -0,0 +1,228 @@
"""Phase 1 tests: magic-link auth, access requests, RBAC, cookie flags."""
import logging
from datetime import timedelta
from uuid import uuid4
import pytest
from sqlmodel import select
from app.auth import service
from app.auth.deps import SESSION_COOKIE, resolve_tenant
from app.models import MagicLinkToken, User
from app.settings import settings
ADMIN = "admin@example.com"
MEMBER = "member@example.com"
@pytest.fixture(autouse=True)
def _no_smtp_many_admins(monkeypatch):
# Never touch the network: dev fallback logs instead of mailing.
# (The real repo .env configures SMTP; tests must not use it.)
monkeypatch.setattr(settings, "smtp_host", "")
monkeypatch.setattr(settings, "admin_emails", ADMIN)
def _get_or_make_user(session, email, role="member"):
user = service.get_user_by_email(session, email)
if user is None:
user = User(email=email, name="Test", role=role)
session.add(user)
session.commit()
session.refresh(user)
return user
async def _login(client, session, email, role="member"):
"""Consume a fresh magic token; persist the session cookie manually.
httpx honours the Secure cookie flag and would withhold it over the
http://test transport, so the session id is re-set as a plain cookie.
"""
user = _get_or_make_user(session, email, role)
token = service.issue_magic_token(session, user)
response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 200, response.text
sid = response.cookies[SESSION_COOKIE]
client.cookies.set(SESSION_COOKIE, sid)
return response.json()
async def test_request_approve_consume_me_logout(client, session):
assert (await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})).status_code == 201
admin = await _login(client, session, ADMIN, role="admin")
assert admin["role"] == "admin"
created = await client.post(
"/api/access/requests",
json={"name": "Maria", "email": MEMBER, "reason": "choir planning"},
)
assert created.status_code == 201, created.text
assert created.json()["status"] == "pending"
queue = await client.get("/api/access/requests")
assert queue.status_code == 200
assert any(r["email"] == MEMBER for r in queue.json())
approved = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
assert approved.status_code == 200, approved.text
body = approved.json()
assert body["request"]["status"] == "approved"
assert body["email_sent"] is False # no SMTP in tests
# New client (no admin cookie) logs in as the approved member.
from httpx import ASGITransport, AsyncClient
from app.main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as member:
user = service.get_user_by_email(session, MEMBER)
assert user is not None and user.role == "member"
token = service.issue_magic_token(session, user)
consumed = await member.post("/api/auth/magic/consume", json={"token": token})
assert consumed.status_code == 200
assert consumed.json()["email"] == MEMBER
member.cookies.set(SESSION_COOKIE, consumed.cookies[SESSION_COOKIE])
me = await member.get("/api/auth/me")
assert me.status_code == 200
assert me.json()["tenant_id"] == "default"
assert (await member.post("/api/auth/logout")).status_code == 200
assert (await member.get("/api/auth/me")).status_code == 401
async def test_consume_unknown_token_400(client):
response = await client.post("/api/auth/magic/consume", json={"token": "nope"})
assert response.status_code == 400
async def test_token_expiry_boundary(client, session):
user = _get_or_make_user(session, MEMBER)
token = service.issue_magic_token(session, user)
record = session.exec(
select(MagicLinkToken).where(
MagicLinkToken.token_hash == service.hash_token(token)
)
).one()
record.expires_at = service.utcnow() - timedelta(seconds=1)
session.add(record)
session.commit()
response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 400
assert "expired" in response.json()["detail"]
async def test_token_reuse_400(client, session):
user = _get_or_make_user(session, MEMBER)
token = service.issue_magic_token(session, user)
assert (await client.post("/api/auth/magic/consume", json={"token": token})).status_code == 200
again = await client.post("/api/auth/magic/consume", json={"token": token})
assert again.status_code == 400
assert "used" in again.json()["detail"]
async def test_admin_only_endpoints(client, session):
await _login(client, session, MEMBER, role="member")
created = await client.post(
"/api/access/requests",
json={"name": "X", "email": "x@example.com", "reason": ""},
)
assert created.status_code == 201
assert (await client.get("/api/access/requests")).status_code == 403
approve = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
assert approve.status_code == 403
from httpx import ASGITransport, AsyncClient
from app.main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as anon:
assert (await anon.get("/api/access/requests")).status_code == 401
assert (
await anon.post(
f"/api/access/requests/{created.json()['id']}/approve"
)
).status_code == 401
assert (await anon.get("/api/auth/me")).status_code == 401
async def test_approve_unknown_and_double_decide(client, session):
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
await _login(client, session, ADMIN, role="admin")
assert (
await client.post(f"/api/access/requests/{uuid4()}/approve")
).status_code == 404
created = await client.post(
"/api/access/requests",
json={"name": "Y", "email": "y@example.com", "reason": ""},
)
rid = created.json()["id"]
assert (await client.post(f"/api/access/requests/{rid}/reject")).status_code == 200
again = await client.post(f"/api/access/requests/{rid}/approve")
assert again.status_code == 409
async def test_bootstrap_once_and_forbidden(client):
assert (
await client.post("/api/auth/admin-bootstrap", json={"email": "stranger@example.com"})
).status_code == 403
assert (
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
).status_code == 201
assert (
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
).status_code == 409
async def test_session_cookie_flags(client, session):
user = _get_or_make_user(session, MEMBER)
token = service.issue_magic_token(session, user)
response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 200
raw = " ".join(response.headers.get_list("set-cookie")).lower()
assert "httponly" in raw
assert "secure" in raw
assert "samesite=lax" in raw
assert "path=/" in raw
async def test_no_token_or_session_in_logs(client, session, caplog):
user = _get_or_make_user(session, MEMBER)
token = service.issue_magic_token(session, user)
with caplog.at_level(logging.INFO):
caplog.clear()
response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 200
assert token not in caplog.text
assert response.cookies[SESSION_COOKIE] not in caplog.text
assert service.hash_token(token) not in caplog.text
async def test_access_request_rate_limit(client):
email = f"impatient-{uuid4()}@example.com"
for _ in range(service.REQUESTS_PER_EMAIL_PER_HOUR):
response = await client.post(
"/api/access/requests",
json={"name": "I", "email": email, "reason": ""},
)
assert response.status_code == 201, response.text
limited = await client.post(
"/api/access/requests", json={"name": "I", "email": email, "reason": ""}
)
assert limited.status_code == 429
def test_resolve_tenant():
assert resolve_tenant(None, None) == "default"
assert resolve_tenant(None, "acme") == "acme"
user = User(email="a@b.c", tenant_id="parish-7")
assert resolve_tenant(user, None) == "parish-7"
assert resolve_tenant(user, "other") == "parish-7"