Files
saas_backend/app/routes/auth/invitation.py
T
2026-08-31 20:39:41 -04:00

284 lines
9.9 KiB
Python

"""Sending an invitation, and accepting one.
Two audiences, kept apart the way the identity-provider routes are:
- **Admin** — a workspace administrator, scoped to their own workspace by their
own session. No tenant id is accepted from the caller, so there is nothing to
tamper with.
- **Public** — somebody signed out, holding a link. Both routes are
unauthenticated by necessity: the whole point is that they have no account
yet. They are rate limited, and they say as little as the task allows.
"""
from __future__ import annotations
import uuid
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.config.database import get_db
from app.core.tenant_context import unscoped
from app.helper.helpers import get_client_ip
from app.middleware.auth_middleware import User, get_current_user, require_access
from app.middleware.rate_limit import rate_limit
from app.models.auth.invitation_model import UserInvitation
from app.models.auth.tenant_model import Tenant
from app.schemas.auth.invitation_schema import (
InvitationAccept,
InvitationCreate,
InvitationCreated,
InvitationList,
InvitationPreview,
InvitationResponse,
)
from app.services.auth import invitation_service
from app.services.system.audit_log_service import AuditLogService
router = APIRouter()
public_router = APIRouter()
ACCEPT_LIMIT = rate_limit("invitation-accept", limit=10, window_seconds=900,
by_body_field="token")
PREVIEW_LIMIT = rate_limit("invitation-preview", limit=30, window_seconds=900)
def _own(db: Session, invitation_id: uuid.UUID, actor: User) -> UserInvitation:
"""An invitation belonging to the caller's workspace, or a 404.
404 rather than 403, for the reason it is everywhere else here: another
workspace's id must not be distinguishable from one that never existed.
"""
invitation = (
db.query(UserInvitation)
.filter(
UserInvitation.id == invitation_id,
UserInvitation.tenant_id == actor.tenant_id,
)
.first()
)
if invitation is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="No such invitation")
return invitation
def _to_response(invitation: UserInvitation) -> InvitationResponse:
payload = InvitationResponse.model_validate(invitation)
payload.state = invitation.state
return payload
@router.post("", response_model=InvitationCreated,
status_code=status.HTTP_201_CREATED)
def invite(
payload: InvitationCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.user.create")),
):
"""Invite somebody into the caller's own workspace.
Guarded by the same permission as creating a user, because it is the same
act — with the improvement that the administrator never learns the password.
"""
if current_user.tenant_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is not associated with a workspace",
)
invitation, raw = invitation_service.create(
db,
tenant_id=current_user.tenant_id,
email=payload.email,
invited_by=current_user,
role_id=payload.role_id,
first_name=payload.first_name,
last_name=payload.last_name,
)
sent = invitation_service.send(db, invitation, raw)
AuditLogService.log(
db=db, module_name="Users", action_type="CREATE",
entity_id=str(invitation.id), entity_name=invitation.email,
description="Invitation sent to '" + invitation.email + "'",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
tenant_id=current_user.tenant_id,
)
db.commit()
db.refresh(invitation)
return InvitationCreated(
invitation=_to_response(invitation),
acceptance_url=invitation_service.acceptance_url(raw),
email_sent=sent,
)
@router.get("", response_model=InvitationList)
def list_invitations(
state: Optional[str] = Query(None, pattern="^(pending|accepted|revoked|expired)$"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.user.read")),
):
"""Who has been invited and what happened to them.
Filtered in Python rather than in SQL, because "expired" is a comparison
against the clock that the `state` property already owns — expressing it a
second time in a WHERE clause is how the two definitions drift apart.
"""
rows = (
db.query(UserInvitation)
.filter(UserInvitation.tenant_id == current_user.tenant_id)
.order_by(UserInvitation.created_at.desc())
.limit(500)
.all()
)
if state:
rows = [row for row in rows if row.state == state]
return InvitationList(items=[_to_response(row) for row in rows], total=len(rows))
@router.post("/{invitation_id}/resend", response_model=InvitationCreated)
def resend(
invitation_id: uuid.UUID,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.user.create")),
):
"""Send a fresh invitation to the same address.
A **new** token, not the old one repeated — the old one is not recoverable,
because only its hash was ever stored. The previous invitation is revoked by
`create`, so a forwarded copy of the first email stops working, which is
usually the reason somebody is resending.
"""
existing = _own(db, invitation_id, current_user)
if existing.accepted_at:
raise HTTPException(status_code=status.HTTP_409_CONFLICT,
detail="That invitation has already been accepted")
invitation, raw = invitation_service.create(
db,
tenant_id=current_user.tenant_id,
email=existing.email,
invited_by=current_user,
role_id=existing.role_id,
first_name=existing.first_name,
last_name=existing.last_name,
)
sent = invitation_service.send(db, invitation, raw)
AuditLogService.log(
db=db, module_name="Users", action_type="UPDATE",
entity_id=str(invitation.id), entity_name=invitation.email,
description="Invitation resent to '" + invitation.email + "'",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
tenant_id=current_user.tenant_id,
)
db.commit()
db.refresh(invitation)
return InvitationCreated(
invitation=_to_response(invitation),
acceptance_url=invitation_service.acceptance_url(raw),
email_sent=sent,
)
@router.delete("/{invitation_id}", status_code=status.HTTP_204_NO_CONTENT)
def revoke(
invitation_id: uuid.UUID,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.user.delete")),
):
"""Stop a token working — somebody who left before they ever arrived."""
invitation = _own(db, invitation_id, current_user)
invitation_service.revoke(db, invitation)
AuditLogService.log(
db=db, module_name="Users", action_type="DELETE",
entity_id=str(invitation.id), entity_name=invitation.email,
description="Invitation to '" + invitation.email + "' revoked",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
tenant_id=current_user.tenant_id,
)
db.commit()
@public_router.get("/preview", response_model=InvitationPreview,
dependencies=[Depends(PREVIEW_LIMIT)])
def preview(
token: str = Query(..., min_length=1),
db: Session = Depends(get_db),
):
"""Enough for the accept screen to say who this is for.
The address is echoed because the person needs to know which of their
mailboxes this belongs to; the workspace name because "join Contoso" is the
question being answered. Nothing else — a guessed token must not become a
way to read a workspace's staff list or its role names.
"""
invitation = invitation_service.lookup(db, token)
with unscoped():
workspace = (
db.query(Tenant.tenant_name)
.filter(Tenant.id == invitation.tenant_id)
.scalar()
)
return InvitationPreview(
email=invitation.email,
workspace_name=workspace or "",
expires_at=invitation.expires_at,
)
@public_router.post("/accept", status_code=status.HTTP_201_CREATED,
dependencies=[Depends(ACCEPT_LIMIT)])
def accept(
payload: InvitationAccept,
request: Request,
db: Session = Depends(get_db),
):
"""Set a password and become a member of the workspace.
Returns no session. Signing in afterwards is one extra step for the person
and removes a whole class of question from this route — whether the token
also has to satisfy a second factor, what happens if the workspace's
subscription has lapsed since the invitation was sent, and so on. Sign-in
already answers all of those, in one place.
"""
user = invitation_service.accept(
db,
payload.token,
password=payload.password,
first_name=payload.first_name,
last_name=payload.last_name,
)
AuditLogService.log(
db=db, module_name="Users", action_type="CREATE",
entity_id=str(user.id), entity_name=user.email,
description="Invitation accepted by '" + user.email + "'",
performed_by_id=str(user.id),
performed_by_email=user.email,
ip_address=get_client_ip(request),
tenant_id=user.tenant_id,
)
db.commit()
return {"email": user.email, "workspace_id": str(user.tenant_id)}