feat: magic-link auth with access requests and RBAC
This commit is contained in:
parent
c2125f063b
commit
c8280f7078
13 changed files with 1058 additions and 2 deletions
1
backend/app/auth/__init__.py
Normal file
1
backend/app/auth/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Magic-link authentication + access-request approval (SMTP invite)."""
|
||||||
173
backend/app/auth/api.py
Normal file
173
backend/app/auth/api.py
Normal 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
57
backend/app/auth/deps.py
Normal 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
66
backend/app/auth/mail.py
Normal 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
|
||||||
49
backend/app/auth/schemas.py
Normal file
49
backend/app/auth/schemas.py
Normal 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
277
backend/app/auth/service.py
Normal 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
|
||||||
|
|
@ -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)]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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("/")
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
|
||||||
72
backend/app/models/user.py
Normal file
72
backend/app/models/user.py
Normal 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())
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
82
backend/migrations/versions/a3f1c9e2b4d7_auth_tables.py
Normal file
82
backend/migrations/versions/a3f1c9e2b4d7_auth_tables.py
Normal 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
228
backend/tests/test_auth.py
Normal 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"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue