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 app.db import get_engine, get_session
|
||||
from app.models import User
|
||||
from app.settings import settings
|
||||
from app.auth.deps import get_optional_user, resolve_tenant
|
||||
|
||||
from . import runner, store
|
||||
from .deploy import import_from_langflow
|
||||
|
|
@ -225,7 +227,11 @@ async def run_flow(
|
|||
stream: bool = Query(default=False),
|
||||
session: Session = Depends(get_db),
|
||||
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:
|
||||
version = _resolve_version(session, flow_id, data.version_no)
|
||||
except store.NotFoundError as exc:
|
||||
|
|
@ -316,7 +322,12 @@ def list_runs(
|
|||
flow_id: UUID,
|
||||
tenant_id: str | None = Query(default=None),
|
||||
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:
|
||||
return [_read_run(r) for r in store.list_runs(session, flow_id, tenant_id)]
|
||||
except store.NotFoundError as exc:
|
||||
|
|
@ -380,8 +391,11 @@ def get_run(
|
|||
run_id: UUID,
|
||||
tenant_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_optional_user),
|
||||
):
|
||||
"""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:
|
||||
run = store.get_run(session, run_id)
|
||||
except store.NotFoundError as exc:
|
||||
|
|
@ -408,7 +422,10 @@ def create_approval(data: ApprovalCreate, session: Session = Depends(get_db)):
|
|||
def list_approvals(
|
||||
tenant_id: str | None = Query(default=None),
|
||||
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)]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from pathlib import Path
|
|||
from fastapi import FastAPI
|
||||
|
||||
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 .settings import settings
|
||||
|
||||
|
|
@ -65,6 +66,8 @@ app = FastAPI(
|
|||
|
||||
app.include_router(flows_router)
|
||||
app.include_router(approvals_router)
|
||||
app.include_router(access_router)
|
||||
app.include_router(auth_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,13 @@
|
|||
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
|
||||
|
||||
# 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.
|
||||
flows_dir: str = str(REPO_ROOT / "backend" / "flows")
|
||||
|
|
@ -30,5 +32,24 @@ class Settings(BaseSettings):
|
|||
# FROM the LFX process (compose: http://api: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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue