"""Internal-user authentication and permission introspection routes. Frontends should call ``GET /api/access/me`` to discover which permission keys the current user has, then use those keys to hide/show navigation items. **Visibility is not security** — every privileged backend route must depend on ``require_permission`` (or one of its siblings) directly. """ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session, selectinload from app.core.access import ( INTERNAL_USER_SUBJECT, INTERNAL_USER_TENANT_ID, get_current_user, get_user_permissions, permissions_to_module_map, require_permission, ) from app.core.config import settings from app.core.http import CLIENT_AUTH_COOKIE from app.core.rate_limit import SlidingWindowRateLimiter, request_client_key from app.core.security_logging import log_security_event from app.core.security import hash_password, issue_token, verify_password from app.db.session import get_db from app.models.access import Permission, Role, User router = APIRouter(prefix="/api/access", tags=["access"]) login_rate_limiter = SlidingWindowRateLimiter( limit=settings.login_rate_limit_attempts, window_seconds=settings.login_rate_limit_window_seconds, ) class LoginRequest(BaseModel): email: str password: str class UserSession(BaseModel): # Mirrors the existing `LoginResponse` shape so the frontend's `AppSession` # store can consume this response without a separate type. `permissions` # is the new permission-key array; `module_permissions` is the legacy # module→access-level map for nav gating. user_id: int email: str name: str role: str role_name: str | None = None is_active: bool tenant_id: str | None = None client_role: str | None = None client_account_id: int | None = None module_permissions: dict[str, str] = {} permissions: list[str] token: str | None = None class RoleRead(BaseModel): id: int name: str description: str | None permissions: list[str] class UserRead(BaseModel): id: int email: str name: str is_active: bool role: str | None def _serialize_session(user: User, *, include_token: bool = False) -> UserSession: permission_set = get_user_permissions(user) permissions = sorted(permission_set) module_permissions = permissions_to_module_map(permission_set) role_name = user.role.name if user.role else None token = None if include_token: token = issue_token( {"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email}, ttl_seconds=settings.session_ttl_seconds, ) # role="internal" is a marker the shared auth deps recognise so internal # users can hit the same routes as client-portal users without being # confused with them. Display name lives in role_name / client_role. return UserSession( user_id=user.id, email=user.email, name=user.name, role="internal", role_name=role_name, is_active=user.is_active, tenant_id=INTERNAL_USER_TENANT_ID, client_role=role_name, client_account_id=None, module_permissions=module_permissions, permissions=permissions, token=token, ) @router.post("/login", response_model=UserSession) def login(payload: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)): """Internal-user login. Authenticates against the per-user password hash stored on ``users``. Inactive or unknown users are rejected with a generic 401 to avoid leaking which emails are valid. """ login_rate_limiter.hit(request_client_key(request, suffix="internal-login")) email = payload.email.strip().lower() user = db.scalar( select(User) .where(User.email == email) .options(selectinload(User.role).selectinload(Role.permissions)) ) if user is None or not user.is_active: log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request)) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") if not ( verify_password(payload.password, user.password_hash) or (user.password_hash is None and payload.password == settings.admin_password) ): log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request)) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") session = _serialize_session(user, include_token=True) if session.token: CLIENT_AUTH_COOKIE.apply(response, session.token) log_security_event("auth.login_succeeded", audience="internal", role=user.role.name if user.role else None, user_id=user.id) return session.model_copy(update={"token": None}) @router.get("/me", response_model=UserSession) def read_me(user: User = Depends(get_current_user)): """Return the current user with permission keys for UI navigation gating.""" return _serialize_session(user).model_copy(update={"token": None}) @router.get("/me/permissions", response_model=list[str]) def read_my_permissions(user: User = Depends(get_current_user)): return sorted(get_user_permissions(user)) class UpdateMeRequest(BaseModel): name: str | None = None email: str | None = None current_password: str | None = None new_password: str | None = None @router.patch("/me", response_model=UserSession) def update_me( payload: UpdateMeRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Allow an internal user to update their own name, email, or password.""" if payload.new_password: # Require current password verification before allowing a password # change. Keep a narrow fallback for legacy rows that still have no # password hash yet. current_ok = verify_password(payload.current_password or "", user.password_hash) or ( user.password_hash is None and (payload.current_password or "") == settings.admin_password ) if not current_ok: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect", ) if len(payload.new_password) < 8: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="New password must be at least 8 characters", ) user.password_hash = hash_password(payload.new_password) if payload.name is not None: name = payload.name.strip() if not name: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name cannot be empty") user.name = name if payload.email is not None: email = payload.email.strip().lower() if not email or "@" not in email: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid email address") existing = db.scalar(select(User).where(User.email == email, User.id != user.id)) if existing: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already in use") user.email = email db.commit() db.refresh(user) return _serialize_session(user, include_token=True).model_copy(update={"token": None}) @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) def logout(response: Response): CLIENT_AUTH_COOKIE.clear(response) response.status_code = status.HTTP_204_NO_CONTENT return None # Permission-enforced administrative endpoints. Route bodies should not check # role names — every gate is the require_permission(...) dependency. @router.get("/users", response_model=list[UserRead]) def list_users( db: Session = Depends(get_db), _: User = Depends(require_permission("view_users")), # gated by permission key ): users = db.scalars(select(User).options(selectinload(User.role))).all() return [ UserRead( id=user.id, email=user.email, name=user.name, is_active=user.is_active, role=user.role.name if user.role else None, ) for user in users ] @router.get("/roles", response_model=list[RoleRead]) def list_roles( db: Session = Depends(get_db), _: User = Depends(require_permission("manage_permissions")), # gated by permission key ): roles = db.scalars( select(Role).options(selectinload(Role.permissions)).order_by(Role.name) ).all() return [ RoleRead( id=role.id, name=role.name, description=role.description, permissions=sorted(p.key for p in role.permissions), ) for role in roles ] @router.get("/permissions", response_model=list[str]) def list_permissions( db: Session = Depends(get_db), _: User = Depends(require_permission("manage_permissions")), # gated by permission key ): return sorted(p.key for p in db.scalars(select(Permission)).all())