284 lines
9.9 KiB
Python
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)}
|