diff --git a/projects/ops-desk/api/app/main.py b/projects/ops-desk/api/app/main.py index f903e2b..e66a73e 100644 --- a/projects/ops-desk/api/app/main.py +++ b/projects/ops-desk/api/app/main.py @@ -21,6 +21,7 @@ from app import crm_leads, integration_health from app.cloudflare_dns import fetch_domain_dns from app.modules.routes import router as modules_router from app.vm112_domains_routes import router as vm112_domains_router +from app.purge_auth_routes import router as purge_auth_router from app.carbonio_release_routes import router as carbonio_release_router from app.migration.router import router as migration_router from app.billing_routes import router as billing_router @@ -123,6 +124,7 @@ app.include_router(assist_router) app.include_router(crm_router) app.include_router(modules_router) app.include_router(vm112_domains_router) +app.include_router(purge_auth_router) app.include_router(security_router) app.include_router(carbonio_release_router) app.include_router(migration_router) @@ -175,8 +177,10 @@ def init_db(): migration_store.init_schema(conn) billing_store.init_schema(conn) from app.vm112_purge_jobs import init_purge_jobs_schema + from app.purge_auth_codes import init_purge_auth_schema init_purge_jobs_schema(conn) + init_purge_auth_schema(conn) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA busy_timeout=30000") conn.commit() diff --git a/projects/ops-desk/api/app/purge_auth_codes.py b/projects/ops-desk/api/app/purge_auth_codes.py new file mode 100644 index 0000000..fb2d317 --- /dev/null +++ b/projects/ops-desk/api/app/purge_auth_codes.py @@ -0,0 +1,196 @@ +"""Códigos de autorização purge — domínios protegidos (Spec 017 extensão).""" + +from __future__ import annotations + +import secrets +import sqlite3 +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any + +from app import auth, vm112_domains + +DEFAULT_TTL_HOURS = int(__import__("os").getenv("PURGE_AUTH_CODE_TTL_HOURS", "24")) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _now_iso() -> str: + return _now().isoformat() + + +def init_purge_auth_schema(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS purge_auth_codes ( + id TEXT PRIMARY KEY, + domain TEXT NOT NULL, + code_hash TEXT NOT NULL, + note TEXT, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + used_by TEXT + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_purge_auth_codes_domain_active + ON purge_auth_codes(domain, expires_at, used_at) + """ + ) + conn.commit() + + +def normalize_code(code: str) -> str: + return code.upper().replace("-", "").replace(" ", "").strip() + + +def format_code_display(raw: str) -> str: + raw = normalize_code(raw) + if len(raw) == 8: + return f"{raw[:4]}-{raw[4:]}" + return raw + + +def generate_raw_code() -> str: + return secrets.token_hex(4).upper() + + +def requires_extra_auth(domain: str) -> bool: + return domain.lower().strip() in vm112_domains.PURGE_EXTRA_AUTH_DOMAINS + + +def create_code( + conn: sqlite3.Connection, + domain: str, + root_password: str, + created_by: str, + note: str = "", + ttl_hours: int | None = None, +) -> dict[str, Any]: + domain = domain.lower().strip() + if not vm112_domains.verify_root_password(conn, root_password): + raise ValueError("Senha Root incorrecta") + if domain in vm112_domains.PURGE_BLOCKLIST: + raise ValueError(f"Domínio {domain} está na blocklist absoluta — purge proibido") + if not requires_extra_auth(domain): + raise ValueError(f"Domínio {domain} não exige código extra de autorização") + + ttl = ttl_hours if ttl_hours is not None else DEFAULT_TTL_HOURS + ttl = max(1, min(int(ttl), 168)) + raw = generate_raw_code() + display = format_code_display(raw) + now = _now() + expires = now + timedelta(hours=ttl) + code_id = uuid.uuid4().hex[:16] + + conn.execute( + """ + INSERT INTO purge_auth_codes ( + id, domain, code_hash, note, created_by, created_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + code_id, + domain, + auth.hash_password(normalize_code(raw)), + (note or "").strip() or None, + created_by, + now.isoformat(), + expires.isoformat(), + ), + ) + conn.commit() + return { + "ok": True, + "id": code_id, + "domain": domain, + "code": display, + "expires_at": expires.isoformat(), + "ttl_hours": ttl, + "note": note or None, + } + + +def validate_and_consume( + conn: sqlite3.Connection, + domain: str, + code: str, + used_by: str | None = None, +) -> bool: + domain = domain.lower().strip() + if not requires_extra_auth(domain): + return True + normalized = normalize_code(code or "") + if len(normalized) < 6: + return False + + now = _now_iso() + rows = conn.execute( + """ + SELECT id, code_hash FROM purge_auth_codes + WHERE domain = ? AND used_at IS NULL AND expires_at > ? + ORDER BY created_at DESC + """, + (domain, now), + ).fetchall() + + for row in rows: + if auth.verify_password(normalized, row["code_hash"]): + conn.execute( + """ + UPDATE purge_auth_codes + SET used_at = ?, used_by = ? + WHERE id = ? AND used_at IS NULL + """, + (now, used_by, row["id"]), + ) + conn.commit() + return True + return False + + +def list_codes(conn: sqlite3.Connection, active_only: bool = True, limit: int = 50) -> list[dict[str, Any]]: + limit = max(1, min(int(limit), 200)) + now = _now_iso() + if active_only: + rows = conn.execute( + """ + SELECT id, domain, note, created_by, created_at, expires_at, used_at, used_by + FROM purge_auth_codes + WHERE used_at IS NULL AND expires_at > ? + ORDER BY created_at DESC + LIMIT ? + """, + (now, limit), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT id, domain, note, created_by, created_at, expires_at, used_at, used_by + FROM purge_auth_codes + ORDER BY created_at DESC + LIMIT ? + """, + (limit,), + ).fetchall() + + return [ + { + "id": row["id"], + "domain": row["domain"], + "note": row["note"], + "created_by": row["created_by"], + "created_at": row["created_at"], + "expires_at": row["expires_at"], + "used_at": row["used_at"], + "used_by": row["used_by"], + "active": row["used_at"] is None and row["expires_at"] > now, + } + for row in rows + ] diff --git a/projects/ops-desk/api/app/purge_auth_routes.py b/projects/ops-desk/api/app/purge_auth_routes.py new file mode 100644 index 0000000..37bf18e --- /dev/null +++ b/projects/ops-desk/api/app/purge_auth_routes.py @@ -0,0 +1,75 @@ +"""Rotas Infra — códigos autorização purge (root / super_admin).""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +from app import auth, purge_auth_codes, vm112_domains +from app.permissions import can_manage_users + +router = APIRouter(prefix="/api/v1/infra", tags=["infra-purge-auth"]) + + +class PurgeAuthCodeCreate(BaseModel): + domain: str = Field(..., min_length=3) + root_password: str = Field(..., min_length=1) + note: str = Field("", max_length=500) + ttl_hours: int = Field(24, ge=1, le=168) + + +def _require_root_admin(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser: + if not can_manage_users(user.role): + raise HTTPException(403, "Apenas super_admin (root) pode gerar códigos de purge") + return user + + +@router.get("/purge-auth-codes") +def list_purge_auth_codes( + active_only: bool = Query(True), + limit: int = Query(50, ge=1, le=200), + user: auth.DeskUser = Depends(_require_root_admin), +): + conn = auth.db() + try: + purge_auth_codes.init_purge_auth_schema(conn) + codes = purge_auth_codes.list_codes(conn, active_only=active_only, limit=limit) + return { + "codes": codes, + "extra_auth_domains": sorted(vm112_domains.PURGE_EXTRA_AUTH_DOMAINS), + } + finally: + conn.close() + + +@router.get("/purge-auth-domains") +def list_purge_auth_domains( + user: auth.DeskUser = Depends(auth.get_current_user), +): + return { + "domains": sorted(vm112_domains.PURGE_EXTRA_AUTH_DOMAINS), + "can_generate": can_manage_users(user.role), + } + + +@router.post("/purge-auth-codes") +def create_purge_auth_code( + body: PurgeAuthCodeCreate, + user: auth.DeskUser = Depends(_require_root_admin), +): + conn = auth.db() + try: + purge_auth_codes.init_purge_auth_schema(conn) + result = purge_auth_codes.create_code( + conn, + body.domain, + body.root_password, + user.username, + body.note, + ttl_hours=body.ttl_hours, + ) + return result + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + finally: + conn.close() diff --git a/projects/ops-desk/api/app/vm112_domains.py b/projects/ops-desk/api/app/vm112_domains.py index ea6911f..52b6e78 100644 --- a/projects/ops-desk/api/app/vm112_domains.py +++ b/projects/ops-desk/api/app/vm112_domains.py @@ -16,6 +16,9 @@ VM112_ADMIN_API_KEY = os.getenv("VM112_ADMIN_API_KEY", "ibytera-corp-api-key-cha PURGE_BLOCKLIST = frozenset({"ligbox.com.br", "itecnologys.com"}) +# Exige código de autorização gerado em Infra (root) — além de senha Root no purge. +PURGE_EXTRA_AUTH_DOMAINS = frozenset({"myvexx.com"}) + VM112_PURGE_STEP_LABELS = ( "Contas Carbonio (zmprov da)", "Domínio Carbonio (zmprov dd)", @@ -48,6 +51,10 @@ def verify_root_password(conn: sqlite3.Connection, password: str) -> bool: return auth.verify_password(password, row["password_hash"]) +def requires_purge_extra_auth(domain: str) -> bool: + return domain.lower().strip() in PURGE_EXTRA_AUTH_DOMAINS + + def delete_carbonio_account(email: str) -> dict[str, Any]: """Remove uma conta Carbonio (zmprov da) — Spec 022.""" email = email.lower().strip() @@ -92,7 +99,11 @@ def get_domain(domain: str) -> dict[str, Any]: headers=_vm112_headers(), ) r.raise_for_status() - return r.json() + data = r.json() + if isinstance(data, dict): + data["purge_extra_auth_required"] = requires_purge_extra_auth(domain) + data["purge_blocked"] = domain in PURGE_BLOCKLIST + return data diff --git a/projects/ops-desk/api/app/vm112_domains_routes.py b/projects/ops-desk/api/app/vm112_domains_routes.py index 18147f0..abfa2a8 100644 --- a/projects/ops-desk/api/app/vm112_domains_routes.py +++ b/projects/ops-desk/api/app/vm112_domains_routes.py @@ -25,6 +25,7 @@ router = APIRouter(prefix="/api/v1/vm112", tags=["vm112-domains"]) class DomainPurgeRequest(BaseModel): confirm_domain: str = Field(..., min_length=3) root_password: str = Field(..., min_length=1) + purge_auth_code: str = Field("", max_length=32) def _require_admin(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser: @@ -33,12 +34,36 @@ def _require_admin(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth return user -def _validate_purge_request(domain: str, body: DomainPurgeRequest) -> str: +def _validate_purge_request( + domain: str, + body: DomainPurgeRequest, + conn: sqlite3.Connection | None = None, + username: str | None = None, +) -> str: domain = domain.lower().strip() if domain in vm112_domains.PURGE_BLOCKLIST: raise HTTPException(400, f"Domínio {domain} está protegido contra purge") if body.confirm_domain.lower().strip() != domain: raise HTTPException(400, "Confirmação do domínio não coincide") + if vm112_domains.requires_purge_extra_auth(domain): + if not body.purge_auth_code or not body.purge_auth_code.strip(): + raise HTTPException( + 403, + f"Domínio {domain} exige código de autorização — gere em Infra (root) e use na conferência", + ) + from app import purge_auth_codes + + db = conn or auth.db() + close_conn = conn is None + try: + purge_auth_codes.init_purge_auth_schema(db) + if not purge_auth_codes.validate_and_consume( + db, domain, body.purge_auth_code, used_by=username + ): + raise HTTPException(403, "Código de autorização purge inválido ou expirado") + finally: + if close_conn: + db.close() return domain @@ -70,7 +95,7 @@ def purge_vm112_domain( body: DomainPurgeRequest, user: auth.DeskUser = Depends(_require_admin), ): - domain = _validate_purge_request(domain, body) + domain = _validate_purge_request(domain, body, username=user.username) conn = auth.db() try: if not vm112_domains.verify_root_password(conn, body.root_password): @@ -104,7 +129,7 @@ def purge_vm112_domain_stream( user: auth.DeskUser = Depends(_require_admin), ): """SSE — progresso purge em tempo real (Fase 2 Spec 017).""" - domain = _validate_purge_request(domain, body) + domain = _validate_purge_request(domain, body, username=user.username) return StreamingResponse( purge_sse_generator(domain, body.root_password, user.username), media_type="text/event-stream", @@ -123,7 +148,7 @@ def start_purge_job( user: auth.DeskUser = Depends(_require_admin), ): """Inicia purge em background; consultar GET /purge/jobs/{id} (recomendado via Traefik).""" - domain = _validate_purge_request(domain, body) + domain = _validate_purge_request(domain, body, username=user.username) try: job_id = start_job(domain, body.root_password, user.username) except PurgeJobConflictError as exc: diff --git a/projects/ops-desk/frontend/assets/app.js b/projects/ops-desk/frontend/assets/app.js index 660cdf0..06e86a3 100644 --- a/projects/ops-desk/frontend/assets/app.js +++ b/projects/ops-desk/frontend/assets/app.js @@ -3855,6 +3855,11 @@ async function renderInfra() {

Alerta se gap > ${health.webhook_gap_alert_minutes || 15} min sem eventos VM112.

+
+

Códigos autorização purge

+

Domínios protegidos (ex.: myvexx.com) exigem código único gerado aqui pelo root — válido para conferência / purge em Serviços.

+

A carregar…

+

OpenPanel API — Re-engenharia Ligbox

Spec 028 · VM123 bridge :18087 · multidomínio · conta temporária com cleanup automático.

@@ -3883,11 +3888,97 @@ async function renderInfra() { document.getElementById('btn-refresh-health')?.addEventListener('click', () => renderInfra()); document.getElementById('btn-test-webhook')?.addEventListener('click', () => runWebhookIntegrationTest('infra')); document.getElementById('btn-test-openpanel-api')?.addEventListener('click', () => runOpenPanelApiTest()); + await renderPurgeAuthInfraPanel(); } catch (e) { el.innerHTML = `

Erro: ${esc(e.message)}

`; } } +async function renderPurgeAuthInfraPanel() { + const panel = document.getElementById('purge-auth-infra-panel'); + if (!panel) return; + try { + const meta = await api('/v1/infra/purge-auth-domains'); + const domains = (meta.domains || []).map((d) => `${esc(d)}`).join(', ') || '—'; + const canGen = meta.can_generate && typeof canManageUsers === 'function' && canManageUsers(); + let codesHtml = ''; + if (canGen) { + const data = await api('/v1/infra/purge-auth-codes?limit=20'); + const rows = (data.codes || []).map((c) => ` + + ${esc(c.domain)} + ${esc(c.note || '—')} + ${fmtDate(c.expires_at)} + ${esc(c.created_by || '—')} + `).join(''); + codesHtml = ` +
+ + + + + + + + + +
+

+ +

Códigos activos

+ + + ${rows || ''} +
DomínioNotaExpiraPor
Nenhum código activo
`; + } else { + codesHtml = '

Apenas super_admin (root) gera códigos. Peça o código ao root antes do purge em Serviços.

'; + } + panel.innerHTML = ` +

Domínios protegidos: ${domains}

+ ${codesHtml}`; + const form = panel.querySelector('#purge-auth-generate-form'); + if (form) { + form.addEventListener('submit', async (ev) => { + ev.preventDefault(); + const msg = panel.querySelector('#purge-auth-gen-msg'); + const out = panel.querySelector('#purge-auth-generated'); + const domain = panel.querySelector('#purge-auth-domain')?.value?.trim() || ''; + const note = panel.querySelector('#purge-auth-note')?.value?.trim() || ''; + const ttl = parseInt(panel.querySelector('#purge-auth-ttl')?.value || '24', 10); + const rootPwd = panel.querySelector('#purge-auth-root-pwd')?.value || ''; + if (!domain || !rootPwd) { + if (msg) msg.textContent = 'Preencha domínio e senha Root.'; + return; + } + if (msg) msg.textContent = 'A gerar…'; + try { + const res = await api('/v1/infra/purge-auth-codes', { + method: 'POST', + body: JSON.stringify({ + domain, + root_password: rootPwd, + note, + ttl_hours: ttl, + }), + }); + if (msg) msg.textContent = 'Código gerado — copie agora (não será mostrado de novo).'; + if (out) { + out.classList.remove('hidden'); + out.innerHTML = ` +

Código: ${esc(res.code)}

+

Domínio ${esc(res.domain)} · expira ${fmtDate(res.expires_at)}

`; + } + panel.querySelector('#purge-auth-root-pwd').value = ''; + } catch (e) { + if (msg) msg.textContent = e.message || 'Falha ao gerar código'; + } + }); + } + } catch (e) { + panel.innerHTML = `

Erro: ${esc(e.message)}

`; + } +} + async function refresh(options = {}) { const { poll = false } = options; await loadHealth(); diff --git a/projects/ops-desk/frontend/assets/servicos.js b/projects/ops-desk/frontend/assets/servicos.js index 1457f39..aeb42b7 100644 --- a/projects/ops-desk/frontend/assets/servicos.js +++ b/projects/ops-desk/frontend/assets/servicos.js @@ -550,10 +550,14 @@ const DeskServices = (() => { } } - async function pollPurgeJob(domain, confirmDomain, rootPassword) { + async function pollPurgeJob(domain, confirmDomain, rootPassword, purgeAuthCode = '') { const start = await apiFetch(`/v1/vm112/domains/${encodeURIComponent(domain)}/purge/jobs`, { method: 'POST', - body: JSON.stringify({ confirm_domain: confirmDomain, root_password: rootPassword }), + body: JSON.stringify({ + confirm_domain: confirmDomain, + root_password: rootPassword, + purge_auth_code: purgeAuthCode || '', + }), }, 60000); const jobId = start.job_id; if (!jobId) throw new Error('Job purge não iniciado'); @@ -731,17 +735,23 @@ const DeskServices = (() => { const msg = document.getElementById('vm112-purge-msg'); const confirmDomain = document.getElementById('vm112-purge-confirm')?.value?.trim() || ''; const rootPassword = document.getElementById('vm112-purge-root-pwd')?.value || ''; + const purgeAuthCode = document.getElementById('vm112-purge-auth-code')?.value?.trim() || ''; + const needsAuthCode = Boolean(document.getElementById('vm112-purge-auth-code')); if (!confirmDomain || !rootPassword) { if (msg) msg.textContent = 'Preencha domínio e senha Root.'; return; } + if (needsAuthCode && !purgeAuthCode) { + if (msg) msg.textContent = 'Este domínio exige código de autorização — gere em Infra (root).'; + return; + } if (!window.confirm(`PURGE definitivo de ${domain}?`)) return; const btn = document.getElementById('vm112-purge-btn'); if (btn) { btn.disabled = true; btn.textContent = 'A apagar…'; } if (msg) { msg.textContent = 'A executar purge…'; msg.classList.remove('vm112-purge-success'); } initPurgeTimelineRunning(); try { - const done = await pollPurgeJob(domain, confirmDomain, rootPassword); + const done = await pollPurgeJob(domain, confirmDomain, rootPassword, purgeAuthCode); stopPurgeElapsed(); showPurgeSuccess(done, domain); return; @@ -811,6 +821,14 @@ const DeskServices = (() => { .map((a) => `
  • ${esc(a)}
  • `).join('') || '
  • Nenhuma
  • '; const cf = d.cloudflare_zone; const cfTxt = cf?.name ? `Zona ${cf.name} (${cf.status || '—'})` : 'Sem zona Cloudflare Ibytera'; + const extraAuth = Boolean(d.purge_extra_auth_required); + const authCodeField = extraAuth + ? ` +

    Domínio protegido — exige código de autorização gerado em Infra (root).

    + + + ` + : ''; body.innerHTML = ` `; diff --git a/projects/ops-desk/frontend/assets/styles.css b/projects/ops-desk/frontend/assets/styles.css index 1a4b8f7..2603b2a 100644 --- a/projects/ops-desk/frontend/assets/styles.css +++ b/projects/ops-desk/frontend/assets/styles.css @@ -3888,6 +3888,23 @@ button.health-card { overflow-y: auto; margin-top: 0.75rem; } +.purge-auth-generated { + margin: 0.75rem 0; + padding: 0.75rem 1rem; + background: rgba(46, 125, 50, 0.12); + border-radius: 6px; +} +.purge-auth-generated.hidden { display: none; } +.purge-auth-code-display { + font-size: 1.25rem; + letter-spacing: 0.08em; +} +.purge-auth-form { + display: grid; + gap: 0.5rem; + max-width: 28rem; + margin-top: 0.75rem; +} .purge-history-removed { font-size: 0.85rem; color: var(--muted, #6b7280);