"""Server-rendered operator pages for the GTI Fiscal control panel.""" import secrets import uuid from datetime import datetime, timedelta, timezone from urllib.parse import parse_qs, urlencode from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy import func, select, update from . import agent_distribution, client_sync, config, enrollment, sso from .config import settings from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context from .context_keys import ALLOWED_KEYS, ENUMS from .installation_identity import reference_agent_version from .panel_presentation import ENRICHMENT_MODES, ENRICHMENT_MODE_LABELS, RTC_MODE_EXPLANATION, RTC_MODE_LABELS, abort_reason_label, audit_outcome_label, candidate_status_label, catalog_state_label, job_kind_label, job_status_label, rtc_rule_label, rtc_status_view from .control_plane import blocker_detail, blocker_filter_summary, dashboard_summary, installation_view, jobs_status_summary, list_blockers, list_installations, list_jobs, list_panel_events, rtc_blocker_summary from .human_auth import create_session, current_session, require_csrf, verify_password from .panel_presentation import format_ui_datetime from .models import AdminUser, Agent, AgentEnrollment, AgentRelease, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, ProductEnrichment, ProductFieldAudit, RtcCandidate, RtcWriteAudit, Tenant from .jobs import create_job from .audit import record_audit from .enrichment.observability import coverage_metrics, kb_summary, official_sources, provider_sources def router(session_factory): routes = APIRouter() templates = Jinja2Templates(directory="central/templates") def auth(request, session): return current_session(request, session) def render(request, template, context=None, status_code=200): values = {"request": request, "csrf_token": request.cookies.get("gti_csrf", ""), "panel_home": sso.panel_home(), **(context or {})} return templates.TemplateResponse(request=request, name=template, context=values, status_code=status_code) def client_ip(request): return request.client.host if request.client else None def _aware(value: datetime | None) -> datetime | None: """SQLite (used by the test suite) drops tzinfo on round-trip even for a DateTime(timezone=True) column; Postgres never does. Normalize to UTC before comparing against datetime.now(timezone.utc).""" if value is None or value.tzinfo is not None: return value return value.replace(tzinfo=timezone.utc) def set_session_cookies(response, token, csrf, seconds): cfg = config.settings response.set_cookie(cfg.web_cookie_name, token, max_age=seconds, httponly=True, secure=cfg.web_cookie_secure, samesite="lax") response.set_cookie("gti_csrf", csrf, max_age=seconds, httponly=False, secure=cfg.web_cookie_secure, samesite="lax") def query_base(**values): return urlencode({key: value for key, value in values.items() if value not in (None, "")}) def date_filter(value, *, end_of_day=False): if not value: return None try: parsed = datetime.fromisoformat(value) if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) return parsed.replace(hour=23, minute=59, second=59, microsecond=999999) if end_of_day and len(value) == 10 else parsed except ValueError: return None @routes.get("/login", response_class=HTMLResponse) def login_page(request: Request, next: str | None = None): """Collaborators never see a Hub login: with SSO configured this starts the flow at GTI Gerenciador (which shows its own login if needed).""" if not sso.enabled(): return render(request, "login.html", {"error": None, "emergency": False}) cfg = config.settings state = secrets.token_urlsafe(24) response = RedirectResponse(f"{cfg.sso_start_url}?{urlencode({'state': state, 'next': sso.safe_next(next)})}", status_code=303) # Sent back on the cross-site POST from the panel, hence SameSite=None (+Secure). response.set_cookie(sso.STATE_COOKIE, state, max_age=sso.STATE_SECONDS, path=sso.STATE_COOKIE_PATH, httponly=True, secure=cfg.web_cookie_secure, samesite="none" if cfg.web_cookie_secure else "lax") return response @routes.get("/login/local", response_class=HTMLResponse) def emergency_login_page(request: Request): """Break-glass only (local AdminUser with a password). Not linked from the UI.""" return render(request, "login.html", {"error": None, "emergency": True}) @routes.post("/login") async def login(request: Request): values = await form_values(request) username = values.get("username", "").strip().lower() with session_factory() as session: admin = session.scalar(select(AdminUser).where(AdminUser.username == username)) if not admin or not admin.is_active or admin.auth_source != "LOCAL" or not verify_password(values.get("password", ""), admin.password_hash): record_audit(session, admin_user_id=None, action="LOGIN_LOCAL_FAILURE", target_type="admin_user", target_id=username[:128] or "-", context={"ip": client_ip(request)}, severity="WARNING") session.commit() return render(request, "login.html", {"error": "Credenciais inválidas", "emergency": True}, 401) token, csrf = create_session(session, admin, request) record_audit(session, admin_user_id=admin.id, action="LOGIN_LOCAL_SUCCESS", target_type="admin_user", target_id=admin.username, context={"ip": client_ip(request)}, severity="WARNING") session.commit() response = RedirectResponse("/", status_code=303) set_session_cookies(response, token, csrf, config.settings.web_session_seconds) return response @routes.post("/auth/sso/callback") async def sso_callback(request: Request): values = await form_values(request) with session_factory() as session: try: if not sso.enabled(): raise sso.SsoRejected("SSO_DISABLED") identity = sso.verify_assertion(values.get("assertion"), request.cookies.get(sso.STATE_COOKIE)) sso.consume_jti(session, identity) user = sso.provision(session, identity) seconds = config.settings.federated_session_seconds token, csrf = create_session(session, user, request, seconds=seconds) record_audit(session, admin_user_id=user.id, action="SSO_LOGIN_SUCCESS", target_type="admin_user", target_id=identity.subject, context={"ip": client_ip(request), "login": identity.login, "role": identity.role}) session.commit() except sso.SsoRejected as rejected: session.rollback() record_audit(session, admin_user_id=None, action="SSO_LOGIN_REJECTED", target_type="sso", target_id=rejected.subject or "-", context={"ip": client_ip(request), "reason": rejected.reason}, severity="WARNING") session.commit() response = render(request, "sso_error.html", {"reason": rejected.reason}, 401) response.delete_cookie(sso.STATE_COOKIE, path=sso.STATE_COOKIE_PATH) return response response = RedirectResponse(sso.safe_next(values.get("next")), status_code=303) set_session_cookies(response, token, csrf, seconds) response.delete_cookie(sso.STATE_COOKIE, path=sso.STATE_COOKIE_PATH) return response @routes.post("/logout") async def logout(request: Request): """Ends only the Hub session. Coming back through GTI Gerenciador signs in again without a password (documented in docs/PILOT_DEPLOYMENT.md).""" with session_factory() as session: web_session, admin = current_session(request, session) if web_session: values = await form_values(request) require_csrf(web_session, values.get("csrf_token")) record_audit(session, admin_user_id=admin.id, action="LOGOUT", target_type="admin_user", target_id=admin.external_subject or admin.username, context={"ip": client_ip(request)}) session.delete(web_session); session.commit() response = render(request, "logged_out.html", {}) if sso.enabled() else RedirectResponse("/login", status_code=303) response.delete_cookie(config.settings.web_cookie_name); response.delete_cookie("gti_csrf") return response def page_auth(request): session = session_factory() web_session, admin = current_session(request, session) if not admin: session.close() wanted = request.url.path + (f"?{request.url.query}" if request.url.query else "") return None, None, RedirectResponse(f"/login?{urlencode({'next': wanted})}", status_code=303) return session, (web_session, admin), None async def form_values(request): return {key: items[0] for key, items in parse_qs((await request.body()).decode()).items() if items} @routes.get("/", response_class=HTMLResponse) def dashboard(request: Request): session, auth_data, redirect = page_auth(request) if redirect: return redirect try: return render(request, "dashboard.html", {"summary": dashboard_summary(session), "auth": auth_data, "sources": provider_sources(session), "official": official_sources(session), "enrich_metrics": coverage_metrics(session), "kb": kb_summary(session)}) finally: session.close() @routes.get("/agents", response_class=HTMLResponse) def agents(request: Request, page: int = 1, search: str | None = None, health: str | None = None, sort: str = "display_name", scope: str = "active", version: str | None = None): session, auth_data, redirect = page_auth(request) if redirect: return redirect try: scope = scope if scope in ("active", "all") else "active" health = health if health in ("ONLINE", "DEGRADED", "OFFLINE", "FAILED", "UNKNOWN") else None sort = sort if sort in ("display_name", "worst_coverage") else "display_name" version = version if version == "outdated" else None return render(request, "agents.html", {"result": list_installations(session, page=page, page_size=50, search=search, health=health, sort=sort, scope=scope, version=version), "auth": auth_data, "search": search or "", "health_filter": health or "", "sort": sort, "scope_filter": scope, "version_filter": version or "", "query_base": query_base(search=search, health=health, sort=sort, scope=scope, version=version)}) finally: session.close() # --- Static /agents/* routes MUST be declared before /agents/{agent_id}: # Starlette matches in registration order, so a dynamic route declared # first would capture "/agents/new" and "/agents/releases" and fail the # int conversion with a 422 (the production bug this ordering prevents). @routes.get("/agents/releases", response_class=HTMLResponse) def agent_releases(request: Request): session, auth_data, redirect = page_auth(request) if redirect: return redirect try: releases = session.scalars(select(AgentRelease).order_by(AgentRelease.created_at.desc())).all() counts = {} for release_id, in session.execute(select(AgentEnrollment.release_id)).all(): counts[release_id] = counts.get(release_id, 0) + 1 return render(request, "agent_releases.html", {"releases": releases, "installation_counts": counts, "auth": auth_data}) finally: session.close() @routes.post("/agents/releases/{release_id}/revoke") async def revoke_release(request: Request, release_id: int): session, auth_data, redirect = page_auth(request) if redirect: return redirect try: values = await form_values(request); web_session, admin = auth_data require_csrf(web_session, values.get("csrf_token")) release = session.get(AgentRelease, release_id) if not release: return HTMLResponse("Release not found", status_code=404) release.status = "REVOKED" record_audit(session, admin_user_id=admin.id, action="AGENT_RELEASE_REVOKED", target_type="agent_release", target_id=release.version, context={"platform": release.platform, "architecture": release.architecture}) session.commit() return RedirectResponse("/agents/releases", status_code=303) finally: session.close() def _active_gerenciador_tenants(session, search: str | None = None): # GTI Gerenciador is the single source of truth for clients (central/app/ # client_sync.py) — a new installation never offers a LOCAL (dev/test) or # inactive tenant, so it can never pick a client that doesn't exist there. query = select(Tenant).where(Tenant.source == client_sync.SOURCE, Tenant.active.is_(True)) if search: query = query.where(Tenant.name.ilike(f"%{search}%")) return session.scalars(query.order_by(Tenant.name)).all() def _clients_json(tenants): import json # "<" escaped so the payload can never close the