from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.orm import Session from app.api.v1.schemas import GroupInfo, LoginRequest, LoginResponse, MeResponse, RoleInfo, TenantInfo, TenantMembershipInfo, UserInfo from app.auth.dependencies import ApiPrincipal, get_api_principal from app.db.models import Tenant, User from app.db.session import get_session from app.security.passwords import verify_password from app.security.sessions import collect_user_groups, collect_user_roles, collect_user_scopes, create_auth_session, revoke_auth_session router = APIRouter(prefix="/auth", tags=["auth"]) def _tenant_info(tenant: Tenant) -> TenantInfo: return TenantInfo(id=tenant.id, slug=tenant.slug, name=tenant.name) def _user_info(user: User) -> UserInfo: return UserInfo(id=user.id, email=user.email, display_name=user.display_name, is_tenant_admin=user.is_tenant_admin) def _roles_info(roles) -> list[RoleInfo]: return [RoleInfo(id=r.id, slug=r.slug, name=r.name, permissions=r.permissions or []) for r in roles] def _groups_info(groups) -> list[GroupInfo]: return [GroupInfo(id=g.id, slug=g.slug, name=g.name) for g in groups] def _tenant_memberships_for_email(session: Session, email: str) -> list[TenantMembershipInfo]: """Return tenants that currently contain an active user with this email. The current data model still stores users inside one tenant. Until a dedicated cross-tenant identity table exists, matching by email is the lightweight bridge that lets the frontend render tenant context and later expose switching only when more than one membership exists. """ rows = ( session.query(User, Tenant) .join(Tenant, Tenant.id == User.tenant_id) .filter(User.email == email, User.is_active.is_(True), Tenant.is_active.is_(True)) .order_by(Tenant.name.asc()) .all() ) memberships: list[TenantMembershipInfo] = [] for user, tenant in rows: roles = collect_user_roles(session, user) memberships.append( TenantMembershipInfo( id=tenant.id, slug=tenant.slug, name=tenant.name, roles=[role.slug for role in roles], is_active=True, ) ) return memberships def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[User, Tenant]: query = ( session.query(User, Tenant) .join(Tenant, Tenant.id == User.tenant_id) .filter(User.email == payload.email, User.is_active.is_(True), Tenant.is_active.is_(True)) ) if payload.tenant_slug: query = query.filter(Tenant.slug == payload.tenant_slug) rows = query.order_by(Tenant.name.asc()).all() for user, tenant in rows: if user.password_hash and verify_password(payload.password, user.password_hash): return user, tenant raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login") @router.post("/login", response_model=LoginResponse) def login(payload: LoginRequest, request: Request, session: Session = Depends(get_session)): user, tenant = _resolve_login_user(session, payload) user_agent = request.headers.get("user-agent") ip_address = request.client.host if request.client else None created = create_auth_session(session, user=user, user_agent=user_agent, ip_address=ip_address) roles = collect_user_roles(session, user) groups = collect_user_groups(session, user) scopes = collect_user_scopes(session, user) tenants = _tenant_memberships_for_email(session, user.email) session.commit() active_tenant = _tenant_info(tenant) return LoginResponse( access_token=created.token, expires_at=created.model.expires_at, user=_user_info(user), tenant=active_tenant, active_tenant=active_tenant, tenants=tenants, scopes=scopes, roles=_roles_info(roles), groups=_groups_info(groups), ) @router.get("/me", response_model=MeResponse) def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)): tenant = session.get(Tenant, principal.tenant_id) roles = collect_user_roles(session, principal.user) groups = collect_user_groups(session, principal.user) active_tenant = _tenant_info(tenant) return MeResponse( user=_user_info(principal.user), tenant=active_tenant, active_tenant=active_tenant, tenants=_tenant_memberships_for_email(session, principal.user.email), scopes=principal.scopes, roles=_roles_info(roles), groups=_groups_info(groups), ) @router.post("/logout") def logout(request: Request, session: Session = Depends(get_session)): authorization = request.headers.get("authorization") or "" if authorization.lower().startswith("bearer "): revoke_auth_session(session, authorization[7:].strip()) session.commit() return {"ok": True}