Files
GTI_GERENCIADOR_FISCAL/central/app/web.py
T

422 lines
28 KiB
Python

"""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 select
from . import agent_distribution, 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 .control_plane import blocker_detail, blocker_filter_summary, dashboard_summary, installation_view, list_blockers, list_installations, list_jobs, list_panel_events
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, Tenant
from .jobs import create_job
from .audit import record_audit
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 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})
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"):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
return render(request, "agents.html", {"result": list_installations(session, page=page, page_size=50, search=search, health=health, sort=sort), "auth": auth_data, "search": search or "", "health_filter": health or "", "sort": sort, "query_base": query_base(search=search, health=health, sort=sort)})
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()
@routes.get("/agents/new", response_class=HTMLResponse)
def new_installation_form(request: Request):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
tenants = session.scalars(select(Tenant).order_by(Tenant.name)).all()
releases = session.scalars(select(AgentRelease).where(AgentRelease.status == "PUBLISHED").order_by(AgentRelease.created_at.desc())).all()
return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": None})
finally: session.close()
@routes.post("/agents/new")
async def create_pending_installation(request: Request):
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"))
tenant_id = values.get("tenant_id")
display_name = (values.get("display_name") or "").strip()[:160]
expected_hostname = (values.get("expected_hostname") or "").strip()[:255] or None
release_id = values.get("release_id")
errors = []
tenant = session.get(Tenant, int(tenant_id)) if tenant_id and tenant_id.isdigit() else None
release = session.get(AgentRelease, int(release_id)) if release_id and release_id.isdigit() else None
if not tenant: errors.append("Selecione um cliente válido.")
if not display_name: errors.append("Informe um nome para a instalação.")
if not release or release.status != "PUBLISHED": errors.append("Selecione uma versão publicada do Agent.")
if errors:
tenants = session.scalars(select(Tenant).order_by(Tenant.name)).all()
releases = session.scalars(select(AgentRelease).where(AgentRelease.status == "PUBLISHED").order_by(AgentRelease.created_at.desc())).all()
return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": " ".join(errors)}, 400)
enrollment_uuid = str(uuid.uuid4())
enr = AgentEnrollment(enrollment_uuid=enrollment_uuid, tenant_id=tenant.id, display_name=display_name, expected_hostname=expected_hostname, release_id=release.id, status="PENDING", created_by=admin.id)
session.add(enr)
record_audit(session, admin_user_id=admin.id, tenant_id=tenant.id, action="INSTALLATION_CREATED", target_type="agent_enrollment", target_id=enrollment_uuid, context={"display_name": display_name, "release_version": release.version})
session.commit()
return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
finally: session.close()
@routes.get("/agents/{agent_id}", response_class=HTMLResponse)
def agent_detail(request: Request, agent_id: str, tab: str = "overview", event_page: int = 1, severity: str | None = None, event_type: str | None = None, job_id: int | None = None, product_code: str | None = None, since: str | None = None, until: str | None = None):
# agent_id is parsed here (not typed int in the path) so a non-numeric
# slug like /agents/abc gets a controlled HTML 404 instead of a raw
# 422 — and can never shadow the static routes above.
try:
agent_id = int(agent_id)
except ValueError:
return HTMLResponse("Agent não encontrado (identificador inválido)", status_code=404)
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
row = session.execute(select(Installation, Agent, Tenant, AgentSnapshot).join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == Installation.tenant_id).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id).where(Agent.id == agent_id)).first()
if row is None: return HTMLResponse("Agent not found", status_code=404)
installation, agent, tenant, snapshot = row
detail = installation_view(installation, agent, tenant, snapshot)
event_page = max(1, event_page)
events = list_panel_events(session, installation.id, page=event_page, page_size=25, include_more=True, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=date_filter(since), until=date_filter(until, end_of_day=True))
has_more = len(events) > 25
events = events[:25]
for event in events:
event["occurred_display"] = format_ui_datetime(datetime.fromisoformat(event["occurred_at"]) if event["occurred_at"] else None)
agent_blockers = list_blockers(session, tenant_id=installation.tenant_id, agent_id=agent_id, page=1, page_size=25)
return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": agent_blockers, "blocker_breakdown": blocker_filter_summary(session, tenant_id=installation.tenant_id, agent_id=agent_id, status="OPEN")["blocker_breakdown"], "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25), "events": events, "event_page": max(1, event_page), "has_more": has_more, "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "since": since or "", "until": until or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=since, until=until), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", "")})
finally: session.close()
@routes.get("/agents/{agent_id}/reprocess-confirmation", response_class=HTMLResponse)
def reprocess_confirmation(request: Request, agent_id: int):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
if not session.scalar(select(Agent.id).where(Agent.id == agent_id)):
return HTMLResponse("Agent not found", status_code=404)
return render(request, "reprocess_confirmation.html", {"agent_id": agent_id, "auth": auth_data})
finally: session.close()
@routes.post("/agents/{agent_id}/reprocess")
async def reprocess_agent(request: Request, agent_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"))
if values.get("confirm") != "yes": return render(request, "reprocess_confirmation.html", {"agent_id": agent_id, "auth": auth_data, "error": "Confirme explicitamente o reprocessamento."}, 400)
agent = session.get(Agent, agent_id)
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
existing = next((candidate for candidate in session.scalars(select(Job).where(Job.agent_id == agent.id, Job.kind == "REPROCESS_ALL", Job.status == "PENDING")).all() if candidate.payload == {}), None)
job = existing or create_job(session, agent, "REPROCESS_ALL", {})
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="REPROCESS_ALL", target_type="installation", target_id=installation.installation_id, context={"job_id": job.id})
session.commit()
return RedirectResponse(f"/agents/{agent_id}?tab=jobs", status_code=303)
finally: session.close()
def _pending_or_404(session, enrollment_uuid):
return session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == enrollment_uuid))
@routes.get("/agents/pending/{enrollment_uuid}", response_class=HTMLResponse)
def pending_installation_detail(request: Request, enrollment_uuid: str, link: str | None = None):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
enr = _pending_or_404(session, enrollment_uuid)
if not enr: return HTMLResponse("Installation not found", status_code=404)
release = session.get(AgentRelease, enr.release_id)
tenant = session.get(Tenant, enr.tenant_id)
installed_agent = session.get(Agent, enr.agent_id) if enr.agent_id else None
return render(request, "agent_pending_detail.html", {"enr": enr, "release": release, "tenant": tenant, "installed_agent": installed_agent, "download_link": link, "auth": auth_data})
finally: session.close()
@routes.post("/agents/pending/{enrollment_uuid}/link")
async def create_download_link(request: Request, enrollment_uuid: str):
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"))
enr = _pending_or_404(session, enrollment_uuid)
if not enr: return HTMLResponse("Installation not found", status_code=404)
if enr.status in ("ENROLLED", "REVOKED"): return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
token = enrollment.new_token()
enr.download_token_hash = enrollment.token_digest(token)
enr.download_token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=config.settings.agent_download_link_ttl_seconds)
enr.download_max_uses = config.settings.agent_download_max_uses_default
enr.download_uses = 0
enr.status = "LINK_CREATED"
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="DOWNLOAD_LINK_CREATED", target_type="agent_enrollment", target_id=enrollment_uuid, context={"ttl_seconds": config.settings.agent_download_link_ttl_seconds, "ip": client_ip(request)})
session.commit()
url = f"{config.settings.base_url}/dl/agent/{token}"
return RedirectResponse(f"/agents/pending/{enrollment_uuid}?link={url}", status_code=303)
finally: session.close()
@routes.get("/agents/pending/{enrollment_uuid}/download")
def direct_download(request: Request, enrollment_uuid: str):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
web_session, admin = auth_data
enr = _pending_or_404(session, enrollment_uuid)
if not enr: return HTMLResponse("Installation not found", status_code=404)
if enr.status == "REVOKED": return HTMLResponse("Installation revoked", status_code=409)
try:
return agent_distribution.build_and_serve(session, enr, request, actor_admin_id=admin.id)
except enrollment.EnrollmentRejected as rejected:
return HTMLResponse(f"Download indisponível: {rejected.reason}", status_code=409)
finally: session.close()
@routes.post("/agents/pending/{enrollment_uuid}/revoke")
async def revoke_pending_installation(request: Request, enrollment_uuid: str):
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"))
enr = _pending_or_404(session, enrollment_uuid)
if not enr: return HTMLResponse("Installation not found", status_code=404)
enr.status = "REVOKED"
enr.download_token_hash = None
enr.enrollment_token_hash = None
if enr.agent_id:
agent = session.get(Agent, enr.agent_id)
if agent: agent.enabled = False
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_REVOKED", target_type="agent_enrollment", target_id=enrollment_uuid, context={"had_agent": bool(enr.agent_id)})
session.commit()
return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
finally: session.close()
@routes.get("/blockers", response_class=HTMLResponse)
def blockers(request: Request, page: int = 1, client: str | None = None, installation: str | None = None, field: str | None = None, blocker_type: str | None = None, status: str = "OPEN", age: str | None = None, search: str | None = None, agent_id: int | None = None, product_code: str | None = None):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
filters = {"agent_id": agent_id, "product_code": product_code, "client": client, "installation": installation, "field": field, "blocker_type": blocker_type, "status": status, "age": age, "search": search}
result = list_blockers(session, page=page, page_size=50, **filters)
summary = blocker_filter_summary(session, **filters)
return render(request, "blockers.html", {"result": result, "summary": summary, "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", ""), "client_filter": client or "", "installation_filter": installation or "", "field_filter": field or "", "blocker_type_filter": blocker_type or "", "status_filter": status or "OPEN", "age_filter": age or "", "search": search or "", "query_base": query_base(**filters)})
finally: session.close()
@routes.get("/blockers/{blocker_id}", response_class=HTMLResponse)
def blocker_page(request: Request, blocker_id: int):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
detail = blocker_detail(session, blocker_id)
if detail is None: return HTMLResponse("Blocker not found", status_code=404)
blocker = session.get(BlockedProduct, blocker_id)
installation = session.get(Installation, blocker.installation_id) if blocker.installation_id else None
tenant = session.get(Tenant, blocker.tenant_id)
company_key = _company_key(blocker, installation, tenant)
current_context = session.scalars(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == blocker.tenant_id, FiscalContextEntry.company_key == company_key)).all() if installation else []
value_choices = sorted({value for values in ENUMS.values() for value in values})
return render(request, "blocker_detail.html", {"detail": detail, "auth": auth_data, "context_entries": current_context, "context_keys": sorted(ALLOWED_KEYS), "value_choices": value_choices, "error": None, "resolution_available": installation is not None, "resolution_values": {}})
finally: session.close()
@routes.post("/blockers/{blocker_id}/resolve-context")
async def resolve_blocker_with_context_route(request: Request, blocker_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"))
payload = {key: values.get(key) for key in ("scope", "scope_key", "key", "value", "reason")}
try:
resolve_blocker_with_context(session, blocker_id, admin, payload, request_ip=request.client.host if request.client else None)
except BlockerResolutionUnavailable as error:
return HTMLResponse(str(error), status_code=409)
except LookupError:
return HTMLResponse("Blocker not found", status_code=404)
except ValueError as error:
detail = blocker_detail(session, blocker_id)
blocker = session.get(BlockedProduct, blocker_id)
installation = session.get(Installation, blocker.installation_id) if blocker and blocker.installation_id else None
tenant = session.get(Tenant, blocker.tenant_id) if blocker else None
company_key = _company_key(blocker, installation, tenant) if blocker else ""
current_context = session.scalars(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == blocker.tenant_id, FiscalContextEntry.company_key == company_key)).all() if blocker and installation else []
return render(request, "blocker_detail.html", {"detail": detail, "auth": auth_data, "context_entries": current_context, "context_keys": sorted(ALLOWED_KEYS), "value_choices": sorted({value for values in ENUMS.values() for value in values}), "error": str(error), "resolution_available": installation is not None, "resolution_values": payload}, 422)
return RedirectResponse(f"/blockers/{blocker_id}", status_code=303)
finally:
session.close()
@routes.get("/jobs", response_class=HTMLResponse)
def jobs(request: Request, page: int = 1, status: str | None = None, search: str | None = None, kind: str | None = None, job_id: int | None = None):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try: return render(request, "jobs.html", {"result": list_jobs(session, page=page, page_size=50, status=status, search=search, kind=kind, job_id=job_id), "auth": auth_data, "status_filter": status or "", "search": search or "", "kind_filter": kind or "", "query_base": query_base(status=status, search=search, kind=kind, job_id=job_id)})
finally: session.close()
return routes