Implement Spec 027 access matrix preview and agent bindings enforcement.

Adds RBAC matrix API/UI behind feature flags, agent×role toggles with audit,
and extends Agentic Ops with streaming chat, Kimi LLM support, and UI updates.
This commit is contained in:
Ligbox Spec Hub 2026-06-20 22:02:03 +00:00
parent 9c443f32a9
commit 6c4b063f76
20 changed files with 6256 additions and 447 deletions

View file

@ -0,0 +1,358 @@
"""Persistência e enforcement — atribuições Agentics × função (Spec 027 UI-C)."""
from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from typing import Any
RELATIONS = ("ui", "focus", "approve")
CAP_IDS = ("use_ui", "trigger_runs", "approve_runbooks", "configure_models")
LOCKED_AGENT_BINDINGS: frozenset[tuple[str, str, str]] = frozenset({
("A7", "agentic_operator", "approve"),
("A7", "super_admin", "approve"),
})
AGENT_IDS = ("A0", "A1", "A2", "A3", "A4", "A5", "A6", "A7")
def _spec():
from app.rbac_matrix import AGENTIC_GOVERNANCE, AGENT_ROLE_MAP, ROLE_COLUMNS
return AGENTIC_GOVERNANCE, AGENT_ROLE_MAP, ROLE_COLUMNS
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _default_agent_enabled(agent_id: str, role_id: str, relation: str) -> bool:
_, AGENT_ROLE_MAP, _ = _spec()
from app.rbac_matrix import AGENTIC_GOVERNANCE
mapping = AGENT_ROLE_MAP.get(agent_id, {})
if relation == "approve":
return role_id in mapping.get("approvers", [])
if relation == "focus":
return role_id in mapping.get("operators", [])
if relation == "ui":
return role_id in AGENTIC_GOVERNANCE["use_ui"]
return False
def _default_cap_enabled(cap_id: str, role_id: str) -> bool:
AGENTIC_GOVERNANCE, _, _ = _spec()
roles = AGENTIC_GOVERNANCE.get(cap_id)
if isinstance(roles, list):
return role_id in roles
return False
def _is_locked(agent_id: str, role_id: str, relation: str) -> bool:
return (agent_id, role_id, relation) in LOCKED_AGENT_BINDINGS
def init_schema(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS agent_role_bindings (
agent_id TEXT NOT NULL,
role_id TEXT NOT NULL,
relation TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
locked INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
updated_by TEXT,
PRIMARY KEY (agent_id, role_id, relation)
);
CREATE TABLE IF NOT EXISTS agent_governance_caps (
cap_id TEXT NOT NULL,
role_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
locked INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
updated_by TEXT,
PRIMARY KEY (cap_id, role_id)
);
CREATE TABLE IF NOT EXISTS rbac_agent_audit (
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL,
username TEXT NOT NULL,
action TEXT NOT NULL,
payload_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rbac_agent_audit_ts ON rbac_agent_audit(ts DESC);
"""
)
_seed_defaults(conn)
def _seed_defaults(conn: sqlite3.Connection) -> None:
_, _, ROLE_COLUMNS = _spec()
now = _now()
if conn.execute("SELECT COUNT(*) c FROM agent_role_bindings").fetchone()["c"] == 0:
rows = []
for agent_id in AGENT_IDS:
for role_id in ROLE_COLUMNS:
for relation in RELATIONS:
enabled = 1 if _default_agent_enabled(agent_id, role_id, relation) else 0
locked = 1 if _is_locked(agent_id, role_id, relation) else 0
rows.append((agent_id, role_id, relation, enabled, locked, now, "system"))
conn.executemany(
"""INSERT INTO agent_role_bindings
(agent_id, role_id, relation, enabled, locked, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
rows,
)
if conn.execute("SELECT COUNT(*) c FROM agent_governance_caps").fetchone()["c"] == 0:
cap_rows = []
for cap_id in CAP_IDS:
for role_id in ROLE_COLUMNS:
enabled = 1 if _default_cap_enabled(cap_id, role_id) else 0
cap_rows.append((cap_id, role_id, enabled, 0, now, "system"))
conn.executemany(
"""INSERT INTO agent_governance_caps
(cap_id, role_id, enabled, locked, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?)""",
cap_rows,
)
def audit_log(
conn: sqlite3.Connection,
*,
username: str,
action: str,
payload: dict[str, Any],
) -> None:
conn.execute(
"INSERT INTO rbac_agent_audit (ts, username, action, payload_json) VALUES (?, ?, ?, ?)",
(_now(), username, action, json.dumps(payload)),
)
def list_audit(conn: sqlite3.Connection, *, limit: int = 50) -> list[dict[str, Any]]:
rows = conn.execute(
"SELECT id, ts, username, action, payload_json FROM rbac_agent_audit ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
out = []
for r in rows:
item = dict(r)
try:
item["payload"] = json.loads(item.pop("payload_json") or "{}")
except json.JSONDecodeError:
item["payload"] = {}
out.append(item)
return out
def _row_enabled(row: sqlite3.Row | None, default: bool = False) -> bool:
if row is None:
return default
return bool(row["enabled"])
def agent_relations(conn: sqlite3.Connection, agent_id: str, role_id: str) -> list[str]:
"""Relações activas função ↔ agente."""
if role_id == "super_admin":
return list(RELATIONS)
rels: list[str] = []
for relation in RELATIONS:
row = conn.execute(
"""SELECT enabled FROM agent_role_bindings
WHERE agent_id=? AND role_id=? AND relation=?""",
(agent_id, role_id, relation),
).fetchone()
if _row_enabled(row, _default_agent_enabled(agent_id, role_id, relation)):
rels.append(relation)
return rels
def cap_enabled(conn: sqlite3.Connection, cap_id: str, role_id: str) -> bool:
if role_id == "super_admin":
return True
row = conn.execute(
"SELECT enabled FROM agent_governance_caps WHERE cap_id=? AND role_id=?",
(cap_id, role_id),
).fetchone()
return _row_enabled(row, _default_cap_enabled(cap_id, role_id))
def can_use_agentics_ui(conn: sqlite3.Connection, role_id: str) -> bool:
if role_id == "super_admin":
return True
return cap_enabled(conn, "use_ui", role_id)
def can_chat_agent(conn: sqlite3.Connection, role_id: str, agent_id: str) -> bool:
if role_id == "super_admin":
return True
rels = agent_relations(conn, agent_id, role_id)
return bool(set(rels) & {"ui", "focus", "approve"})
def can_approve_agent(conn: sqlite3.Connection, role_id: str, agent_id: str) -> bool:
if role_id == "super_admin":
return True
return "approve" in agent_relations(conn, agent_id, role_id)
def can_trigger_runs(conn: sqlite3.Connection, role_id: str) -> bool:
if role_id == "super_admin":
return True
return cap_enabled(conn, "trigger_runs", role_id)
def can_approve_runbooks(conn: sqlite3.Connection, role_id: str) -> bool:
if role_id == "super_admin":
return True
return cap_enabled(conn, "approve_runbooks", role_id)
def load_bindings_matrix(conn: sqlite3.Connection) -> dict[str, Any]:
"""Estado completo para UI / export."""
agent_rows = conn.execute(
"SELECT agent_id, role_id, relation, enabled, locked FROM agent_role_bindings"
).fetchall()
cap_rows = conn.execute(
"SELECT cap_id, role_id, enabled, locked FROM agent_governance_caps"
).fetchall()
agents: dict[str, dict[str, dict[str, dict[str, Any]]]] = {}
for r in agent_rows:
agents.setdefault(r["agent_id"], {}).setdefault(r["role_id"], {})[r["relation"]] = {
"enabled": bool(r["enabled"]),
"locked": bool(r["locked"]),
}
caps: dict[str, dict[str, dict[str, Any]]] = {}
for r in cap_rows:
caps.setdefault(r["cap_id"], {})[r["role_id"]] = {
"enabled": bool(r["enabled"]),
"locked": bool(r["locked"]),
}
return {"agents": agents, "caps": caps}
def role_agentic_caps(conn: sqlite3.Connection, role_id: str) -> list[dict[str, str]]:
from app.rbac_matrix import AGENTIC_GOVERNANCE
caps = []
for cap_id in CAP_IDS:
if cap_enabled(conn, cap_id, role_id):
caps.append({
"id": cap_id,
"label": AGENTIC_GOVERNANCE["labels"].get(cap_id, cap_id),
})
return caps
def set_agent_binding(
conn: sqlite3.Connection,
*,
agent_id: str,
role_id: str,
relation: str,
enabled: bool,
username: str,
) -> dict[str, Any]:
_, _, ROLE_COLUMNS = _spec()
if agent_id not in AGENT_IDS:
raise ValueError(f"agente inválido: {agent_id}")
if role_id not in ROLE_COLUMNS:
raise ValueError(f"função inválida: {role_id}")
if relation not in RELATIONS:
raise ValueError(f"relação inválida: {relation}")
row = conn.execute(
"""SELECT enabled, locked FROM agent_role_bindings
WHERE agent_id=? AND role_id=? AND relation=?""",
(agent_id, role_id, relation),
).fetchone()
locked = bool(row["locked"]) if row else _is_locked(agent_id, role_id, relation)
if locked and not enabled:
raise ValueError("binding bloqueado pela Spec 027 (obrigatório)")
now = _now()
if row:
conn.execute(
"""UPDATE agent_role_bindings SET enabled=?, updated_at=?, updated_by=?
WHERE agent_id=? AND role_id=? AND relation=?""",
(1 if enabled else 0, now, username, agent_id, role_id, relation),
)
else:
conn.execute(
"""INSERT INTO agent_role_bindings
(agent_id, role_id, relation, enabled, locked, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(agent_id, role_id, relation, 1 if enabled else 0, 1 if locked else 0, now, username),
)
audit_log(
conn,
username=username,
action="agent_binding.update",
payload={
"agent_id": agent_id,
"role_id": role_id,
"relation": relation,
"enabled": enabled,
},
)
return {
"agent_id": agent_id,
"role_id": role_id,
"relation": relation,
"enabled": enabled,
"locked": locked,
}
def set_governance_cap(
conn: sqlite3.Connection,
*,
cap_id: str,
role_id: str,
enabled: bool,
username: str,
) -> dict[str, Any]:
_, _, ROLE_COLUMNS = _spec()
if cap_id not in CAP_IDS:
raise ValueError(f"capacidade inválida: {cap_id}")
if role_id not in ROLE_COLUMNS:
raise ValueError(f"função inválida: {role_id}")
row = conn.execute(
"SELECT enabled, locked FROM agent_governance_caps WHERE cap_id=? AND role_id=?",
(cap_id, role_id),
).fetchone()
locked = bool(row["locked"]) if row else False
if locked and not enabled:
raise ValueError("capacidade bloqueada")
now = _now()
if row:
conn.execute(
"""UPDATE agent_governance_caps SET enabled=?, updated_at=?, updated_by=?
WHERE cap_id=? AND role_id=?""",
(1 if enabled else 0, now, username, cap_id, role_id),
)
else:
conn.execute(
"""INSERT INTO agent_governance_caps
(cap_id, role_id, enabled, locked, updated_at, updated_by)
VALUES (?, ?, ?, 0, ?, ?)""",
(cap_id, role_id, 1 if enabled else 0, now, username),
)
audit_log(
conn,
username=username,
action="agent_cap.update",
payload={"cap_id": cap_id, "role_id": role_id, "enabled": enabled},
)
return {"cap_id": cap_id, "role_id": role_id, "enabled": enabled, "locked": locked}

View file

@ -0,0 +1,240 @@
"""Contexto operacional rico para chat investigativo — Spec 030+."""
from __future__ import annotations
import sqlite3
from app.agents.catalog import AGENT_CATALOG
from app.agents import store
AGENT_INVESTIGATION_LENS: dict[str, str] = {
"A0": "Visão global: correlacionar incidentes, delegar aos agentes certos, priorizar por severidade.",
"A1": "Investigar: VM112 wizard/Carbonio, Proxmox cluster, CPU/RAM, containers down, restarts.",
"A2": "Investigar: DNS Cloudflare, Traefik CT114, certificados LE, SNI, pfSense API/regras.",
"A3": "Investigar: SPF/DKIM/DMARC, entregabilidade mail, registos DNS por domínio tenant.",
"A4": "Investigar: filas mail, amavis/clamav, spam, quarentena, bloqueios de envio.",
"A5": "Investigar: alertas Wazuh VM104, correlação SIEM, timeline segurança, runbooks R0/R1.",
"A6": "Investigar: tickets Desk, onboarding stuck, findings abertos, contexto cliente, runbooks KB.",
"A7": "Investigar: runbooks aprovados pós-incidente, remediação R0R3, aprovações pendentes.",
"sentinel": "Investigar: health checks T0 (desk, wizard, pfSense, proxmox, ollama, VM123 stack).",
}
def _fmt_incident(inc: dict) -> str:
action = (inc.get("suggested_human_action") or "Investigar manualmente.")[:200]
return (
f"- [{inc.get('severity', '?').upper()}] {inc.get('title')} "
f"(cenário `{inc.get('scenario_id')}`, agente {inc.get('agent_name', inc.get('primary_agent'))}, "
f"visto {inc.get('occurrence_count', 1)}×, última vez {inc.get('last_seen_at', '?')})\n"
f" → Acção sugerida: {action}"
)
def build_ops_context(
conn: sqlite3.Connection,
question: str,
target_agent: str,
*,
include_findings: bool = True,
kb_snippets: list[str] | None = None,
) -> str:
"""Monta dossiê operacional para respostas investigativas."""
sections: list[str] = []
ov = store.get_overview(conn)
open_inc = ov.get("incidents_open") or {}
sections.append(
"### Estado do ambiente (dados reais do Agentic Ops)\n"
f"- Provider LLM: {ov.get('provider')} / {ov.get('model')}\n"
f"- Último tick worker: {ov.get('last_tick_at') or 'desconhecido'} — status `{ov.get('last_tick_status', '?')}`\n"
f"- Cenários vigilância: **{ov.get('scenarios_ok', 0)}/{ov.get('scenarios_total', 0)} OK**\n"
f"- Incidentes abertos: {open_inc.get('critical', 0)} crít · "
f"{open_inc.get('high', 0)} alto · {open_inc.get('warn', 0)} aviso"
)
lens = AGENT_INVESTIGATION_LENS.get(target_agent, AGENT_INVESTIGATION_LENS["A6"])
profile = AGENT_CATALOG.get(target_agent, AGENT_CATALOG["A6"])
sections.append(
f"### Lente investigativa do agente {profile.name} ({target_agent})\n{lens}\n"
f"Competências: {', '.join(profile.reads[:4])}"
)
incidents = store.list_incidents(conn, status="open", limit=15)
if target_agent not in ("A0", "A6", "ALL"):
agent_first = [i for i in incidents if i.get("primary_agent") == target_agent]
other = [i for i in incidents if i.get("primary_agent") != target_agent][:5]
incidents = agent_first + other
if incidents:
sections.append(
"### Incidentes activos (deduplicados por cenário)\n"
+ "\n".join(_fmt_incident(i) for i in incidents[:8])
)
else:
sections.append("### Incidentes activos\nNenhum incidente aberto no momento.")
scenarios = store.list_scenarios(conn)
failing = [s for s in scenarios if s.get("last_run_status") not in (None, "ok")]
if failing:
fail_lines = []
for s in failing[:8]:
fail_lines.append(
f"- `{s['id']}` — {s.get('title', s['id'])}: status **{s.get('last_run_status')}** "
f"(último run {s.get('last_run_at', '?')})"
)
sections.append("### Cenários com falha recente\n" + "\n".join(fail_lines))
if include_findings:
findings = store.list_findings(conn, limit=10, open_only=True)
if findings:
flines = []
for f in findings[:8]:
detail = (f.get("detail_md") or f.get("title") or "")[:180]
flines.append(
f"- [{f.get('severity')}] **{f.get('title')}** — {detail}\n"
f" → Humano: {(f.get('suggested_human_action') or 'verificar')[:160]}"
)
sections.append("### Findings T0/T1 abertos\n" + "\n".join(flines))
if kb_snippets:
sections.append(
"### Base de conhecimento (RAG — specs/runbooks)\n"
+ "\n---\n".join(kb_snippets[:6])[:4500]
)
sections.append(
"### Infra de referência Ligbox\n"
"- Proxmox host · pfSense WAN/LAN · VM112 wizard/onboard · VM122 Desk/API · VM123 finance/Ollama\n"
"- API pfSense via Traefik: `firewall.itecnologys.com` · Desk: `desk.ligbox.com.br`"
)
return "\n\n".join(sections)
RESPONSE_FORMAT_INSTRUCTION = """
## Formato OBRIGATÓRIO da resposta (markdown)
Estruture SEMPRE assim respostas planas ou genéricas são proibidas:
### 🔍 Diagnóstico
Sintetize o que os **dados do contexto** indicam sobre a pergunta. Cite incidentes/findings/cenários relevantes por nome.
### 🧪 Hipóteses (ordem de probabilidade)
1. **Hipótese mais provável** porquê, o que corrobora
2. **Hipótese alternativa** o que verificar para confirmar/descartar
3. (opcional) terceira hipótese se aplicável
### ✅ Plano de acção proposto
| # | Prioridade | Acção concreta | Onde / comando |
|---|------------|----------------|----------------|
| 1 | Alta/Média/Baixa | passo executável | `comando` ou painel/link |
| 2 | ... | ... | ... |
Inclua **comandos shell** ou **URLs/painéis** quando souber (sem inventar credenciais).
### ⚠️ Riscos e impacto
- O que pode piorar se ignorarmos
- Dependências (ex.: reiniciar X afecta Y)
### ▶️ Próximo passo imediato
Uma frase directa: **faça isto agora** a acção única de maior valor.
---
Regras: português BR · baseie-se no contexto injectado · se faltar dado, diga **exactamente** o que o operador deve correr/verificar · não invente estados de serviços.
"""
def _question_relevance(question: str, *texts: str) -> int:
q = question.lower()
score = 0
for t in texts:
if not t:
continue
tl = str(t).lower()
for word in q.split():
if len(word) > 3 and word in tl:
score += 2
if tl in q or q in tl:
score += 3
return score
def build_suggested_actions(
conn: sqlite3.Connection,
question: str,
target_agent: str,
*,
limit: int = 6,
) -> list[dict]:
"""Acções executáveis no Desk — checks T0 e ack de incidentes."""
actions: list[dict] = []
seen_scenarios: set[str] = set()
seen_incidents: set[int] = set()
def add_run(scenario_id: str, label: str, severity: str = "info") -> None:
if scenario_id in seen_scenarios or len(actions) >= limit:
return
if not store.get_scenario(conn, scenario_id):
return
seen_scenarios.add(scenario_id)
actions.append({
"type": "run_scenario",
"scenario_id": scenario_id,
"label": label,
"severity": severity,
})
def add_ack(incident_id: int, label: str, severity: str, scenario_id: str) -> None:
if incident_id in seen_incidents or len(actions) >= limit:
return
seen_incidents.add(incident_id)
actions.append({
"type": "ack_incident",
"incident_id": incident_id,
"scenario_id": scenario_id,
"label": label,
"severity": severity,
})
incidents = store.list_incidents(conn, status="open", limit=15)
ranked_inc = sorted(
incidents,
key=lambda i: (
-{"critical": 4, "high": 3, "warn": 2, "info": 1}.get(i.get("severity", "info"), 0),
-_question_relevance(question, i.get("title", ""), i.get("scenario_id", "")),
),
)
for inc in ranked_inc[:5]:
sid = inc.get("scenario_id") or ""
title = inc.get("title") or sid
sev = inc.get("severity") or "warn"
rel = _question_relevance(question, title, sid)
if rel > 0 or len(actions) < 2:
add_run(sid, f"▶ Executar check: {title[:48]}", sev)
if rel > 0 and inc.get("id"):
add_ack(int(inc["id"]), f"✓ Ack #{inc['id']} · {title[:32]}", sev, sid)
for s in store.list_scenarios(conn):
if s.get("last_run_status") not in (None, "ok"):
add_run(
s["id"],
f"▶ Re-validar: {(s.get('title') or s['id'])[:42]}",
"warn" if s.get("last_run_status") == "fail" else "info",
)
profile = AGENT_CATALOG.get(target_agent)
if profile:
for sid in profile.scenarios:
sc = store.get_scenario(conn, sid)
if sc:
add_run(sid, f"▶ Check: {(sc.get('title') or sid)[:40]}", "info")
if len(actions) < 2 and target_agent in ("A6", "A0", "sentinel"):
for sid, lbl in (
("desk.api.health", "▶ Health Desk API"),
("proxmox.cluster", "▶ Health Proxmox"),
("ollama.vm123.health", "▶ Health Ollama VM123"),
):
add_run(sid, lbl, "info")
return actions[:limit]

View file

@ -0,0 +1,137 @@
"""SSE streaming chat — respostas token-a-token como assistente."""
from __future__ import annotations
import json
from collections.abc import Iterator
from typing import Any
from app import auth
from app.agents import llm_client, store
from app.agents import messages as agent_messages
from app.agents.chat_context_builder import build_ops_context, build_suggested_actions
def _sse(payload: dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
def _thread_history_messages(conn, thread_id: int, limit: int = 16) -> list[dict]:
msgs = agent_messages.thread_messages(conn, thread_id)[-limit:]
out: list[dict] = []
for m in msgs:
if m["from_type"] == "human":
out.append({"role": "user", "content": m["body"]})
elif m["from_type"] == "agent":
out.append({"role": "assistant", "content": m["body"]})
return out
def chat_stream_generator(
*,
username: str,
user_role: str,
question: str,
target_agent: str,
agent_name: str,
agent_role: str,
include_findings: bool,
thread_id: int | None = None,
) -> Iterator[str]:
conn = auth.db()
try:
kb = store.search_kb(conn, question)
kb_snippets = [k["snippet"] for k in kb]
ops_context = build_ops_context(
conn,
question,
target_agent,
include_findings=include_findings,
kb_snippets=kb_snippets,
)
suggested_actions = build_suggested_actions(conn, question, target_agent)
tid = thread_id
history_msgs: list[dict] = []
if tid:
row = conn.execute("SELECT * FROM agent_threads WHERE id=?", (tid,)).fetchone()
if not row:
yield _sse({"type": "error", "message": "thread not found"})
return
history_msgs = _thread_history_messages(conn, tid)
else:
tid = agent_messages.create_thread(
conn,
subject=f"Chat: {question[:60]}",
primary_agent=target_agent,
severity="info",
)
agent_messages.post_message(
conn,
thread_id=tid,
from_type="human",
from_id=username,
to_type="agent",
to_id=target_agent,
body=question,
)
conn.commit()
messages = llm_client.build_chat_messages(
question=question,
kb_snippets=kb_snippets,
ops_context=ops_context,
user_role=user_role,
target_agent=target_agent,
agent_name=agent_name,
agent_role=agent_role,
history_messages=history_msgs,
)
yield _sse({"type": "start", "thread_id": tid, "agent": target_agent, "actions": suggested_actions})
token_iter, model_label = llm_client.stream_llm_chat(messages)
parts: list[str] = []
for tok in token_iter:
parts.append(tok)
yield _sse({"type": "token", "text": tok})
answer = "".join(parts).strip()
if not answer:
answer = (
"Não consegui gerar resposta agora. Verifique Groq/Ollama no health do Agentic Ops."
)
model_label = "error"
yield _sse({"type": "token", "text": answer})
agent_messages.post_message(
conn,
thread_id=tid,
from_type="agent",
from_id=target_agent,
to_type="human",
to_id=username,
body=answer,
context={"model": model_label, "kb_hits": len(kb), "streamed": True, "actions": suggested_actions},
)
store.log_event(
conn,
event_type="chat.stream" if thread_id else "chat.query",
message=question[:120],
agent_id=target_agent,
payload={"user": username, "model": model_label, "thread_id": tid},
)
conn.commit()
yield _sse({
"type": "done",
"answer": answer,
"model": model_label,
"thread_id": tid,
"kb_hits": len(kb),
"actions": suggested_actions,
})
except Exception as exc:
yield _sse({"type": "error", "message": str(exc)[:300]})
finally:
conn.close()

View file

@ -1,7 +1,10 @@
"""Ollama VM123 + fallback — Spec 029 T0/T1."""
"""LLM providers — Spec 029 T1 (Ollama) + 034 KIMI + Groq free tier."""
from __future__ import annotations
import json
import os
import re
from collections.abc import Iterator
import httpx
@ -9,6 +12,60 @@ OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://10.10.10.123:11434").rstr
AGENTIC_LLM_MODEL = os.getenv("AGENTIC_LLM_MODEL", "qwen2.5:7b-instruct")
AGENTIC_EMBED_MODEL = os.getenv("AGENTIC_EMBED_MODEL", "nomic-embed-text")
AGENTIC_LLM_ENABLED = os.getenv("AGENTIC_LLM_ENABLED", "false").lower() in ("1", "true", "yes")
KIMI_API_KEY = os.getenv("KIMI_API_KEY", "").strip()
KIMI_BASE_URL = os.getenv("KIMI_BASE_URL", "https://api.moonshot.ai/v1").strip().rstrip("/")
KIMI_MODEL = os.getenv("KIMI_MODEL", "kimi-k2.5").strip()
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip()
GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1").strip().rstrip("/")
GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.1-8b-instant").strip()
AGENTIC_LLM_PROVIDER = os.getenv("AGENTIC_LLM_PROVIDER", "auto").lower().strip()
AGENTIC_LLM_FALLBACK = os.getenv("AGENTIC_LLM_FALLBACK", "ollama").lower().strip()
def _clean_env(value: str) -> str:
return re.split(r"\s+#", value or "", maxsplit=1)[0].strip()
def resolve_provider() -> str:
if not AGENTIC_LLM_ENABLED:
return "t0"
provider = _clean_env(AGENTIC_LLM_PROVIDER).lower() or "auto"
if provider == "auto":
if GROQ_API_KEY:
return "groq"
if KIMI_API_KEY:
return "kimi"
return "ollama"
return provider
def active_model() -> str:
p = resolve_provider()
if p == "groq":
return _clean_env(GROQ_MODEL) or "llama-3.1-8b-instant"
if p == "kimi":
return _clean_env(KIMI_MODEL) or "kimi-k2.5"
if p == "ollama":
return AGENTIC_LLM_MODEL
return "t0"
def llm_status() -> dict:
provider = resolve_provider()
return {
"tier": "t1" if AGENTIC_LLM_ENABLED else "t0",
"provider": provider,
"model": active_model(),
"groq_configured": bool(GROQ_API_KEY),
"groq_base_url": _clean_env(GROQ_BASE_URL),
"kimi_configured": bool(KIMI_API_KEY),
"kimi_base_url": _clean_env(KIMI_BASE_URL),
"ollama": ollama_available(),
"ollama_url": OLLAMA_BASE_URL,
"ollama_model": AGENTIC_LLM_MODEL,
"embed_model": AGENTIC_EMBED_MODEL,
"fallback": AGENTIC_LLM_FALLBACK,
}
def ollama_available() -> bool:
@ -19,13 +76,80 @@ def ollama_available() -> bool:
return False
def _chat(prompt: str, *, system: str | None = None, max_tokens: int = 800) -> tuple[str, str]:
if not AGENTIC_LLM_ENABLED or not ollama_available():
return ("", "t0")
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
def _openai_compatible_chat(
*,
base_url: str,
api_key: str,
model: str,
messages: list[dict],
max_tokens: int,
timeout: float,
prefix: str,
) -> tuple[str, str]:
try:
with httpx.Client(timeout=timeout) as c:
r = c.post(
f"{base_url.rstrip('/')}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"messages": messages,
"temperature": 0.3,
"max_tokens": max_tokens,
},
)
if r.status_code == 200:
data = r.json()
choice = (data.get("choices") or [{}])[0]
msg = choice.get("message") or {}
txt = (msg.get("content") or "").strip()
if txt:
return txt, model
if r.status_code == 429:
return ("", f"{prefix}-quota")
return ("", f"{prefix}-error-{r.status_code}")
except Exception:
return ("", f"{prefix}-error")
def _groq_chat(messages: list[dict], *, max_tokens: int = 1200) -> tuple[str, str]:
if not GROQ_API_KEY:
return ("", "groq-unconfigured")
model = _clean_env(GROQ_MODEL) or "llama-3.1-8b-instant"
base = _clean_env(GROQ_BASE_URL) or "https://api.groq.com/openai/v1"
return _openai_compatible_chat(
base_url=base,
api_key=GROQ_API_KEY,
model=model,
messages=messages,
max_tokens=max_tokens,
timeout=30.0,
prefix="groq",
)
def _kimi_chat(messages: list[dict], *, max_tokens: int = 1200) -> tuple[str, str]:
if not KIMI_API_KEY:
return ("", "kimi-unconfigured")
model = _clean_env(KIMI_MODEL) or "kimi-k2.5"
base = _clean_env(KIMI_BASE_URL) or "https://api.moonshot.ai/v1"
return _openai_compatible_chat(
base_url=base,
api_key=KIMI_API_KEY,
model=model,
messages=messages,
max_tokens=max_tokens,
timeout=45.0,
prefix="kimi",
)
def _ollama_chat(messages: list[dict], *, max_tokens: int = 800) -> tuple[str, str]:
if not ollama_available():
return ("", "ollama-offline")
try:
with httpx.Client(timeout=120.0) as c:
r = c.post(
@ -38,18 +162,241 @@ def _chat(prompt: str, *, system: str | None = None, max_tokens: int = 800) -> t
return txt, AGENTIC_LLM_MODEL
except Exception:
pass
return ("", "ollama-error")
def _try_fallback(messages: list[dict], *, max_tokens: int, skip: str) -> tuple[str, str]:
order = []
if AGENTIC_LLM_FALLBACK == "ollama" and skip != "ollama":
order.append("ollama")
if skip != "groq" and GROQ_API_KEY:
order.append("groq")
if skip != "kimi" and KIMI_API_KEY:
order.append("kimi")
for fb in order:
if fb == "ollama":
txt, model = _ollama_chat(messages, max_tokens=max_tokens)
elif fb == "groq":
txt, model = _groq_chat(messages, max_tokens=max_tokens)
else:
txt, model = _kimi_chat(messages, max_tokens=max_tokens)
if txt:
return txt, f"{fb}:{model}"
return ("", "t0-fallback")
def _llm_chat(messages: list[dict], *, max_tokens: int = 2000) -> tuple[str, str]:
if not AGENTIC_LLM_ENABLED:
return ("", "t0")
provider = resolve_provider()
dispatch = {
"groq": _groq_chat,
"kimi": _kimi_chat,
"ollama": _ollama_chat,
}
fn = dispatch.get(provider)
if not fn:
return ("", "t0")
txt, model = fn(messages, max_tokens=max_tokens)
if txt:
return txt, f"{provider}:{model.split(':')[-1] if ':' in model else model}"
if model.endswith("-quota"):
return ("", model)
return _try_fallback(messages, max_tokens=max_tokens, skip=provider)
def _openai_compatible_stream(
*,
base_url: str,
api_key: str,
model: str,
messages: list[dict],
max_tokens: int,
timeout: float,
) -> Iterator[str]:
try:
with httpx.Client(timeout=timeout) as c:
with c.stream(
"POST",
f"{base_url.rstrip('/')}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"messages": messages,
"temperature": 0.5,
"max_tokens": max_tokens,
"stream": True,
},
) as r:
if r.status_code != 200:
return
for line in r.iter_lines():
if not line or not line.startswith("data: "):
continue
payload = line[6:].strip()
if payload == "[DONE]":
break
try:
data = json.loads(payload)
except json.JSONDecodeError:
continue
delta = (data.get("choices") or [{}])[0].get("delta") or {}
txt = delta.get("content") or ""
if txt:
yield txt
except Exception:
return
def _groq_stream(messages: list[dict], *, max_tokens: int = 1200) -> Iterator[str]:
if not GROQ_API_KEY:
return
model = _clean_env(GROQ_MODEL) or "llama-3.1-8b-instant"
base = _clean_env(GROQ_BASE_URL) or "https://api.groq.com/openai/v1"
yield from _openai_compatible_stream(
base_url=base,
api_key=GROQ_API_KEY,
model=model,
messages=messages,
max_tokens=max_tokens,
timeout=60.0,
)
def _kimi_stream(messages: list[dict], *, max_tokens: int = 1200) -> Iterator[str]:
if not KIMI_API_KEY:
return
model = _clean_env(KIMI_MODEL) or "kimi-k2.5"
base = _clean_env(KIMI_BASE_URL) or "https://api.moonshot.ai/v1"
yield from _openai_compatible_stream(
base_url=base,
api_key=KIMI_API_KEY,
model=model,
messages=messages,
max_tokens=max_tokens,
timeout=60.0,
)
def _ollama_stream(messages: list[dict], *, max_tokens: int = 800) -> Iterator[str]:
if not ollama_available():
return
try:
with httpx.Client(timeout=120.0) as c:
with c.stream(
"POST",
f"{OLLAMA_BASE_URL}/api/chat",
json={"model": AGENTIC_LLM_MODEL, "messages": messages, "stream": True},
) as r:
for line in r.iter_lines():
if not line:
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
continue
chunk = (data.get("message") or {}).get("content") or ""
if chunk:
yield chunk
if data.get("done"):
break
except Exception:
return
def stream_llm_chat(messages: list[dict], *, max_tokens: int = 2000) -> tuple[Iterator[str], str]:
"""Returns (token iterator, provider label). Falls back to burst if stream empty."""
if not AGENTIC_LLM_ENABLED:
return iter(()), "t0"
provider = resolve_provider()
dispatch = {"groq": _groq_stream, "kimi": _kimi_stream, "ollama": _ollama_stream}
fn = dispatch.get(provider)
if not fn:
return iter(()), "t0"
def _gen():
got = False
for tok in fn(messages, max_tokens=max_tokens):
got = True
yield tok
if not got:
txt, model = _llm_chat(messages, max_tokens=max_tokens)
if txt:
yield txt
label = f"{provider}:{active_model()}"
return _gen(), label
def build_chat_messages(
*,
question: str,
kb_snippets: list[str] | None = None,
findings_summary: str | None = None,
ops_context: str | None = None,
user_role: str = "technician",
target_agent: str = "A6",
agent_name: str = "Copiloto",
agent_role: str = "Assistência tickets e janela humana",
history_messages: list[dict] | None = None,
) -> list[dict]:
from app.agents.chat_context_builder import RESPONSE_FORMAT_INSTRUCTION
ctx_block = ops_context or ""
if not ctx_block:
ctx_parts = [f"Operador: {user_role} (Ligbox Desk)"]
if findings_summary:
ctx_parts.append(f"Findings abertos:\n{findings_summary[:2000]}")
if kb_snippets:
ctx_parts.append("Base de conhecimento relevante:\n" + "\n---\n".join(kb_snippets[:6])[:4000])
ctx_block = "\n\n".join(ctx_parts)
system = (
f"Você é **{agent_name}** ({target_agent}) — {agent_role}.\n"
"Actua como **investigador DevOps sénior** no ecossistema Ligbox, não como FAQ genérico.\n\n"
"## Missão\n"
"Analisar o contexto operacional real, formular hipóteses fundamentadas e propor "
"**acções concretas** que o operador possa executar agora.\n\n"
"## Dados operacionais injectados\n"
f"{ctx_block}\n\n"
f"{RESPONSE_FORMAT_INSTRUCTION}"
)
messages: list[dict] = [{"role": "system", "content": system}]
for hm in history_messages or []:
role = hm.get("role")
content = (hm.get("content") or "").strip()
if role in ("user", "assistant") and content:
messages.append({"role": role, "content": content})
messages.append({
"role": "user",
"content": (
f"Pergunta do operador ({user_role}):\n{question}\n\n"
"Responda no formato investigativo obrigatório. "
"Use os incidentes/findings/cenários do contexto quando relevantes."
),
})
return messages
def advise_human_action(
*, finding_title: str, finding_detail: str, kb_snippets: list[str] | None = None
) -> tuple[str, str]:
prompt = (
"Advisor Agentic Ops Ligbox. Português BR, máx 6 frases. O que fazer AGORA?\n"
f"Problema: {finding_title}\nDetalhe: {finding_detail}\n"
f"KB: {'---'.join(kb_snippets or [])[:2500] or 'N/A'}"
)
txt, model = _chat(prompt)
kb = "---".join(kb_snippets or [])[:2500] or "N/A"
messages = [
{
"role": "system",
"content": "Advisor Agentic Ops Ligbox. Português BR, máx 6 frases. O que fazer AGORA?",
},
{
"role": "user",
"content": f"Problema: {finding_title}\nDetalhe: {finding_detail}\nKB: {kb}",
},
]
txt, model = _llm_chat(messages, max_tokens=400)
if txt:
return txt, model
return (f"Investigar manualmente: {finding_title}", "t0")
@ -60,27 +407,43 @@ def chat_context(
question: str,
kb_snippets: list[str] | None = None,
findings_summary: str | None = None,
ops_context: str | None = None,
user_role: str = "technician",
target_agent: str = "A6",
agent_name: str = "Copiloto",
agent_role: str = "Assistência tickets e janela humana",
history: str | None = None,
history_messages: list[dict] | None = None,
) -> tuple[str, str]:
"""T1 — resposta contextual para janela Desk / bot interno."""
system = (
"És o copiloto Agentic Ops da Ligbox (VM112 wizard, VM122 Desk, VM123 finance). "
"Responde em português BR, objectivo, com passos acionáveis. "
"Nunca inventes credenciais. Se não souberes, diz o que verificar."
# Legacy string history → single user turn if no structured history
hist_msgs = list(history_messages or [])
if not hist_msgs and history:
hist_msgs = [{"role": "user", "content": f"[Histórico resumido]\n{history[:3000]}"}]
messages = build_chat_messages(
question=question,
kb_snippets=kb_snippets,
findings_summary=findings_summary,
ops_context=ops_context,
user_role=user_role,
target_agent=target_agent,
agent_name=agent_name,
agent_role=agent_role,
history_messages=hist_msgs,
)
ctx = []
if findings_summary:
ctx.append(f"Findings abertos:\n{findings_summary[:2000]}")
if kb_snippets:
ctx.append("KB:\n" + "\n---\n".join(kb_snippets[:6])[:4000])
prompt = (
f"Perfil utilizador: {user_role}\n"
f"Contexto ops:\n{chr(10).join(ctx) or 'N/A'}\n\n"
f"Pergunta: {question}"
)
txt, model = _chat(prompt, system=system)
txt, model = _llm_chat(messages, max_tokens=1200)
if txt:
return txt, model
if model == "groq-quota":
return (
"Quota Groq esgotada (free tier). Tente amanhã ou use fallback Ollama local.",
"groq-quota",
)
if model == "kimi-quota":
return (
"Conta KIMI sem saldo. Groq/Ollama disponíveis como alternativa.",
"kimi-quota",
)
return (
"Modo T0 activo — LLM indisponível. Consulte findings e audit log no painel Agentic Ops.",
"t0",

View file

@ -275,17 +275,6 @@ def human_reply(
requires_human=False,
)
# Copiloto (A6) ecoa confirmação para o thread
post_message(
conn,
thread_id=tid,
from_type="agent",
from_id="A6",
to_type="human",
to_id=username,
body=f"Recebi a sua instrução. Vou coordenar com **{AGENT_CATALOG.get(agent_to, AGENT_CATALOG['A6']).name}** e actualizar o finding se aplicável.",
requires_human=False,
)
return mid

View file

@ -5,12 +5,15 @@ import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app import auth
from app import agent_bindings, auth
from app.agents import llm_client, runner, store
from app.agents import messages as agent_messages
from app.agents.catalog import roster_public
from app.agents.catalog import AGENT_CATALOG, roster_public
from app.agents.chat_stream import chat_stream_generator, _thread_history_messages
from app.agents.chat_context_builder import build_ops_context
router = APIRouter(prefix="/api/v1/agents", tags=["agents"])
@ -23,20 +26,17 @@ def _db():
conn.close()
def _ops_view(user):
if user.role not in (
"super_admin",
"ops_lead",
"technician",
"noc",
"agentic_operator",
"developer",
"devops",
"security_analyst",
):
def _ops_view(user, conn):
if not agent_bindings.can_use_agentics_ui(conn, user.role):
raise HTTPException(403, "insufficient permissions")
def _require_agent_chat(user, conn, agent_id: str):
_ops_view(user, conn)
if not agent_bindings.can_chat_agent(conn, user.role, agent_id):
raise HTTPException(403, f"sem atribuição Agentics para agente {agent_id}")
class ChatRequest(BaseModel):
question: str = Field(..., min_length=2, max_length=4000)
include_findings: bool = True
@ -50,19 +50,12 @@ class ReplyRequest(BaseModel):
@router.get("/health")
def agents_health():
return {
"status": "ok",
"tier": "t1" if llm_client.AGENTIC_LLM_ENABLED else "t0",
"ollama": llm_client.ollama_available(),
"ollama_url": llm_client.OLLAMA_BASE_URL,
"model": llm_client.AGENTIC_LLM_MODEL,
"embed_model": llm_client.AGENTIC_EMBED_MODEL,
}
return {"status": "ok", **llm_client.llm_status()}
@router.get("/overview")
def agents_overview(user=Depends(auth.get_current_user), conn=Depends(_db)):
_ops_view(user)
_ops_view(user, conn)
return store.get_overview(conn)
@ -75,13 +68,13 @@ def agents_incidents(
agent_id: str | None = None,
limit: int = Query(50, ge=1, le=200),
):
_ops_view(user)
_ops_view(user, conn)
return {"incidents": store.list_incidents(conn, status=status, severity=severity, agent_id=agent_id, limit=limit)}
@router.get("/incidents/{incident_id}")
def agents_incident_detail(incident_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
_ops_view(user)
_ops_view(user, conn)
inc = store.get_incident(conn, incident_id)
if not inc:
raise HTTPException(404, "incident not found")
@ -94,7 +87,7 @@ def agents_incident_detail(incident_id: int, user=Depends(auth.get_current_user)
@router.post("/incidents/{incident_id}/ack")
def ack_incident(incident_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
_ops_view(user)
_ops_view(user, conn)
inc = store.ack_incident(conn, incident_id, user.username)
if not inc:
raise HTTPException(404, "incident not found")
@ -105,7 +98,7 @@ def ack_incident(incident_id: int, user=Depends(auth.get_current_user), conn=Dep
@router.get("/timeline")
def agents_timeline(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(24, ge=1, le=100)):
_ops_view(user)
_ops_view(user, conn)
ticks = [
dict(r)
for r in conn.execute(
@ -128,31 +121,60 @@ def agents_timeline(user=Depends(auth.get_current_user), conn=Depends(_db), limi
@router.get("/roster")
def agents_roster(user=Depends(auth.get_current_user)):
_ops_view(user)
def agents_roster(user=Depends(auth.get_current_user), conn=Depends(_db)):
_ops_view(user, conn)
return {"agents": roster_public()}
@router.get("/inbox")
def agents_inbox(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(50, ge=1, le=200)):
_ops_view(user)
_ops_view(user, conn)
return {"messages": agent_messages.list_inbox(conn, role=user.role, limit=limit)}
@router.get("/threads")
def agents_threads(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(40, ge=1, le=100)):
_ops_view(user)
_ops_view(user, conn)
return {"threads": agent_messages.list_threads(conn, limit=limit)}
@router.get("/threads/{thread_id}/messages")
def thread_messages(thread_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
_ops_view(user)
_ops_view(user, conn)
if not conn.execute("SELECT id FROM agent_threads WHERE id=?", (thread_id,)).fetchone():
raise HTTPException(404, "thread not found")
return {"thread_id": thread_id, "messages": agent_messages.thread_messages(conn, thread_id)}
def _agent_profile(agent_id: str):
return AGENT_CATALOG.get(agent_id, AGENT_CATALOG["A6"])
def _findings_summary(conn, include: bool) -> str:
if not include:
return ""
open_f = store.list_findings(conn, limit=8, open_only=True)
if not open_f:
return ""
return "\n".join(
f"- [{f['severity']}] {f['title']}: {f.get('suggested_human_action') or ''}" for f in open_f
)
def _thread_history(conn, thread_id: int, limit: int = 10) -> str:
msgs = agent_messages.thread_messages(conn, thread_id)[-limit:]
return "\n".join(f"{m.get('from_label') or m.get('from_id')}: {m.get('body', '')[:240]}" for m in msgs)
def _chat_ops_context(conn, question: str, target_agent: str, include: bool) -> tuple[list[str], str]:
kb = store.search_kb(conn, question)
snippets = [k["snippet"] for k in kb]
ctx = build_ops_context(
conn, question, target_agent, include_findings=include, kb_snippets=snippets
)
return snippets, ctx
@router.post("/threads/{thread_id}/reply")
def thread_reply(
thread_id: int,
@ -160,26 +182,116 @@ def thread_reply(
user=Depends(auth.get_current_user),
conn=Depends(_db),
):
_ops_view(user)
if not conn.execute("SELECT id FROM agent_threads WHERE id=?", (thread_id,)).fetchone():
_ops_view(user, conn)
row = conn.execute("SELECT * FROM agent_threads WHERE id=?", (thread_id,)).fetchone()
if not row:
raise HTTPException(404, "thread not found")
target = body.target_agent or row["primary_agent"]
mid = agent_messages.human_reply(
conn, thread_id=thread_id, username=user.username, body=body.body, target_agent=body.target_agent
conn, thread_id=thread_id, username=user.username, body=body.body, target_agent=target
)
store.log_event(
conn,
event_type="human.reply",
message=body.body[:120],
agent_id=body.target_agent or "A6",
agent_id=target,
payload={"thread_id": thread_id, "user": user.username},
)
conn.commit()
return {"ok": True, "message_id": mid}
@router.post("/threads/{thread_id}/chat")
def thread_chat(
thread_id: int,
body: ChatRequest,
user=Depends(auth.get_current_user),
conn=Depends(_db),
):
"""Continuar conversa com agente (LLM) numa thread existente."""
row = conn.execute("SELECT * FROM agent_threads WHERE id=?", (thread_id,)).fetchone()
if not row:
raise HTTPException(404, "thread not found")
target = body.target_agent or row["primary_agent"]
_require_agent_chat(user, conn, target)
profile = _agent_profile(target)
kb_snippets, ops_context = _chat_ops_context(conn, body.question, target, body.include_findings)
answer, model = llm_client.chat_context(
question=body.question,
kb_snippets=kb_snippets,
ops_context=ops_context,
user_role=user.role,
target_agent=target,
agent_name=profile.name,
agent_role=profile.role,
history_messages=_thread_history_messages(conn, thread_id),
)
agent_messages.post_message(
conn,
thread_id=thread_id,
from_type="human",
from_id=user.username,
to_type="agent",
to_id=target,
body=body.question,
)
agent_messages.post_message(
conn,
thread_id=thread_id,
from_type="agent",
from_id=target,
to_type="human",
to_id=user.username,
body=answer,
context={"model": model, "kb_hits": len(kb_snippets)},
)
store.log_event(
conn,
event_type="chat.thread",
message=body.question[:120],
agent_id=target,
payload={"user": user.username, "model": model, "thread_id": thread_id},
)
conn.commit()
return {"answer": answer, "model": model, "kb_hits": len(kb_snippets), "thread_id": thread_id}
@router.post("/threads/{thread_id}/chat/stream")
def thread_chat_stream(
thread_id: int,
body: ChatRequest,
user=Depends(auth.get_current_user),
):
"""Chat com streaming SSE — resposta token a token."""
_ops_view(user, conn)
conn = auth.db()
try:
row = conn.execute("SELECT * FROM agent_threads WHERE id=?", (thread_id,)).fetchone()
if not row:
raise HTTPException(404, "thread not found")
target = body.target_agent or row["primary_agent"]
profile = _agent_profile(target)
finally:
conn.close()
return StreamingResponse(
chat_stream_generator(
username=user.username,
user_role=user.role,
question=body.question,
target_agent=target,
agent_name=profile.name,
agent_role=profile.role,
include_findings=body.include_findings,
thread_id=thread_id,
),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/messages/{message_id}/ack")
def ack_inbox_message(message_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
_ops_view(user)
_ops_view(user, conn)
if not agent_messages.ack_message(conn, message_id, user.username):
raise HTTPException(404, "not found")
conn.commit()
@ -188,7 +300,7 @@ def ack_inbox_message(message_id: int, user=Depends(auth.get_current_user), conn
@router.get("/scenarios")
def list_scenarios(user=Depends(auth.get_current_user), conn=Depends(_db)):
_ops_view(user)
_ops_view(user, conn)
runner.sync_registry(conn)
conn.commit()
return {"scenarios": store.list_scenarios(conn)}
@ -202,13 +314,13 @@ def list_findings(
limit: int = Query(50, ge=1, le=200),
open_only: bool = True,
):
_ops_view(user)
_ops_view(user, conn)
return {"findings": store.list_findings(conn, severity=severity, limit=limit, open_only=open_only)}
@router.post("/findings/{finding_id}/ack")
def ack_finding(finding_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
_ops_view(user)
_ops_view(user, conn)
if not conn.execute("SELECT id FROM agent_findings WHERE id=?", (finding_id,)).fetchone():
raise HTTPException(404, "not found")
now = datetime.now(timezone.utc).isoformat()
@ -223,13 +335,36 @@ def ack_finding(finding_id: int, user=Depends(auth.get_current_user), conn=Depen
@router.get("/action-log")
def action_log(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(100, ge=1, le=500)):
_ops_view(user)
_ops_view(user, conn)
return {"events": store.list_action_log(conn, limit=limit)}
@router.get("/kb/sources")
def kb_sources(user=Depends(auth.get_current_user), conn=Depends(_db)):
_ops_view(user, conn)
rows = conn.execute(
"""SELECT source_path, COUNT(*) AS chunks, MAX(indexed_at) AS indexed_at
FROM agent_kb_chunks GROUP BY source_path ORDER BY source_path"""
).fetchall()
total = conn.execute("SELECT COUNT(*) c FROM agent_kb_chunks").fetchone()["c"]
return {"sources": [dict(r) for r in rows], "total_chunks": total}
@router.get("/kb/search")
def kb_search(
user=Depends(auth.get_current_user),
conn=Depends(_db),
q: str = Query(..., min_length=1, max_length=500),
limit: int = Query(12, ge=1, le=50),
):
_ops_view(user, conn)
hits = store.search_kb(conn, q, limit=limit)
return {"query": q, "hits": hits, "count": len(hits)}
@router.post("/runs/{scenario_id}")
def trigger_run(scenario_id: str, user=Depends(auth.get_current_user), conn=Depends(_db)):
if user.role not in ("super_admin", "ops_lead", "agentic_operator"):
if not agent_bindings.can_trigger_runs(conn, user.role):
raise HTTPException(403, "insufficient permissions")
r = runner.run_scenario(conn, scenario_id, trigger=f"manual:{user.username}")
conn.commit()
@ -239,20 +374,17 @@ def trigger_run(scenario_id: str, user=Depends(auth.get_current_user), conn=Depe
@router.post("/chat")
def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depends(_db)):
"""Janela de contexto T1 — humano ↔ agente (default Copiloto A6)."""
_ops_view(user)
kb = store.search_kb(conn, body.question)
findings_summary = ""
if body.include_findings:
open_f = store.list_findings(conn, limit=8, open_only=True)
if open_f:
findings_summary = "\n".join(
f"- [{f['severity']}] {f['title']}: {f.get('suggested_human_action') or ''}" for f in open_f
)
_require_agent_chat(user, conn, body.target_agent)
profile = _agent_profile(body.target_agent)
kb_snippets, ops_context = _chat_ops_context(conn, body.question, body.target_agent, body.include_findings)
answer, model = llm_client.chat_context(
question=body.question,
kb_snippets=[k["snippet"] for k in kb],
findings_summary=findings_summary,
kb_snippets=kb_snippets,
ops_context=ops_context,
user_role=user.role,
target_agent=body.target_agent,
agent_name=profile.name,
agent_role=profile.role,
)
thread_id = agent_messages.create_thread(
conn,
@ -277,7 +409,7 @@ def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depe
to_type="human",
to_id=user.username,
body=answer,
context={"model": model, "kb_hits": len(kb)},
context={"model": model, "kb_hits": len(kb_snippets)},
)
store.log_event(
conn,
@ -287,7 +419,28 @@ def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depe
payload={"user": user.username, "model": model, "thread_id": thread_id},
)
conn.commit()
return {"answer": answer, "model": model, "kb_hits": len(kb), "thread_id": thread_id}
return {"answer": answer, "model": model, "kb_hits": len(kb_snippets), "thread_id": thread_id}
@router.post("/chat/stream")
def agent_chat_stream(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depends(_db)):
"""Nova conversa com streaming SSE."""
_require_agent_chat(user, conn, body.target_agent)
profile = _agent_profile(body.target_agent)
return StreamingResponse(
chat_stream_generator(
username=user.username,
user_role=user.role,
question=body.question,
target_agent=body.target_agent,
agent_name=profile.name,
agent_role=profile.role,
include_findings=body.include_findings,
thread_id=None,
),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/internal/tick")

View file

@ -298,9 +298,7 @@ def get_overview(conn) -> dict:
except json.JSONDecodeError:
pass
return {
"tier": "t1" if llm_client.AGENTIC_LLM_ENABLED else "t0",
"ollama": llm_client.ollama_available(),
"model": llm_client.AGENTIC_LLM_MODEL,
**llm_client.llm_status(),
"last_tick_at": last_tick["ts"] if last_tick else None,
"last_tick_status": "degraded" if payload.get("runs") and any(
r.get("findings_count", 0) > 0 for r in payload.get("runs", []) if isinstance(r, dict)

View file

@ -29,6 +29,7 @@ from app.security_routes import router as security_router
from app.infra_stack_routes import router as infra_stack_router
from app.vm123.routes import router as vm123_router
from app.agents.routes import router as agents_router
from app.rbac_routes import router as rbac_router
from app.agents.store import init_agent_schema
from app.collectors.base import run_audit
from app.permissions import (
@ -136,6 +137,7 @@ app.include_router(billing_router)
app.include_router(infra_stack_router)
app.include_router(vm123_router)
app.include_router(agents_router)
app.include_router(rbac_router)
TICKET_COLUMNS = "id,tenant_id,subject,status,payload,created_at,assigned_to,assigned_at,session_id,assist_mode,assisted_by,assisted_at,client_paused"
@ -189,6 +191,9 @@ def init_db():
init_purge_jobs_schema(conn)
init_purge_auth_schema(conn)
init_agent_schema(conn)
from app import agent_bindings
agent_bindings.init_schema(conn)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=60000")
conn.commit()

View file

@ -0,0 +1,495 @@
"""Exportação read-only da matriz Spec 027 para UI preview."""
from __future__ import annotations
import os
from typing import Any
from app.platform_role_catalog import catalog_export
from app.agents.catalog import AGENT_CATALOG
from app import agent_bindings
# Legenda global Spec 027
LEGEND = {
"full": {"symbol": "", "label": "Acesso total", "access": "full"},
"read": {"symbol": "🔒", "label": "Só leitura", "access": "read"},
"link": {"symbol": "🔗", "label": "Deep-link", "access": "link"},
"api": {"symbol": "⚙️", "label": "Via API Desk", "access": "api"},
"system": {"symbol": "🤖", "label": "Conta sistema", "access": "system"},
"none": {"symbol": "", "label": "Sem acesso", "access": "none"},
}
ROLE_COLUMNS = [
"super_admin", "ops_lead", "technician", "noc", "finance",
"sales_admin", "sales_support", "marketing", "seo", "developer",
"devops", "security_analyst", "content_editor", "agentic_operator",
]
# Spec 027 §3.1 — módulos Desk × função
DESK_MODULE_MATRIX: dict[str, dict[str, str]] = {
"core": {
"super_admin": "full", "ops_lead": "full", "technician": "full", "noc": "read",
"finance": "read", "sales_admin": "full", "sales_support": "full",
"marketing": "read", "seo": "read", "developer": "read", "devops": "read",
"security_analyst": "read", "content_editor": "read", "agentic_operator": "read",
},
"overview": {
"super_admin": "full", "ops_lead": "full", "technician": "read", "noc": "read",
"finance": "read", "sales_admin": "full", "sales_support": "read",
"marketing": "read", "seo": "read", "developer": "read", "devops": "read",
"security_analyst": "read", "content_editor": "none", "agentic_operator": "read",
},
"overview-home": {
"super_admin": "full", "ops_lead": "full", "technician": "full", "noc": "read",
"finance": "read", "sales_admin": "full", "sales_support": "full",
"marketing": "read", "seo": "read", "developer": "read", "devops": "full",
"security_analyst": "read", "content_editor": "read", "agentic_operator": "read",
},
"infra": {
"super_admin": "full", "ops_lead": "full", "technician": "read", "noc": "read",
"finance": "none", "sales_admin": "none", "sales_support": "none",
"marketing": "none", "seo": "read", "developer": "read", "devops": "none",
"security_analyst": "full", "content_editor": "none", "agentic_operator": "read",
},
"infra2-soc": {
"super_admin": "full", "ops_lead": "full", "technician": "read", "noc": "read",
"finance": "none", "sales_admin": "none", "sales_support": "none",
"marketing": "none", "seo": "none", "developer": "none", "devops": "none",
"security_analyst": "full", "content_editor": "none", "agentic_operator": "read",
},
"funnel-timing": {
"super_admin": "full", "ops_lead": "full", "technician": "full", "noc": "read",
"finance": "read", "sales_admin": "full", "sales_support": "full",
"marketing": "full", "seo": "full", "developer": "read", "devops": "none",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "read",
},
"wazuh-soc": {
"super_admin": "full", "ops_lead": "full", "technician": "read", "noc": "read",
"finance": "none", "sales_admin": "none", "sales_support": "none",
"marketing": "none", "seo": "none", "developer": "none", "devops": "read",
"security_analyst": "full", "content_editor": "none", "agentic_operator": "read",
},
"leads": {
"super_admin": "full", "ops_lead": "full", "technician": "full", "noc": "none",
"finance": "read", "sales_admin": "full", "sales_support": "full",
"marketing": "full", "seo": "full", "developer": "none", "devops": "none",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "none",
},
"events": {
"super_admin": "full", "ops_lead": "full", "technician": "read", "noc": "read",
"finance": "read", "sales_admin": "read", "sales_support": "read",
"marketing": "read", "seo": "read", "developer": "api", "devops": "api",
"security_analyst": "read", "content_editor": "none", "agentic_operator": "api",
},
"tenants": {
"super_admin": "full", "ops_lead": "full", "technician": "full", "noc": "read",
"finance": "read", "sales_admin": "full", "sales_support": "read",
"marketing": "read", "seo": "read", "developer": "read", "devops": "read",
"security_analyst": "read", "content_editor": "none", "agentic_operator": "read",
},
"messages": {
"super_admin": "full", "ops_lead": "none", "technician": "none", "noc": "none",
"finance": "none", "sales_admin": "none", "sales_support": "none",
"marketing": "none", "seo": "none", "developer": "none", "devops": "none",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "none",
},
"admin-users": {
"super_admin": "full", "ops_lead": "none", "technician": "none", "noc": "none",
"finance": "none", "sales_admin": "none", "sales_support": "none",
"marketing": "none", "seo": "none", "developer": "none", "devops": "none",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "none",
},
"billing-recurrence": {
"super_admin": "full", "ops_lead": "full", "technician": "read", "noc": "none",
"finance": "full", "sales_admin": "full", "sales_support": "read",
"marketing": "none", "seo": "none", "developer": "none", "devops": "none",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "none",
},
"migration": {
"super_admin": "full", "ops_lead": "full", "technician": "full", "noc": "read",
"finance": "none", "sales_admin": "none", "sales_support": "none",
"marketing": "none", "seo": "none", "developer": "none", "devops": "read",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "none",
},
"assist": {
"super_admin": "full", "ops_lead": "full", "technician": "full", "noc": "read",
"finance": "none", "sales_admin": "none", "sales_support": "none",
"marketing": "none", "seo": "none", "developer": "none", "devops": "none",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "none",
},
"modules": {
"super_admin": "full", "ops_lead": "none", "technician": "none", "noc": "none",
"finance": "none", "sales_admin": "none", "sales_support": "none",
"marketing": "none", "seo": "none", "developer": "none", "devops": "none",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "none",
},
}
DESK_MODULE_LABELS: dict[str, str] = {
"core": "Núcleo (dashboard/tickets)",
"overview": "Audit Overview",
"overview-home": "Serviços IaaS",
"infra": "INFRA CODE",
"infra2-soc": "Infra 2 SOC",
"funnel-timing": "Relógio por fase",
"wazuh-soc": "Wazuh SOC",
"leads": "Leads / abandono CRM",
"events": "Eventos / webhooks",
"tenants": "Tenants",
"messages": "Mensagens (cadastro)",
"admin-users": "Administradores",
"billing-recurrence": "Billing recorrente",
"migration": "Migração e-mail",
"assist": "Assist / takeover",
"modules": "Toggle módulos",
}
AGENTS_LEGACY = [
{"id": "A0", "name": "Maestro", "role": "Orquestrador multi-agente", "approval": "agentic_operator / ops_lead"},
{"id": "A1", "name": "Node Health", "role": "CPU, RAM, serviços Carbonio", "approval": "ops_lead (restart)"},
{"id": "A2", "name": "Infra Mail", "role": "DNS, LE, Traefik, nginx", "approval": "devops / ops_lead"},
{"id": "A3", "name": "Deliverability", "role": "SPF/DKIM/DMARC", "approval": "seo / technician"},
{"id": "A4", "name": "Security Mail", "role": "amavis, spam, clamav", "approval": "security_analyst"},
{"id": "A5", "name": "Wazuh SOC", "role": "Correlação SIEM", "approval": "security_analyst / noc"},
{"id": "A6", "name": "Support Copilot", "role": "Assistência tickets", "approval": "technician"},
{"id": "A7", "name": "Remediation", "role": "Runbooks", "approval": "agentic_operator (obrigatório)"},
]
# Spec 027 §6 — governança Agentics (fonte: catalog.py + permissions + agents/routes.py)
AGENTIC_GOVERNANCE: dict[str, Any] = {
"use_ui": [
"super_admin", "ops_lead", "technician", "noc",
"agentic_operator", "developer", "devops", "security_analyst",
],
"trigger_runs": ["super_admin", "ops_lead", "agentic_operator"],
"approve_runbooks": ["super_admin", "ops_lead", "agentic_operator", "security_analyst"],
"configure_models": ["super_admin", "developer"],
"labels": {
"use_ui": "Usa módulo Agentics (chat, findings, inbox)",
"trigger_runs": "Dispara cenários manualmente",
"approve_runbooks": "Aprova runbooks A7 / remediação",
"configure_models": "Configura modelos/prompts LLM",
},
}
# Por agente: quem aprova acções sensíveis vs quem opera no dia-a-dia
AGENT_ROLE_MAP: dict[str, dict[str, list[str]]] = {
"A0": {
"approvers": ["agentic_operator", "ops_lead"],
"operators": ["super_admin", "ops_lead", "agentic_operator"],
},
"A1": {
"approvers": ["ops_lead"],
"operators": ["ops_lead", "devops", "noc", "super_admin", "agentic_operator"],
},
"A2": {
"approvers": ["devops", "ops_lead"],
"operators": ["devops", "ops_lead", "developer", "super_admin"],
},
"A3": {
"approvers": ["seo", "technician"],
"operators": ["seo", "technician", "ops_lead", "super_admin"],
},
"A4": {
"approvers": ["security_analyst"],
"operators": ["security_analyst", "ops_lead", "super_admin"],
},
"A5": {
"approvers": ["security_analyst", "noc"],
"operators": ["security_analyst", "noc", "ops_lead", "agentic_operator", "super_admin"],
},
"A6": {
"approvers": ["technician"],
"operators": ["technician", "ops_lead", "agentic_operator", "super_admin"],
},
"A7": {
"approvers": ["agentic_operator", "ops_lead", "super_admin"],
"operators": ["agentic_operator", "ops_lead", "super_admin"],
},
}
AGENT_RELATION_LABELS = {
"approve": "Aprova acções sensíveis",
"focus": "Operador principal",
"ui": "Acede UI Agentics",
}
CATEGORY_LABELS = {
"ops": "Operações",
"commercial": "Comercial",
"business": "Negócio",
"platform": "Plataforma",
"system": "Sistema",
}
# Spec 027 §2 / §4 / §5 — software em escopo (fora do Desk, dentro da plataforma Ligbox)
SOFTWARE_GROUPS: list[dict[str, Any]] = [
{
"id": "vm112",
"label": "VM112 — Onboard & Mail",
"host": "10.10.10.112",
"url": "https://onboard.ligbox.com.br",
"items": [
{"id": "wizard_assist", "label": "Wizard assist / takeover", "product": "ligbox-wizard",
"levels": {"super_admin": "full", "ops_lead": "full", "technician": "full", "noc": "read",
"finance": "read", "marketing": "read", "seo": "read", "developer": "api",
"sales_admin": "read", "sales_support": "read", "devops": "read",
"security_analyst": "read", "content_editor": "none", "agentic_operator": "read"}},
{"id": "vm112_api", "label": "API VM112 (:8090)", "product": "ligbox-wizard",
"levels": {"super_admin": "full", "ops_lead": "full", "technician": "api", "noc": "read",
"finance": "read", "marketing": "read", "seo": "read", "developer": "full",
"sales_admin": "read", "sales_support": "read", "devops": "full",
"security_analyst": "read", "content_editor": "none", "agentic_operator": "api"}},
{"id": "carbonio_admin", "label": "Carbonio admin (mail tenant)", "product": "Carbonio",
"levels": {"super_admin": "full", "ops_lead": "link", "technician": "link", "noc": "none",
"finance": "none", "marketing": "none", "seo": "none", "developer": "none",
"sales_admin": "none", "sales_support": "none", "devops": "link",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}},
{"id": "vm112_ssh", "label": "SSH VM112", "product": "Linux",
"levels": {"super_admin": "full", "ops_lead": "link", "technician": "none", "noc": "none",
"finance": "none", "marketing": "none", "seo": "none", "developer": "link",
"sales_admin": "none", "sales_support": "none", "devops": "full",
"security_analyst": "link", "content_editor": "none", "agentic_operator": "none"}},
{"id": "purge_domain", "label": "Purge domínio", "product": "ligbox-wizard",
"levels": {"super_admin": "full", "ops_lead": "full", "technician": "none", "noc": "none",
"finance": "none", "marketing": "none", "seo": "none", "developer": "none",
"sales_admin": "none", "sales_support": "none", "devops": "api",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}},
],
},
{
"id": "vm123",
"label": "VM123 — Finance & Hosting",
"host": "10.10.10.123",
"url": "https://financeiro.ligbox.com.br",
"items": [
{"id": "foss_admin", "label": "FOSSBilling Admin", "product": "FOSSBilling",
"levels": {"super_admin": "full", "ops_lead": "link", "technician": "none", "noc": "none",
"finance": "full", "sales_admin": "full", "sales_support": "full",
"marketing": "read", "seo": "none", "developer": "api", "devops": "none",
"security_analyst": "read", "content_editor": "none", "agentic_operator": "none"}},
{"id": "foss_client", "label": "FOSSBilling Cliente", "product": "FOSSBilling",
"levels": {"super_admin": "full", "ops_lead": "read", "technician": "none", "noc": "none",
"finance": "full", "sales_admin": "full", "sales_support": "full",
"marketing": "full", "seo": "none", "developer": "none", "devops": "none",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}},
{"id": "odoo16", "label": "Odoo 16 (CRM / Invoicing)", "product": "Odoo",
"levels": {"super_admin": "full", "ops_lead": "link", "technician": "none", "noc": "none",
"finance": "full", "sales_admin": "full", "sales_support": "full",
"marketing": "none", "seo": "none", "developer": "api", "devops": "none",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}},
{"id": "openpanel", "label": "OpenPanel (hosting sites)", "product": "OpenPanel",
"levels": {"super_admin": "full", "ops_lead": "link", "technician": "link", "noc": "none",
"finance": "read", "sales_admin": "link", "sales_support": "link",
"marketing": "full", "seo": "full", "developer": "api", "devops": "full",
"security_analyst": "read", "content_editor": "full", "agentic_operator": "none"}},
{"id": "openadmin", "label": "OpenAdmin", "product": "OpenPanel",
"levels": {"super_admin": "full", "ops_lead": "link", "technician": "none", "noc": "none",
"finance": "read", "sales_admin": "link", "sales_support": "none",
"marketing": "link", "seo": "link", "developer": "none", "devops": "full",
"security_analyst": "read", "content_editor": "none", "agentic_operator": "none"}},
{"id": "vm123_ssh", "label": "SSH VM123 (:2523)", "product": "Linux",
"levels": {"super_admin": "full", "ops_lead": "link", "technician": "none", "noc": "none",
"finance": "link", "sales_admin": "none", "sales_support": "none",
"marketing": "none", "seo": "none", "developer": "link", "devops": "full",
"security_analyst": "link", "content_editor": "none", "agentic_operator": "none"}},
],
},
{
"id": "externas",
"label": "Consolas & Infra (deep-link / API)",
"host": "",
"url": "",
"items": [
{"id": "cloudflare", "label": "Cloudflare DNS", "product": "Cloudflare",
"levels": {"super_admin": "full", "ops_lead": "full", "technician": "link", "noc": "none",
"finance": "none", "sales_admin": "read", "sales_support": "read",
"marketing": "read", "seo": "full", "developer": "none", "devops": "full",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}},
{"id": "traefik", "label": "Traefik CT114", "product": "Traefik",
"levels": {"super_admin": "full", "ops_lead": "link", "technician": "none", "noc": "none",
"finance": "none", "sales_admin": "none", "sales_support": "none",
"marketing": "none", "seo": "none", "developer": "link", "devops": "full",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}},
{"id": "proxmox", "label": "Proxmox host", "product": "Proxmox",
"levels": {"super_admin": "full", "ops_lead": "link", "technician": "none", "noc": "none",
"finance": "none", "sales_admin": "none", "sales_support": "none",
"marketing": "none", "seo": "none", "developer": "none", "devops": "full",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}},
{"id": "pfsense", "label": "pfSense API", "product": "pfSense",
"levels": {"super_admin": "full", "ops_lead": "link", "technician": "none", "noc": "none",
"finance": "none", "sales_admin": "none", "sales_support": "none",
"marketing": "none", "seo": "none", "developer": "none", "devops": "full",
"security_analyst": "link", "content_editor": "none", "agentic_operator": "none"}},
{"id": "wazuh", "label": "Wazuh VM104", "product": "Wazuh",
"levels": {"super_admin": "full", "ops_lead": "full", "technician": "read", "noc": "read",
"finance": "none", "sales_admin": "none", "sales_support": "none",
"marketing": "none", "seo": "none", "developer": "none", "devops": "read",
"security_analyst": "full", "content_editor": "none", "agentic_operator": "none"}},
{"id": "github", "label": "GitHub itecnologys/*", "product": "GitHub",
"levels": {"super_admin": "full", "ops_lead": "read", "technician": "none", "noc": "none",
"finance": "none", "sales_admin": "none", "sales_support": "none",
"marketing": "none", "seo": "none", "developer": "full", "devops": "none",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}},
{"id": "gsc", "label": "Google Search Console", "product": "Google",
"levels": {"super_admin": "none", "ops_lead": "none", "technician": "none", "noc": "none",
"finance": "none", "sales_admin": "none", "sales_support": "none",
"marketing": "link", "seo": "full", "developer": "none", "devops": "none",
"security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}},
],
},
]
def _software_groups_export() -> list[dict[str, Any]]:
out = []
for grp in SOFTWARE_GROUPS:
items = []
for item in grp["items"]:
levels = item.get("levels", {})
items.append({
**item,
"levels": {role: levels.get(role, "none") for role in ROLE_COLUMNS},
})
out.append({**grp, "items": items})
return out
def _agent_role_relations_from_db(conn, agent_id: str, role_id: str) -> list[str]:
return agent_bindings.agent_relations(conn, agent_id, role_id)
def _agents_export(conn) -> list[dict[str, Any]]:
order = ["A0", "A1", "A2", "A3", "A4", "A5", "A6", "A7"]
matrix = agent_bindings.load_bindings_matrix(conn)
out: list[dict[str, Any]] = []
for aid in order:
p = AGENT_CATALOG.get(aid)
if not p:
continue
mapping = AGENT_ROLE_MAP.get(aid, {})
role_levels = {role: _agent_role_relations_from_db(conn, aid, role) for role in ROLE_COLUMNS}
role_bindings = matrix["agents"].get(aid, {})
out.append({
"id": p.id,
"codename": p.codename,
"name": p.name,
"role": p.role,
"reads": list(p.reads),
"actions": list(p.actions),
"approval_text": p.approval,
"approvers": mapping.get("approvers", []),
"operators": mapping.get("operators", []),
"scenarios": list(p.scenarios),
"role_relations": role_levels,
"bindings": role_bindings,
})
return out
def _role_agents_summary(conn, role_id: str) -> list[dict[str, Any]]:
rows = []
for agent in _agents_export(conn):
rels = agent["role_relations"].get(role_id, [])
if not rels:
continue
rows.append({
"id": agent["id"],
"name": agent["name"],
"role": agent["role"],
"relations": rels,
"relation_labels": [AGENT_RELATION_LABELS[r] for r in rels if r in AGENT_RELATION_LABELS],
"is_approver": "approve" in rels,
"is_operator": "focus" in rels,
})
return rows
def _role_agentic_caps(conn, role_id: str) -> list[dict[str, str]]:
return agent_bindings.role_agentic_caps(conn, role_id)
def _role_software_summary(role_id: str) -> list[dict[str, Any]]:
"""Visão «cartão de função» — todos os SW onde a função tem acesso ≠ none."""
rows = []
for grp in SOFTWARE_GROUPS:
for item in grp["items"]:
lv = item.get("levels", {}).get(role_id, "none")
if lv == "none":
continue
rows.append({
"group": grp["label"],
"product": item.get("product", ""),
"label": item["label"],
"level": lv,
"host": grp.get("host", ""),
"url": grp.get("url", ""),
})
return rows
def _all_role_software_summaries() -> dict[str, list[dict[str, Any]]]:
return {role: _role_software_summary(role) for role in ROLE_COLUMNS}
def _desk_modules_export() -> list[dict[str, Any]]:
rows = []
for mod_id, levels in DESK_MODULE_MATRIX.items():
rows.append({
"id": mod_id,
"label": DESK_MODULE_LABELS.get(mod_id, mod_id),
"levels": {role: levels.get(role, "none") for role in ROLE_COLUMNS},
})
return rows
def matrix_export(conn) -> dict[str, Any]:
catalog = catalog_export()
bindings_matrix = agent_bindings.load_bindings_matrix(conn)
edit_enabled = os.getenv("ACCESS_MATRIX_EDIT", "0").strip().lower() in ("1", "true", "yes", "on")
return {
"spec": "027",
"preview_mode": not edit_enabled,
"read_only": not edit_enabled,
"editable": edit_enabled,
"legend": LEGEND,
"role_columns": ROLE_COLUMNS,
"category_labels": CATEGORY_LABELS,
"desk_modules": _desk_modules_export(),
"software_groups": _software_groups_export(),
"role_software_summaries": _all_role_software_summaries(),
"role_agents_summaries": {role: _role_agents_summary(conn, role) for role in ROLE_COLUMNS},
"role_agentic_caps": {role: _role_agentic_caps(conn, role) for role in ROLE_COLUMNS},
"agent_bindings_matrix": bindings_matrix,
"agentic_governance": AGENTIC_GOVERNANCE,
"agent_relation_labels": AGENT_RELATION_LABELS,
"agents": _agents_export(conn),
"catalog": catalog,
"tabs": [
{"id": "overview", "label": "Visão da função", "type": "overview",
"hint": "Desk + software + APIs da função seleccionada"},
{"id": "vm122", "label": "Matriz Desk", "type": "desk_modules",
"hint": "VM122 — módulos internos da plataforma"},
{"id": "software", "label": "Software & Infra", "type": "software",
"hint": "VM112 · VM123 · consolas externas"},
{"id": "bindings", "label": "APIs & Grupos", "type": "catalog",
"hint": "Bindings Odoo-style — grupos, roles, permissões"},
{"id": "agents", "label": "Agentes IA", "type": "agents",
"hint": "A0A7 — orquestração e aprovações"},
],
"scope_layers": [
{"id": "vm122", "label": "Desk VM122", "host": "10.10.10.122", "desc": "Módulos internos"},
{"id": "vm112", "label": "Onboard VM112", "host": "10.10.10.112", "desc": "Wizard, mail, API"},
{"id": "vm123", "label": "Finance VM123", "host": "10.10.10.123", "desc": "FOSS, Odoo, OpenPanel"},
{"id": "externas", "label": "Infra externa", "host": "", "desc": "Cloudflare, Traefik, Proxmox…"},
],
"service_labels": {
"desk": "Desk VM122",
"vm112": "VM112 Wizard",
"vm123_foss": "FOSSBilling",
"vm123_odoo": "Odoo 16",
"vm123_openpanel": "OpenPanel",
"infra": "Infra / SSH",
"vm104": "Wazuh VM104",
},
}

View file

@ -0,0 +1,132 @@
"""Rotas RBAC — Spec 027-UI / UI-C."""
from __future__ import annotations
import os
import sqlite3
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from app import agent_bindings, auth
from app.rbac_matrix import matrix_export
router = APIRouter(prefix="/api/v1", tags=["rbac"])
def _access_matrix_ui_enabled() -> bool:
return os.getenv("ACCESS_MATRIX_UI", "0").strip().lower() in ("1", "true", "yes", "on")
def _access_matrix_edit_enabled() -> bool:
return os.getenv("ACCESS_MATRIX_EDIT", "0").strip().lower() in ("1", "true", "yes", "on")
def _db():
conn = auth.db()
try:
yield conn
finally:
conn.close()
def _require_matrix_view(user: auth.DeskUser) -> None:
if not _access_matrix_ui_enabled():
raise HTTPException(404, "Matriz de acessos não activa neste ambiente")
if user.role != "super_admin":
raise HTTPException(403, "Matriz: apenas super_admin")
def _require_matrix_edit(user: auth.DeskUser) -> None:
_require_matrix_view(user)
if not _access_matrix_edit_enabled():
raise HTTPException(403, "Edição da matriz desactivada neste ambiente")
@router.get("/config/features")
def config_features(user: auth.DeskUser = Depends(auth.get_current_user)):
"""Feature flags expostas ao frontend (sem segredos)."""
enabled = _access_matrix_ui_enabled()
return {
"access_matrix_ui": enabled,
"access_matrix_preview": enabled and not _access_matrix_edit_enabled(),
"access_matrix_edit": enabled and _access_matrix_edit_enabled(),
}
@router.get("/rbac/matrix")
def rbac_matrix(
user: auth.DeskUser = Depends(auth.get_current_user),
conn: sqlite3.Connection = Depends(_db),
):
"""Matriz completa — Spec 027 (read-only ou editável)."""
_require_matrix_view(user)
return matrix_export(conn)
class AgentBindingPatch(BaseModel):
agent_id: str = Field(..., min_length=2, max_length=8)
role_id: str = Field(..., min_length=2, max_length=32)
relation: str = Field(..., pattern="^(ui|focus|approve)$")
enabled: bool
class GovernanceCapPatch(BaseModel):
cap_id: str = Field(..., min_length=3, max_length=32)
role_id: str = Field(..., min_length=2, max_length=32)
enabled: bool
@router.patch("/rbac/agent-bindings")
def patch_agent_binding(
body: AgentBindingPatch,
user: auth.DeskUser = Depends(auth.get_current_user),
conn: sqlite3.Connection = Depends(_db),
):
"""Ligar/desligar atribuição agente × função × relação."""
_require_matrix_edit(user)
try:
result = agent_bindings.set_agent_binding(
conn,
agent_id=body.agent_id,
role_id=body.role_id,
relation=body.relation,
enabled=body.enabled,
username=user.username,
)
conn.commit()
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return result
@router.patch("/rbac/agent-governance")
def patch_agent_governance(
body: GovernanceCapPatch,
user: auth.DeskUser = Depends(auth.get_current_user),
conn: sqlite3.Connection = Depends(_db),
):
"""Ligar/desligar capacidade global Agentics por função."""
_require_matrix_edit(user)
try:
result = agent_bindings.set_governance_cap(
conn,
cap_id=body.cap_id,
role_id=body.role_id,
enabled=body.enabled,
username=user.username,
)
conn.commit()
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return result
@router.get("/rbac/agent-bindings/audit")
def agent_bindings_audit(
user: auth.DeskUser = Depends(auth.get_current_user),
conn: sqlite3.Connection = Depends(_db),
limit: int = Query(50, ge=1, le=200),
):
_require_matrix_view(user)
return {"entries": agent_bindings.list_audit(conn, limit=limit)}

View file

@ -0,0 +1,26 @@
"""Tests Spec 034 — KIMI LLM provider."""
from __future__ import annotations
import os
from unittest.mock import patch
from app.agents import llm_client
def test_resolve_provider_auto_kimi_when_key():
with patch.dict(os.environ, {"AGENTIC_LLM_ENABLED": "true", "AGENTIC_LLM_PROVIDER": "auto", "KIMI_API_KEY": "sk-test"}, clear=False):
import importlib
importlib.reload(llm_client)
assert llm_client.resolve_provider() == "kimi"
def test_clean_env_strips_inline_comment():
assert llm_client._clean_env("kimi-k2.5 # comment") == "kimi-k2.5"
assert llm_client._clean_env("https://api.moonshot.ai/v1 # internacional") == "https://api.moonshot.ai/v1"
def test_llm_status_includes_provider():
status = llm_client.llm_status()
assert "provider" in status
assert "model" in status
assert "kimi_configured" in status

View file

@ -17,6 +17,8 @@ services:
SQLITE_PATH: /data/ops-staging.db
REDIS_URL: redis://redis-staging:6379/0
OPS_API_URL: http://api-staging:8080
ACCESS_MATRIX_UI: "1"
ACCESS_MATRIX_EDIT: "1"
volumes:
- /var/lib/ligbox-ops-platform-staging:/data
- ./specs:/opt/ligbox-ops-platform/specs:ro

View file

@ -0,0 +1,955 @@
/* Spec 027-UI — Matriz de Acessos (enterprise preview) */
#access-matrix-content {
--am-surface: #fffdf9;
--am-surface-2: #f8f4ee;
--am-border: #ddd4c8;
--am-ink: #2a2520;
--am-muted: #6b6560;
--am-accent: #5c2e2e;
--am-accent-soft: #f3e8e8;
--am-desk: #5c2e2e;
--am-desk-bg: #f8eded;
--am-vm112: #1a6b5c;
--am-vm112-bg: #e8f5f1;
--am-vm123: #2a5298;
--am-vm123-bg: #eaf0fa;
--am-infra: #9a6b1a;
--am-infra-bg: #faf3e3;
--am-agent: #5a3d8a;
--am-agent-bg: #f2ebfa;
--am-full: #1a6648;
--am-full-bg: #dff0e8;
--am-read: #a05a18;
--am-read-bg: #faecd8;
--am-link: #2563b8;
--am-link-bg: #e3edfb;
--am-api: #5b3a9e;
--am-api-bg: #ede6fa;
--am-sys: #0f766e;
--am-sys-bg: #ddf5f2;
--am-none: #9a9590;
--am-none-bg: #f0ece6;
font-family: 'DM Sans', system-ui, sans-serif;
}
#access-matrix-content .am-wrap {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
#access-matrix-content .am-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--am-border);
}
#access-matrix-content .am-page-title {
margin: 0;
font-size: 1.35rem;
font-weight: 700;
color: var(--am-ink);
letter-spacing: -0.02em;
}
#access-matrix-content .am-page-sub {
margin: 0.25rem 0 0;
font-size: 0.82rem;
color: var(--am-muted);
}
#access-matrix-content .am-preview-tag {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--am-accent);
border: 1px solid #d4a8a8;
padding: 0.35rem 0.65rem;
border-radius: 4px;
background: var(--am-accent-soft);
white-space: nowrap;
}
#access-matrix-content .am-subnav {
display: flex;
gap: 0;
border: 1px solid var(--am-border);
border-radius: 8px;
overflow: hidden;
background: var(--am-surface-2);
width: fit-content;
max-width: 100%;
flex-wrap: wrap;
}
#access-matrix-content .am-tab {
border: none;
border-right: 1px solid var(--am-border);
background: transparent;
padding: 0.55rem 1rem;
font: inherit;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
color: var(--am-muted);
transition: background 0.15s, color 0.15s;
}
#access-matrix-content .am-tab:last-child {
border-right: none;
}
#access-matrix-content .am-tab:hover {
background: rgba(92, 46, 46, 0.04);
color: var(--am-ink);
}
#access-matrix-content .am-tab.active {
background: var(--am-accent-soft);
color: var(--am-accent);
font-weight: 600;
box-shadow: inset 0 -2px 0 var(--am-accent);
}
#access-matrix-content .am-layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 1.25rem;
align-items: start;
}
#access-matrix-content .am-role-list {
position: sticky;
top: 0.5rem;
max-height: calc(100vh - 200px);
overflow: auto;
padding: 0.85rem;
background: linear-gradient(180deg, #fffdf9 0%, #faf6f0 100%);
border: 1px solid var(--am-border);
border-radius: 8px;
box-shadow: 0 1px 4px rgba(92, 46, 46, 0.04);
}
#access-matrix-content .am-role-group {
margin-bottom: 1rem;
}
#access-matrix-content .am-role-group h4 {
margin: 0 0 0.4rem;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--am-muted);
font-weight: 600;
}
#access-matrix-content .am-role-btn {
display: block;
width: 100%;
text-align: left;
border: none;
border-left: 2px solid transparent;
background: transparent;
border-radius: 0 4px 4px 0;
padding: 0.4rem 0.5rem 0.4rem 0.65rem;
font: inherit;
font-size: 0.82rem;
cursor: pointer;
color: var(--am-ink);
}
#access-matrix-content .am-role-btn:hover {
background: var(--am-surface-2);
}
#access-matrix-content .am-role-btn.active {
background: #f3e8e8;
border-left-color: var(--am-accent);
font-weight: 600;
color: var(--am-accent);
}
#access-matrix-content .am-panel {
background: var(--am-surface);
border: 1px solid var(--am-border);
border-radius: 8px;
overflow: hidden;
}
#access-matrix-content .am-panel-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1.5rem;
padding: 1.1rem 1.25rem;
border-bottom: 1px solid var(--am-border);
background: linear-gradient(135deg, #faf6f0 0%, #f3ebe3 100%);
}
#access-matrix-content .am-panel-kicker {
display: block;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--am-muted);
font-weight: 600;
margin-bottom: 0.2rem;
}
#access-matrix-content .am-panel-head h3 {
margin: 0;
font-size: 1.15rem;
font-weight: 700;
color: var(--am-ink);
}
#access-matrix-content .am-panel-desc {
margin: 0.3rem 0 0;
font-size: 0.82rem;
color: var(--am-muted);
line-height: 1.45;
}
#access-matrix-content .am-tab-hint {
margin: 0.45rem 0 0;
font-size: 0.75rem;
color: var(--am-accent);
font-weight: 500;
}
#access-matrix-content .am-panel-body {
padding: 1.15rem 1.25rem 1.25rem;
}
#access-matrix-content .am-legend {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.85rem;
font-size: 0.72rem;
color: var(--am-muted);
justify-content: flex-end;
}
#access-matrix-content .am-legend-item {
display: inline-flex;
align-items: center;
gap: 0.3rem;
white-space: nowrap;
}
#access-matrix-content .am-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.4rem;
padding: 0.2rem 0.45rem;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.04em;
font-family: ui-monospace, 'SF Mono', monospace;
border: 1px solid transparent;
}
#access-matrix-content .am-badge--compact {
min-width: auto;
padding: 0.12rem 0.35rem;
font-size: 0.6rem;
}
#access-matrix-content .am-badge--full { background: var(--am-full-bg); color: var(--am-full); border-color: #b8d4c4; }
#access-matrix-content .am-badge--read { background: var(--am-read-bg); color: var(--am-read); border-color: #e0c9a8; }
#access-matrix-content .am-badge--link { background: var(--am-link-bg); color: var(--am-link); border-color: #b8cce8; }
#access-matrix-content .am-badge--api { background: var(--am-api-bg); color: var(--am-api); border-color: #c8b8e0; }
#access-matrix-content .am-badge--system { background: var(--am-sys-bg); color: var(--am-sys); border-color: #a8d4d0; }
#access-matrix-content .am-badge--none { background: var(--am-none-bg); color: var(--am-none); border-color: #ddd8d0; }
#access-matrix-content .am-section-lead {
margin: 0 0 1rem;
font-size: 0.82rem;
color: var(--am-muted);
line-height: 1.5;
}
#access-matrix-content .am-block {
margin-bottom: 1.5rem;
}
#access-matrix-content .am-block:last-child {
margin-bottom: 0;
}
#access-matrix-content .am-block-title {
margin: 0 0 0.65rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 700;
color: var(--am-accent);
padding-left: 0.55rem;
border-left: 3px solid var(--am-accent);
}
#access-matrix-content .am-empty {
margin: 0;
font-size: 0.82rem;
color: var(--am-muted);
font-style: italic;
}
#access-matrix-content .am-scope-bar {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.65rem;
margin-bottom: 1.25rem;
}
#access-matrix-content .am-scope-item {
padding: 0.65rem 0.75rem;
border: 1px solid var(--am-border);
border-radius: 6px;
border-left-width: 3px;
}
#access-matrix-content .am-scope-item:nth-child(1) {
background: var(--am-desk-bg);
border-left-color: var(--am-desk);
}
#access-matrix-content .am-scope-item:nth-child(2) {
background: var(--am-vm112-bg);
border-left-color: var(--am-vm112);
}
#access-matrix-content .am-scope-item:nth-child(3) {
background: var(--am-vm123-bg);
border-left-color: var(--am-vm123);
}
#access-matrix-content .am-scope-item:nth-child(4) {
background: var(--am-infra-bg);
border-left-color: var(--am-infra);
}
#access-matrix-content .am-scope-item:nth-child(1) .am-scope-host { color: var(--am-desk); }
#access-matrix-content .am-scope-item:nth-child(2) .am-scope-host { color: var(--am-vm112); }
#access-matrix-content .am-scope-item:nth-child(3) .am-scope-host { color: var(--am-vm123); }
#access-matrix-content .am-scope-item:nth-child(4) .am-scope-host { color: var(--am-infra); }
#access-matrix-content .am-scope-label {
display: block;
font-size: 0.78rem;
font-weight: 600;
color: var(--am-ink);
}
#access-matrix-content .am-scope-host {
display: block;
font-size: 0.68rem;
font-family: ui-monospace, monospace;
margin-top: 0.15rem;
}
#access-matrix-content .am-scope-desc {
display: block;
font-size: 0.68rem;
color: var(--am-muted);
margin-top: 0.2rem;
}
#access-matrix-content .am-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.65rem;
margin-bottom: 1.25rem;
}
#access-matrix-content .am-stat {
padding: 0.75rem;
border: 1px solid var(--am-border);
border-radius: 6px;
text-align: center;
background: var(--am-surface);
border-top: 3px solid var(--am-border);
}
#access-matrix-content .am-stat:nth-child(1) { border-top-color: var(--am-desk); background: var(--am-desk-bg); }
#access-matrix-content .am-stat:nth-child(2) { border-top-color: var(--am-vm112); background: var(--am-vm112-bg); }
#access-matrix-content .am-stat:nth-child(3) { border-top-color: var(--am-vm123); background: var(--am-vm123-bg); }
#access-matrix-content .am-stat:nth-child(4) { border-top-color: var(--am-agent); background: var(--am-agent-bg); }
#access-matrix-content .am-stat:nth-child(1) .am-stat-n { color: var(--am-desk); }
#access-matrix-content .am-stat:nth-child(2) .am-stat-n { color: var(--am-vm112); }
#access-matrix-content .am-stat:nth-child(4) .am-stat-n { color: var(--am-agent); }
#access-matrix-content .am-stat-n {
display: block;
font-size: 1.4rem;
font-weight: 700;
line-height: 1;
}
#access-matrix-content .am-stat-l {
display: block;
font-size: 0.68rem;
color: var(--am-muted);
margin-top: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
#access-matrix-content .am-desk-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
#access-matrix-content .am-desk-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.55rem;
border: 1px solid #dcc8c8;
border-radius: 4px;
font-size: 0.78rem;
background: var(--am-desk-bg);
color: var(--am-ink);
}
#access-matrix-content .am-sw-grid,
#access-matrix-content .am-api-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.65rem;
}
#access-matrix-content .am-sw-card {
border: 1px solid var(--am-border);
border-radius: 6px;
padding: 0.75rem 0.85rem;
background: var(--am-surface);
border-left: 3px solid var(--am-vm112);
transition: box-shadow 0.15s;
}
#access-matrix-content .am-api-card {
border: 1px solid var(--am-border);
border-radius: 6px;
padding: 0.75rem 0.85rem;
background: var(--am-surface);
border-left: 3px solid var(--am-vm123);
transition: box-shadow 0.15s;
}
#access-matrix-content .am-api-card.am-svc-desk { border-left-color: var(--am-desk); background: linear-gradient(90deg, var(--am-desk-bg) 0%, var(--am-surface) 40%); }
#access-matrix-content .am-api-card.am-svc-vm112 { border-left-color: var(--am-vm112); background: linear-gradient(90deg, var(--am-vm112-bg) 0%, var(--am-surface) 40%); }
#access-matrix-content .am-api-card.am-svc-vm123_foss,
#access-matrix-content .am-api-card.am-svc-vm123_odoo,
#access-matrix-content .am-api-card.am-svc-vm123_openpanel { border-left-color: var(--am-vm123); background: linear-gradient(90deg, var(--am-vm123-bg) 0%, var(--am-surface) 40%); }
#access-matrix-content .am-api-card.am-svc-infra { border-left-color: var(--am-infra); background: linear-gradient(90deg, var(--am-infra-bg) 0%, var(--am-surface) 40%); }
#access-matrix-content .am-api-card.am-svc-vm104 { border-left-color: var(--am-agent); background: linear-gradient(90deg, var(--am-agent-bg) 0%, var(--am-surface) 40%); }
#access-matrix-content .am-sw-card:hover,
#access-matrix-content .am-api-card:hover {
box-shadow: 0 2px 8px rgba(42, 37, 32, 0.06);
}
#access-matrix-content .am-sw-card-head,
#access-matrix-content .am-api-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
#access-matrix-content .am-sw-card-head strong {
display: block;
font-size: 0.85rem;
color: var(--am-ink);
}
#access-matrix-content .am-sw-product {
display: block;
font-size: 0.68rem;
color: var(--am-muted);
margin-top: 0.1rem;
}
#access-matrix-content .am-sw-group {
margin: 0;
font-size: 0.72rem;
color: var(--am-muted);
}
#access-matrix-content .am-sw-host {
display: block;
margin-top: 0.35rem;
font-size: 0.68rem;
color: var(--am-accent);
}
#access-matrix-content .am-api-service {
font-size: 0.82rem;
font-weight: 600;
color: var(--am-ink);
}
#access-matrix-content .am-api-type {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--am-muted);
margin-bottom: 0.35rem;
}
#access-matrix-content .am-api-value {
display: block;
font-size: 0.72rem;
word-break: break-all;
color: var(--am-ink);
background: var(--am-surface-2);
padding: 0.35rem 0.45rem;
border-radius: 4px;
border: 1px solid var(--am-border);
}
#access-matrix-content .am-api-foot {
display: block;
margin-top: 0.4rem;
font-size: 0.65rem;
font-family: ui-monospace, monospace;
color: var(--am-muted);
}
#access-matrix-content .am-filter-bar {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 1rem 0 0.85rem;
}
#access-matrix-content .am-filter {
border: 1px solid var(--am-border);
background: var(--am-surface);
border-radius: 4px;
padding: 0.35rem 0.7rem;
font: inherit;
font-size: 0.75rem;
cursor: pointer;
color: var(--am-muted);
}
#access-matrix-content .am-filter.active {
background: var(--am-accent);
border-color: var(--am-accent);
color: #fff;
font-weight: 600;
}
#access-matrix-content .am-sw-section {
margin-bottom: 1.5rem;
}
#access-matrix-content .am-sw-section-head h4 {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
}
#access-matrix-content .am-sw-meta {
font-size: 0.72rem;
color: var(--am-muted);
font-family: ui-monospace, monospace;
}
#access-matrix-content .am-table-wrap {
overflow: auto;
max-height: calc(100vh - 320px);
border: 1px solid var(--am-border);
border-radius: 6px;
margin-top: 0.5rem;
}
#access-matrix-content .am-matrix-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
#access-matrix-content .am-matrix-table th,
#access-matrix-content .am-matrix-table td {
border-bottom: 1px solid var(--am-border);
padding: 0.45rem 0.4rem;
text-align: center;
vertical-align: middle;
}
#access-matrix-content .am-matrix-table th {
background: var(--am-surface-2);
font-weight: 600;
font-size: 0.68rem;
white-space: nowrap;
color: var(--am-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
#access-matrix-content .am-matrix-table th.am-col-active {
background: var(--am-accent-soft);
color: var(--am-accent);
}
#access-matrix-content .am-matrix-table td.am-row-highlight {
background: rgba(92, 46, 46, 0.08);
}
#access-matrix-content .am-matrix-table th.am-sticky,
#access-matrix-content .am-matrix-table td.am-sticky {
position: sticky;
left: 0;
z-index: 1;
background: var(--am-surface);
text-align: left;
min-width: 180px;
}
#access-matrix-content .am-matrix-table th.am-sticky {
z-index: 2;
background: var(--am-surface-2);
}
#access-matrix-content .am-row-title {
display: block;
font-weight: 500;
color: var(--am-ink);
font-size: 0.8rem;
}
#access-matrix-content .am-row-code,
#access-matrix-content .am-row-sub {
display: block;
font-size: 0.65rem;
color: var(--am-muted);
margin-top: 0.15rem;
}
#access-matrix-content .am-row-code {
font-family: ui-monospace, monospace;
}
#access-matrix-content .am-agents-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.65rem;
}
#access-matrix-content .am-agent-card {
padding: 0.85rem 1rem;
border: 1px solid var(--am-border);
border-radius: 6px;
background: var(--am-surface);
border-top: 3px solid #c8b8e0;
}
#access-matrix-content .am-agent-card--hit {
border-color: var(--am-agent);
border-top-color: var(--am-agent);
background: var(--am-agent-bg);
}
#access-matrix-content .am-agent-top {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
#access-matrix-content .am-agent-id {
font-size: 0.68rem;
font-weight: 700;
font-family: ui-monospace, monospace;
color: var(--am-agent);
}
#access-matrix-content .am-agent-top strong {
font-size: 0.88rem;
}
#access-matrix-content .am-agent-role {
margin: 0;
font-size: 0.8rem;
color: var(--am-muted);
line-height: 1.4;
}
#access-matrix-content .am-agent-approval {
margin-top: 0.65rem;
padding-top: 0.55rem;
border-top: 1px solid var(--am-border);
}
#access-matrix-content .am-agent-approval span {
display: block;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--am-muted);
margin-bottom: 0.2rem;
}
#access-matrix-content .am-agent-approval code {
font-size: 0.72rem;
color: var(--am-ink);
}
#access-matrix-content .am-agent-codename {
display: block;
font-size: 0.65rem;
color: var(--am-muted);
font-family: ui-monospace, monospace;
}
#access-matrix-content .am-agent-section {
margin-top: 0.55rem;
}
#access-matrix-content .am-agent-lbl {
display: block;
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--am-muted);
margin-bottom: 0.25rem;
}
#access-matrix-content .am-agent-meta {
font-size: 0.72rem;
color: var(--am-muted);
line-height: 1.4;
}
#access-matrix-content .am-role-chip {
display: inline-block;
font-size: 0.68rem;
padding: 0.15rem 0.4rem;
margin: 0 0.2rem 0.2rem 0;
border-radius: 3px;
border: 1px solid var(--am-border);
background: var(--am-surface-2);
color: var(--am-ink);
}
#access-matrix-content .am-role-chip--sel {
background: var(--am-accent-soft);
border-color: var(--am-accent);
color: var(--am-accent);
font-weight: 600;
}
#access-matrix-content .am-rel {
display: inline-block;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.04em;
padding: 0.12rem 0.35rem;
border-radius: 3px;
margin: 0.1rem;
text-transform: uppercase;
}
#access-matrix-content .am-rel--approve { background: #fce8e8; color: #8b2e2e; border: 1px solid #e8b8b8; }
#access-matrix-content .am-rel--focus { background: var(--am-vm123-bg); color: var(--am-vm123); border: 1px solid #b8cce8; }
#access-matrix-content .am-rel--ui { background: var(--am-vm112-bg); color: var(--am-vm112); border: 1px solid #a8d4c8; }
#access-matrix-content .am-rel--none { background: var(--am-none-bg); color: var(--am-none); border: 1px solid var(--am-border); }
#access-matrix-content .am-rel-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.15rem;
margin: 0.45rem 0;
}
#access-matrix-content .am-rel-row--you .am-you {
font-size: 0.62rem;
color: var(--am-accent);
font-weight: 600;
margin-left: 0.25rem;
}
#access-matrix-content .am-cap-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.65rem;
}
#access-matrix-content .am-cap-chip {
font-size: 0.72rem;
padding: 0.3rem 0.55rem;
border-radius: 4px;
background: var(--am-agent-bg);
border: 1px solid #c8b8e0;
color: var(--am-agent);
}
#access-matrix-content .am-agent-legend {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
margin-bottom: 0.65rem;
font-size: 0.72rem;
color: var(--am-muted);
}
#access-matrix-content .am-agent-legend-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
#access-matrix-content .am-agent-mini-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
}
#access-matrix-content .am-agent-mini {
padding: 0.55rem 0.65rem;
border: 1px solid var(--am-border);
border-radius: 6px;
background: var(--am-surface);
border-left: 3px solid #c8b8e0;
}
#access-matrix-content .am-agent-mini--approve {
border-left-color: var(--am-accent);
background: #faf6f6;
}
#access-matrix-content .am-agent-mini header {
display: flex;
align-items: baseline;
gap: 0.35rem;
margin-bottom: 0.2rem;
}
#access-matrix-content .am-agent-mini strong {
font-size: 0.82rem;
}
#access-matrix-content .am-cell-toggles {
display: flex;
flex-direction: column;
gap: 0.2rem;
align-items: center;
padding: 0.35rem 0.25rem !important;
}
#access-matrix-content .am-toggle {
display: block;
width: 2.1rem;
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.03em;
padding: 0.18rem 0;
border-radius: 3px;
border: 1px solid var(--am-border);
background: var(--am-none-bg);
color: var(--am-muted);
cursor: pointer;
font-family: ui-monospace, monospace;
}
#access-matrix-content .am-toggle.on {
background: var(--am-vm112-bg);
border-color: var(--am-vm112);
color: var(--am-vm112);
}
#access-matrix-content .am-toggle.approve.on {
background: #fce8e8;
border-color: var(--am-accent);
color: var(--am-accent);
}
#access-matrix-content .am-toggle.locked {
opacity: 0.55;
cursor: not-allowed;
}
#access-matrix-content .am-cap-toggles {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.65rem;
}
#access-matrix-content .am-cap-toggle {
font-size: 0.72rem;
padding: 0.35rem 0.6rem;
border-radius: 4px;
border: 1px solid var(--am-border);
background: var(--am-surface);
color: var(--am-muted);
cursor: pointer;
font: inherit;
}
#access-matrix-content .am-cap-toggle.on {
background: var(--am-agent-bg);
border-color: var(--am-agent);
color: var(--am-agent);
font-weight: 600;
}
#access-matrix-content .am-cap-toggle.locked {
opacity: 0.55;
cursor: not-allowed;
}
#access-matrix-content .am-save-error {
margin: 0 0 0.75rem;
padding: 0.5rem 0.65rem;
border-radius: 4px;
background: #fce8e8;
border: 1px solid #e8b8b8;
color: #8b2e2e;
font-size: 0.8rem;
}
@media (max-width: 1100px) {
#access-matrix-content .am-scope-bar,
#access-matrix-content .am-stats {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 960px) {
#access-matrix-content .am-layout {
grid-template-columns: 1fr;
}
#access-matrix-content .am-role-list {
position: static;
max-height: none;
}
#access-matrix-content .am-subnav {
width: 100%;
}
#access-matrix-content .am-tab {
flex: 1 1 auto;
text-align: center;
font-size: 0.72rem;
padding: 0.5rem 0.4rem;
}
}
@media (max-width: 560px) {
#access-matrix-content .am-scope-bar,
#access-matrix-content .am-stats {
grid-template-columns: 1fr;
}
}

View file

@ -0,0 +1,651 @@
(function () {
'use strict';
const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const LEVEL_SHORT = {
full: 'ADM',
read: 'READ',
link: 'LINK',
api: 'API',
system: 'SYS',
none: '—',
};
const state = {
data: null,
tab: 'overview',
selectedRole: 'super_admin',
softwareFilter: 'all',
saving: false,
saveError: null,
};
async function patchApi(path, body) {
const r = await fetchWithTimeout(`/api/v1${path}`, {
method: 'PATCH',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`);
return r.json();
}
function bindingMeta(agentId, roleId, relation) {
const b = state.data?.agent_bindings_matrix?.agents?.[agentId]?.[roleId]?.[relation];
return {
enabled: !!b?.enabled,
locked: !!b?.locked,
};
}
function capMeta(capId, roleId) {
const c = state.data?.agent_bindings_matrix?.caps?.[capId]?.[roleId];
return { enabled: !!c?.enabled, locked: !!c?.locked };
}
async function toggleBinding(agentId, roleId, relation) {
const meta = bindingMeta(agentId, roleId, relation);
if (meta.locked) return;
state.saving = true;
state.saveError = null;
try {
await patchApi('/rbac/agent-bindings', {
agent_id: agentId,
role_id: roleId,
relation,
enabled: !meta.enabled,
});
state.data = await api('/rbac/matrix');
const root = document.getElementById('access-matrix-content');
if (root) paint(root);
} catch (e) {
state.saveError = e.message;
const root = document.getElementById('access-matrix-content');
if (root) paint(root);
} finally {
state.saving = false;
}
}
async function toggleCap(capId, roleId) {
const meta = capMeta(capId, roleId);
if (meta.locked) return;
state.saving = true;
state.saveError = null;
try {
await patchApi('/rbac/agent-governance', {
cap_id: capId,
role_id: roleId,
enabled: !meta.enabled,
});
state.data = await api('/rbac/matrix');
const root = document.getElementById('access-matrix-content');
if (root) paint(root);
} catch (e) {
state.saveError = e.message;
const root = document.getElementById('access-matrix-content');
if (root) paint(root);
} finally {
state.saving = false;
}
}
async function api(path) {
const r = await fetchWithTimeout(`/api/v1${path}`, {
headers: authHeaders({ 'Content-Type': 'application/json' }),
});
if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`);
return r.json();
}
function levelMeta(level) {
return state.data.legend[level] || state.data.legend.none;
}
function badgeHtml(level, opts = {}) {
const meta = levelMeta(level);
const short = LEVEL_SHORT[meta.access] || '—';
const cls = opts.compact ? ' am-badge--compact' : '';
return `<span class="am-badge am-badge--${meta.access}${cls}" title="${esc(meta.label)}">${short}</span>`;
}
function serviceLabel(svc) {
return state.data.service_labels?.[svc] || svc;
}
function rolesByCategory(catalog, categoryLabels) {
const groups = {};
Object.values(catalog.roles || {}).forEach((role) => {
const cat = role.category || 'platform';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(role);
});
const order = ['ops', 'commercial', 'business', 'platform', 'system'];
return order
.filter((c) => groups[c]?.length)
.map((c) => ({ id: c, label: categoryLabels[c] || c, roles: groups[c] }));
}
function roleDeskRows() {
const roleId = state.selectedRole;
return (state.data.desk_modules || []).map((row) => ({
...row,
level: row.levels[roleId] || 'none',
})).filter((r) => r.level !== 'none');
}
function renderRoleSidebar() {
const groups = rolesByCategory(state.data.catalog, state.data.category_labels || {});
return groups.map((g) => `
<div class="am-role-group">
<h4>${esc(g.label)}</h4>
${g.roles.map((r) => `
<button type="button" class="am-role-btn${state.selectedRole === r.id ? ' active' : ''}"
data-am-role="${esc(r.id)}">${esc(r.label)}</button>
`).join('')}
</div>`).join('');
}
function renderScopeBar() {
return `<div class="am-scope-bar">
${(state.data.scope_layers || []).map((l) => `
<div class="am-scope-item">
<span class="am-scope-label">${esc(l.label)}</span>
<span class="am-scope-host">${esc(l.host)}</span>
<span class="am-scope-desc">${esc(l.desc)}</span>
</div>`).join('')}
</div>`;
}
function renderMatrixTable(rows, rowLabel, idKey) {
const { role_columns, legend } = state.data;
const cols = role_columns || [];
const head = cols.map((c) => {
const label = state.data.catalog.roles[c]?.label || c;
const hl = c === state.selectedRole ? ' am-col-active' : '';
return `<th class="${hl.trim()}" title="${esc(c)}">${esc(label.split(' ')[0])}</th>`;
}).join('');
const body = rows.map((row) => {
const cells = cols.map((role) => {
const lv = row.levels[role] || 'none';
const tdHl = role === state.selectedRole ? ' am-row-highlight' : '';
return `<td class="${tdHl.trim()}">${badgeHtml(lv)}</td>`;
}).join('');
const sub = row.product
? `<span class="am-row-sub">${esc(row.product)}</span>`
: `<code class="am-row-code">${esc(row[idKey] || '')}</code>`;
return `<tr>
<td class="am-sticky">
<span class="am-row-title">${esc(row.label)}</span>
${sub}
</td>${cells}</tr>`;
}).join('');
return `
<div class="am-table-wrap">
<table class="am-matrix-table">
<thead><tr><th class="am-sticky">${esc(rowLabel)}</th>${head}</tr></thead>
<tbody>${body}</tbody>
</table>
</div>`;
}
function renderDeskMatrix() {
return `
<p class="am-section-lead">Grelha completa módulos internos do Desk (VM122). Use <strong>Visão da função</strong> para resumo ou <strong>Software & Infra</strong> para VM112/123.</p>
${renderMatrixTable(state.data.desk_modules || [], 'Módulo Desk', 'id')}`;
}
function renderBindingCards(bindings, title) {
if (!bindings.length) {
return `<p class="am-empty">${title ? esc(title) + ' — ' : ''}sem registos para esta função.</p>`;
}
const byService = {};
bindings.forEach((b) => {
if (!byService[b.service]) byService[b.service] = [];
byService[b.service].push(b);
});
return `
${title ? `<h4 class="am-block-title">${esc(title)}</h4>` : ''}
<div class="am-api-grid">
${Object.entries(byService).flatMap(([svc, items]) =>
items.map((b) => `
<article class="am-api-card am-svc-${esc(b.service)}">
<header class="am-api-card-head">
<span class="am-api-service">${esc(serviceLabel(svc))}</span>
${badgeHtml(b.access || 'full', { compact: true })}
</header>
<div class="am-api-type">${esc(b.type || 'binding')}</div>
<code class="am-api-value">${esc(b.value)}</code>
<footer class="am-api-foot">${esc(svc)}</footer>
</article>`)
).join('')}
</div>`;
}
function renderSoftwareCards(rows, title) {
if (!rows.length) {
return `<p class="am-empty">${esc(title || 'Software')} — sem acesso directo.</p>`;
}
return `
${title ? `<h4 class="am-block-title">${esc(title)}</h4>` : ''}
<div class="am-sw-grid">
${rows.map((r) => {
const meta = levelMeta(r.level);
return `<article class="am-sw-card">
<header class="am-sw-card-head">
<div>
<strong>${esc(r.label)}</strong>
<span class="am-sw-product">${esc(r.product)}</span>
</div>
${badgeHtml(r.level, { compact: true })}
</header>
<p class="am-sw-group">${esc(r.group)}</p>
${r.host && r.host !== '—' ? `<code class="am-sw-host">${esc(r.host)}</code>` : ''}
</article>`;
}).join('')}
</div>`;
}
function renderDeskChips() {
const rows = roleDeskRows();
if (!rows.length) {
return '<p class="am-empty">Sem módulos Desk activos para esta função.</p>';
}
return `<div class="am-desk-chips">
${rows.map((r) => `
<span class="am-desk-chip" title="${esc(r.id)}">
${esc(r.label)}
${badgeHtml(r.level, { compact: true })}
</span>`).join('')}
</div>`;
}
function renderRoleStats(role) {
const sw = state.data.role_software_summaries?.[state.selectedRole] || [];
const bindings = role?.bindings || [];
const desk = roleDeskRows();
return `<div class="am-stats">
<div class="am-stat"><span class="am-stat-n">${desk.length}</span><span class="am-stat-l">Módulos Desk</span></div>
<div class="am-stat"><span class="am-stat-n">${sw.length}</span><span class="am-stat-l">Software</span></div>
<div class="am-stat"><span class="am-stat-n">${bindings.length}</span><span class="am-stat-l">APIs / Grupos</span></div>
<div class="am-stat"><span class="am-stat-n">${(state.data.role_agents_summaries?.[state.selectedRole] || []).length}</span><span class="am-stat-l">Agentes</span></div>
</div>`;
}
function renderRoleOverview() {
const role = state.data.catalog.roles[state.selectedRole];
const sw = state.data.role_software_summaries?.[state.selectedRole] || [];
const bindings = role?.bindings || [];
return `
${renderScopeBar()}
${renderRoleStats(role)}
<section class="am-block">
<h4 class="am-block-title">Desk VM122 módulos activos</h4>
${renderDeskChips()}
</section>
<section class="am-block">
${renderSoftwareCards(sw, 'Software & infra em escopo')}
</section>
<section class="am-block">
${renderBindingCards(bindings, 'APIs, grupos Odoo e permissões')}
</section>
<section class="am-block">
<h4 class="am-block-title">Agentics capacidades da função</h4>
${renderRoleAgentCaps()}
${renderRoleAgentsSummary('')}
</section>`;
}
function renderSoftwareMatrix() {
const { software_groups } = state.data;
const groups = (software_groups || []).filter((g) =>
state.softwareFilter === 'all' || g.id === state.softwareFilter
);
const filters = [
{ id: 'all', label: 'Todas as camadas' },
...(software_groups || []).map((g) => ({ id: g.id, label: g.label.split('—')[0].trim() })),
];
const filterBar = `
<div class="am-filter-bar">
${filters.map((f) => `
<button type="button" class="am-filter${state.softwareFilter === f.id ? ' active' : ''}"
data-am-sw-filter="${esc(f.id)}">${esc(f.label)}</button>`).join('')}
</div>`;
const sections = groups.map((grp) => {
const meta = [grp.host, grp.url].filter(Boolean).join(' · ');
return `
<section class="am-sw-section">
<header class="am-sw-section-head">
<div>
<h4>${esc(grp.label)}</h4>
${meta ? `<span class="am-sw-meta">${esc(meta)}</span>` : ''}
</div>
</header>
${renderMatrixTable(grp.items || [], 'Recurso', 'id')}
</section>`;
}).join('');
const sw = state.data.role_software_summaries?.[state.selectedRole] || [];
return `
<p class="am-section-lead">Matriz de software fora do Desk VM112 (onboard/mail), VM123 (finance/hosting) e consolas de infra.</p>
${renderSoftwareCards(sw, 'Resumo da função seleccionada')}
${filterBar}${sections}`;
}
function renderBindings() {
const role = state.data.catalog.roles[state.selectedRole];
if (!role) return '<p class="am-empty">Função não encontrada</p>';
return `
<p class="am-section-lead">Bindings estilo Odoo grupos, roles e permissões provisionados por função.</p>
${renderBindingCards(role.bindings || [], '')}`;
}
function roleLabel(roleId) {
return state.data.catalog.roles[roleId]?.label || roleId;
}
function renderRelationChip(rel) {
const labels = state.data.agent_relation_labels || {
approve: 'Aprova',
focus: 'Operador',
ui: 'UI',
};
return `<span class="am-rel am-rel--${esc(rel)}">${esc(labels[rel] || rel)}</span>`;
}
function renderRoleChips(roleIds) {
return (roleIds || []).map((r) =>
`<span class="am-role-chip${r === state.selectedRole ? ' am-role-chip--sel' : ''}" title="${esc(r)}">${esc(roleLabel(r))}</span>`
).join('');
}
function renderAgentGovernanceLegend() {
const labels = state.data.agent_relation_labels || {};
return `<div class="am-agent-legend">
${Object.entries(labels).map(([k, v]) =>
`<span class="am-agent-legend-item">${renderRelationChip(k)} ${esc(v)}</span>`
).join('')}
</div>`;
}
function renderRoleAgentCaps() {
const capsDef = [
['use_ui', 'UI Agentics'],
['trigger_runs', 'Disparar cenários'],
['approve_runbooks', 'Aprovar runbooks'],
['configure_models', 'Configurar LLM'],
];
const roleId = state.selectedRole;
if (!state.data.editable) {
const caps = state.data.role_agentic_caps?.[roleId] || [];
if (!caps.length) {
return '<p class="am-empty">Esta função não acede ao módulo Agentics.</p>';
}
return `<div class="am-cap-chips">
${caps.map((c) => `<span class="am-cap-chip">${esc(c.label)}</span>`).join('')}
</div>`;
}
return `<div class="am-cap-toggles">
${capsDef.map(([id, label]) => {
const meta = capMeta(id, roleId);
return `<button type="button"
class="am-cap-toggle${meta.enabled ? ' on' : ''}${meta.locked ? ' locked' : ''}"
${meta.locked ? 'disabled' : ''}
data-am-cap="${esc(id)}|${esc(roleId)}"
title="${esc(label)}">${esc(label)}</button>`;
}).join('')}
</div>`;
}
function renderRoleAgentsSummary(title) {
const rows = state.data.role_agents_summaries?.[state.selectedRole] || [];
if (!rows.length) {
return `<p class="am-empty">${esc(title || 'Agentes')} — sem interacção directa.</p>`;
}
return `
${title ? `<h4 class="am-block-title">${esc(title)}</h4>` : ''}
<div class="am-agent-mini-grid">
${rows.map((a) => `
<article class="am-agent-mini${a.is_approver ? ' am-agent-mini--approve' : ''}">
<header>
<span class="am-agent-id">${esc(a.id)}</span>
<strong>${esc(a.name)}</strong>
</header>
<p class="am-agent-role">${esc(a.role)}</p>
<div class="am-rel-row">${(a.relations || []).map(renderRelationChip).join('')}</div>
</article>`).join('')}
</div>`;
}
function renderAgentCard(a) {
const rels = a.role_relations?.[state.selectedRole] || [];
const hit = rels.length > 0;
return `
<article class="am-agent-card${hit ? ' am-agent-card--hit' : ''}">
<header class="am-agent-top">
<span class="am-agent-id">${esc(a.id)}</span>
<div>
<strong>${esc(a.name)}</strong>
<span class="am-agent-codename">${esc(a.codename)}</span>
</div>
</header>
<p class="am-agent-role">${esc(a.role)}</p>
${hit ? `<div class="am-rel-row am-rel-row--you">${rels.map(renderRelationChip).join('')} <span class="am-you">esta função</span></div>` : ''}
<div class="am-agent-section">
<span class="am-agent-lbl">Aprova</span>
${renderRoleChips(a.approvers)}
</div>
<div class="am-agent-section">
<span class="am-agent-lbl">Operadores</span>
${renderRoleChips(a.operators)}
</div>
${(a.reads || []).length ? `
<div class="am-agent-section">
<span class="am-agent-lbl"></span>
<span class="am-agent-meta">${esc(a.reads.slice(0, 3).join(' · '))}</span>
</div>` : ''}
<footer class="am-agent-approval">
<span>Regra Spec 027</span>
<code>${esc(a.approval_text)}</code>
</footer>
</article>`;
}
function renderRelationToggle(agentId, roleId, relation) {
const meta = bindingMeta(agentId, roleId, relation);
const short = { ui: 'UI', focus: 'OP', approve: 'AP' }[relation] || relation;
return `<button type="button"
class="am-toggle${meta.enabled ? ' on' : ''}${meta.locked ? ' locked' : ''}"
${meta.locked ? 'disabled' : ''}
data-am-toggle="${esc(agentId)}|${esc(roleId)}|${relation}"
title="${esc(relation)}${meta.locked ? ' (obrigatório)' : ''}">${short}</button>`;
}
function renderAgentMatrixCell(agentId, role, rels) {
const tdHl = role === state.selectedRole ? ' am-row-highlight' : '';
if (!state.data.editable) {
const inner = rels.length
? rels.map(renderRelationChip).join('')
: '<span class="am-rel am-rel--none">—</span>';
return `<td class="${tdHl.trim()}">${inner}</td>`;
}
return `<td class="am-cell-toggles${tdHl.trim()}">
${renderRelationToggle(agentId, role, 'ui')}
${renderRelationToggle(agentId, role, 'focus')}
${renderRelationToggle(agentId, role, 'approve')}
</td>`;
}
function renderAgentsMatrix() {
const agents = state.data.agents || [];
const cols = state.data.role_columns || [];
const head = cols.map((c) => {
const hl = c === state.selectedRole ? ' am-col-active' : '';
return `<th class="${hl.trim()}" title="${esc(c)}">${esc(roleLabel(c).split(' ')[0])}</th>`;
}).join('');
const body = agents.map((a) => {
const cells = cols.map((role) => {
const rels = a.role_relations?.[role] || [];
return renderAgentMatrixCell(a.id, role, rels);
}).join('');
return `<tr>
<td class="am-sticky">
<span class="am-row-title">${esc(a.id)} · ${esc(a.name)}</span>
<span class="am-row-sub">${esc(a.role)}</span>
</td>${cells}</tr>`;
}).join('');
return `
<h4 class="am-block-title">Matriz agente × função</h4>
${renderAgentGovernanceLegend()}
<div class="am-table-wrap">
<table class="am-matrix-table">
<thead><tr><th class="am-sticky">Agente</th>${head}</tr></thead>
<tbody>${body}</tbody>
</table>
</div>`;
}
function renderAgents() {
const caps = state.data.role_agentic_caps?.[state.selectedRole] || [];
return `
<p class="am-section-lead">
Agentes A0A7 usam conta <code>agent_system</code>.
${state.data.editable
? 'Clique <strong>UI / OP / AP</strong> para ligar ou desligar atribuições (audit activo).'
: 'Três relações: UI · Operador · Aprova.'}
</p>
${state.saveError ? `<p class="am-save-error">${esc(state.saveError)}</p>` : ''}
${(state.data.editable || caps.length) ? `<section class="am-block"><h4 class="am-block-title">Capacidades — ${esc(roleLabel(state.selectedRole))}</h4>${renderRoleAgentCaps()}</section>` : ''}
${renderAgentsMatrix()}
<section class="am-block">
<h4 class="am-block-title">Detalhe por agente</h4>
<div class="am-agents-grid">
${(state.data.agents || []).map(renderAgentCard).join('')}
</div>
</section>`;
}
function renderLegend() {
const legend = state.data.legend || {};
return Object.entries(legend).map(([key, l]) =>
`<span class="am-legend-item">${badgeHtml(key, { compact: true })} ${esc(l.label)}</span>`
).join('');
}
function activeTabHint() {
const tab = (state.data.tabs || []).find((t) => t.id === state.tab);
return tab?.hint || '';
}
function renderMainPanel() {
const role = state.data.catalog.roles[state.selectedRole];
let body = '';
if (state.tab === 'overview') body = renderRoleOverview();
else if (state.tab === 'vm122') body = renderDeskMatrix();
else if (state.tab === 'software') body = renderSoftwareMatrix();
else if (state.tab === 'bindings') body = renderBindings();
else if (state.tab === 'agents') body = renderAgents();
return `
<div class="am-panel">
<header class="am-panel-head">
<div class="am-panel-title">
<span class="am-panel-kicker">Spec 027 · RBAC</span>
<h3>${esc(role?.label || state.selectedRole)}</h3>
<p class="am-panel-desc">${esc(role?.description || '')}</p>
${activeTabHint() ? `<p class="am-tab-hint">${esc(activeTabHint())}</p>` : ''}
</div>
<div class="am-legend">${renderLegend()}</div>
</header>
<div class="am-panel-body">${body}</div>
</div>`;
}
function renderTabs() {
return (state.data.tabs || []).map((t) =>
`<button type="button" class="am-tab${state.tab === t.id ? ' active' : ''}" data-am-tab="${esc(t.id)}">${esc(t.label)}</button>`
).join('');
}
function bindEvents(root) {
root.querySelectorAll('[data-am-role]').forEach((btn) => {
btn.addEventListener('click', () => {
state.selectedRole = btn.dataset.amRole;
paint(root);
});
});
root.querySelectorAll('[data-am-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
state.tab = btn.dataset.amTab;
paint(root);
});
});
root.querySelectorAll('[data-am-sw-filter]').forEach((btn) => {
btn.addEventListener('click', () => {
state.softwareFilter = btn.dataset.amSwFilter;
paint(root);
});
});
root.querySelectorAll('[data-am-toggle]').forEach((btn) => {
btn.addEventListener('click', () => {
if (state.saving || !state.data?.editable) return;
const [agentId, roleId, relation] = btn.dataset.amToggle.split('|');
toggleBinding(agentId, roleId, relation);
});
});
root.querySelectorAll('[data-am-cap]').forEach((btn) => {
btn.addEventListener('click', () => {
if (state.saving || !state.data?.editable) return;
const [capId, roleId] = btn.dataset.amCap.split('|');
toggleCap(capId, roleId);
});
});
}
function paint(root) {
root.innerHTML = `
<div class="am-wrap">
<div class="am-header">
<div>
<h2 class="am-page-title">Matriz de Acessos</h2>
<p class="am-page-sub">Quatro camadas: Desk VM122 · VM112 · VM123 · Infra externa</p>
</div>
<span class="am-preview-tag">${state.data?.editable ? 'Edição · audit ON' : 'Preview · read-only'}${state.saving ? ' · …' : ''}</span>
</div>
${state.saveError ? `<p class="am-save-error">${esc(state.saveError)}</p>` : ''}
<nav class="am-subnav">${renderTabs()}</nav>
<div class="am-layout">
<aside class="am-role-list">${renderRoleSidebar()}</aside>
${renderMainPanel()}
</div>
</div>`;
bindEvents(root);
}
async function renderAccessMatrix() {
const root = document.getElementById('access-matrix-content');
if (!root) return;
root.innerHTML = '<p class="loading">Carregando matriz de acessos…</p>';
try {
state.data = await api('/rbac/matrix');
if (!state.selectedRole && state.data.role_columns?.length) {
state.selectedRole = state.data.role_columns[0];
}
paint(root);
} catch (e) {
root.innerHTML = `<p class="loading">Matriz indisponível: ${esc(e.message)}</p>`;
}
}
window.renderAccessMatrix = renderAccessMatrix;
window.DeskAccessMatrix = { renderAccessMatrix };
})();

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -42,8 +42,10 @@ function sessionHashHtml(sessionId, { full = true } = {}) {
return `<code class="session-hash" title="Sessão onboarding VM112">${esc(shown)}</code>`;
}
const DEFAULT_VIEW = 'agentic-ops';
let state = {
view: 'dashboard',
view: DEFAULT_VIEW,
ticketFilter: 'all',
sourceFilter: 'all',
eventSourceFilter: 'all',
@ -62,6 +64,7 @@ let state = {
adminUsers: [],
adminFilter: { q: '', role: 'all', status: 'all', mfa: 'all' },
adminSelected: null,
features: { access_matrix_ui: false },
socWindow: '24h',
socLastEventId: null,
};
@ -79,6 +82,7 @@ const views = {
'agentic-ops': document.getElementById('view-agentic-ops'),
messages: document.getElementById('view-messages'),
admin: document.getElementById('view-admin'),
'access-matrix': document.getElementById('view-access-matrix'),
account: document.getElementById('view-account'),
leads: document.getElementById('view-leads'),
modules: document.getElementById('view-modules'),
@ -194,9 +198,34 @@ function applyRoleNav() {
}
}
async function loadDeskFeatures() {
try {
const data = await api('/v1/config/features');
state.features = { ...state.features, ...data };
} catch {
state.features = { access_matrix_ui: false };
}
}
function applyAccessMatrixNav() {
const nav = document.getElementById('nav-access-matrix');
if (!nav) return;
const user = getUser();
const show = !!state.features?.access_matrix_ui && user?.role === 'super_admin';
if (show) nav.removeAttribute('hidden');
else nav.setAttribute('hidden', '');
}
function resolveDefaultView() {
if (window.DeskModules?.loaded && DeskModules.isViewEnabled(DEFAULT_VIEW)) return DEFAULT_VIEW;
return 'dashboard';
}
function setView(name) {
if (window.DeskModules?.loaded && !DeskModules.isViewEnabled(name)) {
name = 'dashboard';
if (name === 'access-matrix' && !state.features?.access_matrix_ui) {
name = resolveDefaultView();
} else if (window.DeskModules?.loaded && !DeskModules.isViewEnabled(name)) {
name = resolveDefaultView();
}
if (state.view === 'account' && name !== 'account') {
state.accountLoaded = false;
@ -214,6 +243,7 @@ function setView(name) {
'agentic-ops': 'Agentic Ops',
messages: 'Mensagens — pedidos de cadastro',
admin: 'Administradores',
'access-matrix': 'Matriz de Acessos',
account: 'Minha conta',
leads: 'Leads abandonados',
modules: 'Módulos',
@ -230,6 +260,7 @@ function setView(name) {
'agentic-ops': 'Vigilância 24/7, findings, advisor IA e copiloto ops (Spec 029)',
messages: 'Operações Ligbox — onboarding, tickets e monitoramento',
admin: 'Operações Ligbox — onboarding, tickets e monitoramento',
'access-matrix': 'Preview read-only — Spec 027 · funções × VM112/122/123 (estilo Odoo groups)',
account: 'Operações Ligbox — onboarding, tickets e monitoramento',
leads: 'Operações Ligbox — onboarding, tickets e monitoramento',
modules: 'Activar ou desativar funcionalidades do Desk sem afectar o núcleo',
@ -2996,7 +3027,7 @@ async function renderModules() {
await DeskModules.load();
applyRoleNav();
DeskModules.applyVisibility();
if (!DeskModules.isViewEnabled(state.view)) setView('dashboard');
if (!DeskModules.isViewEnabled(state.view)) setView(resolveDefaultView());
else refresh();
} catch (e) {
input.checked = !input.checked;
@ -4227,6 +4258,7 @@ async function refresh(options = {}) {
if (state.view === 'agentic-ops' && window.renderAgenticOps) await window.renderAgenticOps();
if (state.view === 'messages') await renderMessages();
if (state.view === 'admin') await renderAdmin();
if (state.view === 'access-matrix' && window.renderAccessMatrix) await window.renderAccessMatrix();
if (state.view === 'modules') await renderModules();
if (state.view === 'account') await renderAccount();
}
@ -4285,14 +4317,16 @@ document.getElementById('btn-refresh')?.addEventListener('click', () => {
return;
}
setupSidebarUser();
await loadDeskFeatures();
await DeskModules.load();
applyRoleNav();
DeskModules.applyVisibility();
applyAccessMatrixNav();
bindOverviewModal();
bindInfraProcessModal();
bindTeamDrawerClose();
bindSocTestModal();
setView('dashboard');
setView(DEFAULT_VIEW);
ensureValidSession().then((valid) => {
if (!valid) window.location.replace('/login.html');

View file

@ -6,7 +6,8 @@
<title>Ligbox Ops — Support Desk</title>
<link rel="stylesheet" href="/assets/styles.css?v=20260619tickets2"/>
<link rel="stylesheet" href="/assets/tickets-workspace.css?v=20260619tickets2"/>
<link rel="stylesheet" href="/assets/agentic-ops.css?v=20260620v2"/>
<link rel="stylesheet" href="/assets/agentic-ops.css?v=20260620v10"/>
<link rel="stylesheet" href="/assets/access-matrix.css?v=20260620am7"/>
</head>
<body>
<svg width="0" height="0" style="position:absolute;visibility:hidden" aria-hidden="true" focusable="false">
@ -186,7 +187,11 @@
<p>Support Desk · Ibytera</p>
</div>
<nav class="nav">
<button type="button" data-view="dashboard" data-module="core" class="active nav-item nav-item-dashboard">
<button type="button" data-view="agentic-ops" data-module="agentic-ops" id="nav-agentic-ops" class="active nav-item nav-item-agentic">
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-infra"/></svg></span>
<span class="nav-label">Agentic Ops</span>
</button>
<button type="button" data-view="dashboard" data-module="core" class="nav-item nav-item-dashboard">
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-dashboard"/></svg></span>
<span class="nav-label">Dashboard</span>
</button>
@ -226,10 +231,6 @@
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-infra2"/></svg></span>
<span class="nav-label">Infra 2 <span class="nav-badge-new">SOC</span></span>
</button>
<button type="button" data-view="agentic-ops" data-module="agentic-ops" id="nav-agentic-ops" class="nav-item nav-item-agentic">
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-infra"/></svg></span>
<span class="nav-label">Agentic Ops</span>
</button>
<button type="button" data-view="account" data-module="core" id="nav-account" class="nav-item nav-item-account">
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-account"/></svg></span>
<span class="nav-label">Minha conta</span>
@ -242,6 +243,10 @@
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-admin"/></svg></span>
<span class="nav-label">Administradores</span>
</button>
<button type="button" data-view="access-matrix" id="nav-access-matrix" hidden class="nav-item nav-item-admin">
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-admin"/></svg></span>
<span class="nav-label">Matriz <span class="nav-badge-new">β</span></span>
</button>
<button type="button" data-view="modules" data-module="modules-admin" id="nav-modules" hidden class="nav-item nav-item-modules">
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-admin"/></svg></span>
<span class="nav-label">Módulos</span>
@ -254,8 +259,8 @@
<main class="main">
<header class="page-header">
<div>
<h2 id="page-title">Dashboard</h2>
<p id="page-subtitle">Operações Ligbox — onboarding, tickets e monitoramento</p>
<h2 id="page-title">Agentic Ops</h2>
<p id="page-subtitle">Vigilância 24/7, findings, advisor IA e copiloto ops (Spec 029)</p>
</div>
<div class="page-toolbar" id="page-toolbar">
<span id="header-user" class="header-user" hidden></span>
@ -265,7 +270,7 @@
</div>
</header>
<section id="view-dashboard" class="view active">
<section id="view-dashboard" class="view">
<div id="dashboard-content"><p class="loading">Carregando…</p></div>
</section>
@ -331,7 +336,7 @@
<div id="infra2-content"><p class="loading">Carregando SOC…</p></div>
</section>
<section id="view-agentic-ops" class="view">
<section id="view-agentic-ops" class="view active">
<div id="agentic-ops-content"><p class="loading">Carregando Agentic Ops…</p></div>
</section>
@ -347,6 +352,10 @@
<div id="admin-content"><p class="loading">Carregando…</p></div>
</section>
<section id="view-access-matrix" class="view">
<div id="access-matrix-content"><p class="loading">Carregando matriz…</p></div>
</section>
<section id="view-modules" class="view">
<div id="modules-content"><p class="loading">Carregando…</p></div>
</section>
@ -444,7 +453,8 @@
<script src="/assets/tickets-workspace.js?v=20260619tickets2"></script>
<script src="/assets/tickets-detail-panel.js?v=20260619tickets2"></script>
<script src="/assets/servicos.js?v=20260620agentic"></script>
<script src="/assets/agentic-ops.js?v=20260620v2"></script>
<script src="/assets/app.js?v=20260620v2"></script>
<script src="/assets/agentic-ops.js?v=20260620v10"></script>
<script src="/assets/access-matrix.js?v=20260620am7"></script>
<script src="/assets/app.js?v=20260620v4"></script>
</body>
</html>

View file

@ -359,8 +359,9 @@ Ver [`contracts/vm123-product-roles.md`](contracts/vm123-product-roles.md).
## 11. Documentos relacionados
| Spec | Relação |
|------|---------|
| Documento | Relação |
|-----------|---------|
| **ui-access-matrix.md** | Página Desk preview (feature flag, wireframe, referências UI) |
| **003** | RBAC base (4 roles) — **pai** |
| **004** | Cadastro e atribuição de perfil |
| **015** | Registry módulos Desk |

View file

@ -0,0 +1,160 @@
# Spec 027-UI — Matriz de Acessos (Página Desk)
**Criado:** 2026-06-20
**Solicitado por:** Roger
**Status:** 🔄 Preview (read-only, feature flag)
**Sistema:** Desk VM122 · view `access-matrix`
**Relacionado:** Spec **027** (matriz) · Spec **004** (cadastro) · `platform_role_catalog.py`
---
## Objetivo
Página visual **estilo Odoo Groups + Permission Matrix** para consultar a matriz de funções Ligbox (VM112 / VM122 / VM123 / externas / agentes), **sem alterar** produção até Roger aprovar as telas.
---
## Princípios de entrega
| Regra | Implementação |
|-------|---------------|
| Zero impacto produção | `ACCESS_MATRIX_UI=0` (default) — menu oculto |
| Preview staging | `ACCESS_MATRIX_UI=1` no compose staging (`:8192`) |
| Só leitura v1 | Sem PATCH de roles/módulos na matriz |
| Fonte de verdade | `platform_role_catalog.py` + tabelas Spec 027 §3 |
| Quem vê | `super_admin` apenas (preview) |
| Não substituir | Página **Equipe Ligbox** (`admin-users`) mantida |
---
## Feature flag
```bash
# .env / docker-compose staging
ACCESS_MATRIX_UI=1 # staging preview
ACCESS_MATRIX_UI=0 # produção (default)
```
API:
```http
GET /api/v1/config/features
→ { "access_matrix_ui": true, "preview_mode": true }
GET /api/v1/rbac/matrix
→ catálogo completo (roles, módulos Desk, bindings, agentes, legenda)
```
---
## Referências UI (modelos a seguir)
### GitHub — RBAC admin / permission matrix
| Repo | O que reutilizar | URL |
|------|------------------|-----|
| **rbac-admin-platform** | Dashboard admin, tabela role×ação, audit, dark mode | https://github.com/bankotij/rbac-admin-platform |
| **RBAC-UI** (jagadish-pattanaik) | `PermissionMatrix.js`, sidebar roles, grouping | https://github.com/jagadish-pattanaik/RBAC-UI |
| **rbac-ui** (balamuruganpm) | User/role tables, collapsible sidebar | https://github.com/balamuruganpm/rbac-ui |
### shadcn/ui blocks (padrão visual da grelha)
| Block | O que reutilizar | URL |
|-------|------------------|-----|
| **Tables Permission Matrix** | Linhas agrupadas por módulo, colunas = roles, badges de nível | https://www.shadcn.io/blocks/tables-permission-matrix |
| **Dashboard Permission Matrix** | Grelha recurso×role, cores none/read/write/admin | https://www.shadcn.io/blocks/dashboard-permission-matrix |
### Odoo (modelo conceptual — não fork)
| Conceito Odoo | Equivalente Ligbox |
|---------------|-------------------|
| `res.users` + `groups_id` | `desk_users.role` + provisionamento VM123 |
| `res.groups` | `PLATFORM_ROLE_CATALOG[*].bindings` |
| `ir.model.access` | `permissions.py` + route guards |
| `ir.rule` (record rules) | mascaramento NOC, ticket assignee |
| Settings → Users → Groups UI | Página Matriz de Acessos |
Docs: https://www.odoo.com/documentation/16.0/developer/reference/backend/security.html
### Desk Ligbox (consistência visual)
- Paleta existente: `styles.css` (`--accent`, `--ok`, `--warn`, `--sidebar-bg`)
- Padrão módulo isolado: `agentic-ops.js` + CSS scoped (`#access-matrix-content`)
- Tabs admin: Equipe Ligbox | Matriz (quando flag ON)
---
## Wireframe (Preview v1)
```
┌─ PREVIEW — read-only · Spec 027 ─────────────────────────────────────┐
│ [ Equipe Ligbox ] [ Matriz de Acessos ● ] │
├──────────────┬───────────────────────────────────────────────────────┤
│ Funções │ [ VM122 Desk ] [ VM112 ] [ VM123 ] [ Externas ] [A0-A7]│
│ ───────── │ │
│ ▶ Ops │ Grelha módulo × função (✅ 🔒 ❌ ⚙️ 🔗) │
│ super_admin│ ou painel bindings por serviço (Odoo groups) │
│ ops_lead │ │
│ ▶ Comercial │ Ao seleccionar função → detalhe bindings: │
│ sales_admin│ vm123_odoo: sales_team.group_sale_manager │
│ … │ vm123_foss: ligbox-sales-admin │
└──────────────┴───────────────────────────────────────────────────────┘
```
---
## Fases
### Fase UI-A — Preview (esta entrega) ✅
- [x] Spec UI (este documento)
- [x] `GET /api/v1/rbac/matrix` read-only
- [x] `GET /api/v1/config/features`
- [x] `access-matrix.js` + CSS scoped
- [x] Flag staging ON, produção OFF
- [x] Tab **Software em escopo** (VM112 / VM123 / consolas externas — Spec 027 §2, §4, §5)
- [x] Cartão por função (resumo SW ≠ none)
- [ ] Roger aprova layout
### Fase UI-A2 — Melhorias sugeridas (backlog)
| Melhoria | Descrição | Prioridade |
|----------|-----------|------------|
| **Visão unificada** | Uma grelha Desk + SW (coluna «camada»: Desk / VM112 / VM123 / Externa) | Alta |
| **Deep-links activos** | Clicar 🔗 abre URL do serviço (Traefik, OpenPanel, etc.) se binding existir | Alta |
| **Diff vs. realidade** | Comparar matriz Spec 027 com `desk_users.role` + último login por SW | Média |
| **Export CSV/PDF** | Matriz completa para auditoria / onboarding | Média |
| **Filtro por produto** | Chips FOSSBilling, Odoo, OpenPanel, Carbonio… | Baixa |
| **Contas sistema** | Linha 🤖 `api_service` / agentes com credenciais mascaradas | Baixa |
| **Histórico** | Quem alterou role e quando (audit log Spec 004) | Futuro |
### Fase UI-B — Pós-aprovação
- Tab integrada em Administradores (sem flag)
- Export CSV/PDF da matriz
- Link «Ver matriz» no drawer Equipe
### Fase UI-C — Edição Agentics ✅ (staging)
- [x] Tabela SQLite `agent_role_bindings` + `agent_governance_caps`
- [x] `PATCH /api/v1/rbac/agent-bindings` (UI / focus / approve)
- [x] `PATCH /api/v1/rbac/agent-governance` (caps globais)
- [x] `GET /api/v1/rbac/agent-bindings/audit`
- [x] Enforcement API Agentics (`can_use_agentics_ui`, `can_chat_agent`, `can_trigger_runs`)
- [x] Toggles UI / OP / AP na matriz (flag `ACCESS_MATRIX_EDIT=1`)
- [x] Bindings A7→agentic_operator **locked** (Spec FR-027-007)
- [ ] Roger valida em staging → activar produção
---
## Critérios de aceite (Preview)
- [ ] Produção (`8091`): menu Matriz **invisível** com flag default
- [ ] Staging (`8192`): Roger vê Matriz como `super_admin`
- [ ] Página é 100% read-only (sem POST/PATCH)
- [ ] Dados batem com Spec 027 §36 e `platform_role_catalog.py`
- [ ] Equipe Ligbox / Mensagens / cadastro **inalterados**
---
*Roger — revisar em http://10.10.10.122:8192 após deploy staging.*