Add purge authorization codes for protected domains (myvexx.com).

Root generates single-use codes in Infra with root password; Serviços purge requires code plus root password for PURGE_EXTRA_AUTH_DOMAINS.
This commit is contained in:
Ligbox Spec Hub 2026-06-19 22:20:04 +00:00
parent 0ee4845818
commit a39618afb8
8 changed files with 446 additions and 8 deletions

View file

@ -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()

View file

@ -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
]

View file

@ -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()

View file

@ -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

View file

@ -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:

View file

@ -3855,6 +3855,11 @@ async function renderInfra() {
</div>
<p class="health-card-hint">Alerta se gap &gt; ${health.webhook_gap_alert_minutes || 15} min sem eventos VM112.</p>
</div>
<div class="card">
<h3>Códigos autorização purge</h3>
<p class="health-card-hint">Domínios protegidos (ex.: <code>myvexx.com</code>) exigem código único gerado aqui pelo <strong>root</strong> válido para conferência / purge em Serviços.</p>
<div id="purge-auth-infra-panel"><p class="loading">A carregar</p></div>
</div>
<div class="card">
<h3>OpenPanel API Re-engenharia Ligbox</h3>
<p class="health-card-hint">Spec 028 · VM123 bridge :18087 · multidomínio · conta temporária com cleanup automático.</p>
@ -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 = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
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) => `<code>${esc(d)}</code>`).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) => `
<tr>
<td>${esc(c.domain)}</td>
<td>${esc(c.note || '—')}</td>
<td>${fmtDate(c.expires_at)}</td>
<td>${esc(c.created_by || '—')}</td>
</tr>`).join('');
codesHtml = `
<form id="purge-auth-generate-form" class="purge-auth-form">
<label>Domínio</label>
<input type="text" id="purge-auth-domain" class="cf-select" placeholder="myvexx.com" required />
<label>Nota (conferência / ticket)</label>
<input type="text" id="purge-auth-note" class="cf-select" placeholder="Autorizado em call com Roger" />
<label>Validade (horas)</label>
<input type="number" id="purge-auth-ttl" class="cf-select" value="24" min="1" max="168" />
<label>Senha Root</label>
<input type="password" id="purge-auth-root-pwd" class="cf-select" autocomplete="current-password" required />
<button type="submit" class="btn secondary">Gerar código</button>
</form>
<p id="purge-auth-gen-msg" class="ticket-meta"></p>
<div id="purge-auth-generated" class="purge-auth-generated hidden"></div>
<h4 style="margin-top:1rem">Códigos activos</h4>
<table class="purge-history-table">
<thead><tr><th>Domínio</th><th>Nota</th><th>Expira</th><th>Por</th></tr></thead>
<tbody>${rows || '<tr><td colspan="4">Nenhum código activo</td></tr>'}</tbody>
</table>`;
} else {
codesHtml = '<p class="ticket-meta">Apenas <strong>super_admin (root)</strong> gera códigos. Peça o código ao root antes do purge em Serviços.</p>';
}
panel.innerHTML = `
<p><strong>Domínios protegidos:</strong> ${domains}</p>
${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 = `
<p><strong>Código:</strong> <code class="purge-auth-code-display">${esc(res.code)}</code></p>
<p class="ticket-meta">Domínio ${esc(res.domain)} · expira ${fmtDate(res.expires_at)}</p>`;
}
panel.querySelector('#purge-auth-root-pwd').value = '';
} catch (e) {
if (msg) msg.textContent = e.message || 'Falha ao gerar código';
}
});
}
} catch (e) {
panel.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
async function refresh(options = {}) {
const { poll = false } = options;
await loadHealth();

View file

@ -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) => `<li>${esc(a)}</li>`).join('') || '<li class="muted">Nenhuma</li>';
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
? `
<p class="vm112-purge-warn">Domínio protegido exige <strong>código de autorização</strong> gerado em Infra (root).</p>
<label>Código autorização purge</label>
<input type="text" id="vm112-purge-auth-code" class="vm112-purge-input" placeholder="XXXX-XXXX" autocomplete="off" spellcheck="false"/>
`
: '';
body.innerHTML = `
<div class="modal-section">
<h4>Serviço: E-mail Tenant</h4>
@ -835,6 +853,7 @@ const DeskServices = (() => {
<input type="text" id="vm112-purge-confirm" class="vm112-purge-input" placeholder="${esc(domain)}" autocomplete="off"/>
<label>Senha Root</label>
<input type="password" id="vm112-purge-root-pwd" class="vm112-purge-input" autocomplete="current-password"/>
${authCodeField}
<button type="button" class="btn btn-danger" id="vm112-purge-btn">Apagar domínio e todos os dados</button>
<p id="vm112-purge-msg" class="ticket-meta"></p>
</div>`;

View file

@ -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);