Three client decisions for Denya OneCare / Pavilion Accra:
1. Technician roster converges to exactly 5 named techs
- Samuel Shang, Desmond Afful, Desmond Odekyi, Francis Norgbey, Nicholas Nartey
- New app/services/roster.py: converge_tech_roster() runs at startup and is
idempotent; off-roster techs are DEACTIVATED, never deleted, so ticket
history keeps a valid assignee reference
- seed.py SEED_USERS_DATA updated; placeholder emails until client confirms
2. Sub-contractors appear in the "Assign to" list alongside technicians
- New canonical role "Sub-contractor" (ASSIGNEE_POOL_ROLES = Tech + Sub-contractor)
- New GET /api/auth/assignees endpoint returns active pool members only
- Server-side _validate_assignee gate in ticket service rejects off-pool
or deactivated assignees (400/404)
- "Assign To" dropdown added to the new-ticket form; assigning at creation
auto-advances Logged -> Assigned
- base.html isTech() includes Sub-contractor (portal UX, tracked "under tech")
3. Penthouse units selectable when raising a ticket
- apartment_mapping.json: PH1E-/PH1W-/PH2E-/PH2W- -> clean codes
- seed_units self-heals legacy malformed codes on existing DBs and sets floors
- Penthouse units added to the built-in fallback seed
Tests: new tests/test_wahab_directives_20260928.py (8 tests); updated the
stale East unit count in test_categories_and_units.py (60 -> 62 with penthouses).
Full suite green.
144 lines
5.4 KiB
Python
144 lines
5.4 KiB
Python
"""Authentication router — login, refresh, me, and admin user management.
|
|
|
|
Self-registration was removed (P0 hardening): users are created/managed by
|
|
admins only via ``POST/PATCH/DELETE /api/auth/users``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.database import get_db
|
|
from app.core.ratelimit import login_rate_limiter
|
|
from app.core.roles import ADMIN_ROLES, ASSIGNEE_POOL_ROLES
|
|
from app.core.security import get_current_user, require_roles
|
|
from app.models.user import User
|
|
from app.schemas.auth import (
|
|
AdminCreateUserRequest,
|
|
AdminUpdateUserRequest,
|
|
LoginRequest,
|
|
RefreshRequest,
|
|
TokenResponse,
|
|
UserOut,
|
|
)
|
|
from app.services import auth as auth_service
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
|
|
_require_admin = require_roles(*ADMIN_ROLES)
|
|
|
|
|
|
# ── Public authN ─────────────────────────────────────────────────────
|
|
@router.post("/login", response_model=TokenResponse)
|
|
async def login(
|
|
request: Request,
|
|
body: LoginRequest,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> TokenResponse:
|
|
"""Log in. Brute-force limited to ~5 failures / 15 min per IP+email (429)."""
|
|
client_ip = request.client.host if request.client else "unknown"
|
|
key = login_rate_limiter.key(client_ip, body.email)
|
|
login_rate_limiter.check_or_raise(key)
|
|
|
|
try:
|
|
access, refresh, _user = await auth_service.login(db, body.email, body.password)
|
|
except HTTPException as exc:
|
|
# Count only real auth failures toward the limit; success resets it.
|
|
if exc.status_code == status.HTTP_401_UNAUTHORIZED:
|
|
login_rate_limiter.record_failure(key)
|
|
raise
|
|
login_rate_limiter.clear(key) # successful login resets the failure window
|
|
return TokenResponse(access_token=access, refresh_token=refresh)
|
|
|
|
|
|
@router.post("/refresh", response_model=TokenResponse)
|
|
async def refresh(
|
|
body: RefreshRequest,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> TokenResponse:
|
|
access, refresh = await auth_service.refresh_access_token(db, body.refresh_token)
|
|
return TokenResponse(access_token=access, refresh_token=refresh)
|
|
|
|
|
|
@router.get("/me", response_model=UserOut)
|
|
async def me(current_user: Annotated[User, Depends(get_current_user)]) -> User:
|
|
return current_user
|
|
|
|
|
|
@router.get("/admin-only", response_model=UserOut)
|
|
async def admin_only(
|
|
current_user: Annotated[User, Depends(_require_admin)],
|
|
) -> User:
|
|
"""Example RBAC-protected endpoint — only canonical Admins can access."""
|
|
return current_user
|
|
|
|
|
|
# ── User directory ───────────────────────────────────────────────────
|
|
@router.get("/users", response_model=list[UserOut])
|
|
async def list_users(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_user)],
|
|
) -> list[User]:
|
|
"""List users (id, name, role) for authenticated assignment pickers."""
|
|
result = await db.execute(select(User).order_by(User.full_name))
|
|
return list(result.scalars().all())
|
|
|
|
|
|
@router.get("/assignees", response_model=list[UserOut])
|
|
async def list_assignees(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_user)],
|
|
) -> list[User]:
|
|
"""Active users selectable in the "Assign to" pickers.
|
|
|
|
The pool is the technician roster plus sub-contractors (client
|
|
directive 2026-09-28): external labour assigned directly, tracked
|
|
under the FM coordinator. Deactivated accounts never appear.
|
|
"""
|
|
result = await db.execute(
|
|
select(User)
|
|
.where(User.role.in_(ASSIGNEE_POOL_ROLES), User.active.is_(True))
|
|
.order_by(User.full_name)
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
# ── Admin user management ────────────────────────────────────────────
|
|
@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
|
async def create_user(
|
|
body: AdminCreateUserRequest,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(_require_admin)],
|
|
) -> User:
|
|
"""Admin-only: create a user with a forced canonical role.
|
|
|
|
The client cannot self-register or pick an arbitrary role — unknown roles
|
|
(e.g. ``admin``, ``superadmin``) are rejected with 422.
|
|
"""
|
|
return await auth_service.create_user(db, body)
|
|
|
|
|
|
@router.patch("/users/{user_id}", response_model=UserOut)
|
|
async def update_user(
|
|
user_id: int,
|
|
body: AdminUpdateUserRequest,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(_require_admin)],
|
|
) -> User:
|
|
"""Admin-only: change a user's role and/or deactivate the account."""
|
|
return await auth_service.update_user(db, current_user, user_id, body)
|
|
|
|
|
|
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_user(
|
|
user_id: int,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(_require_admin)],
|
|
) -> None:
|
|
"""Admin-only: delete a user account (guarded; see auth_service)."""
|
|
await auth_service.delete_user(db, current_user, user_id)
|