Commita governance, user-wizard, operational-feed e catálogo RBAC; adiciona deploy-desk-full.sh, smoke-desk.sh e regra anti-deploy parcial; documenta credencial VM112 @betinplace. Co-authored-by: Cursor <cursoragent@cursor.com>
364 lines
11 KiB
Python
364 lines
11 KiB
Python
"""Spec 039 — Catálogo de acções + overrides persistidos (Matriz editável)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sqlite3
|
|
from datetime import datetime, timezone
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
VALID_LEVELS = frozenset({"full", "read", "link", "api", "approve", "system", "none"})
|
|
|
|
EXECUTIVE_ROWS: list[dict[str, Any]] = [
|
|
{
|
|
"id": "desk-users-admin",
|
|
"area": "Criar / editar / freeze utilizadores Desk",
|
|
"who": ["SU"],
|
|
"why": "Credenciais internas Ligbox",
|
|
"groups": ["desk-auth"],
|
|
"action_ids": ["desk.auth.user.edit", "desk.auth.user.freeze", "desk.auth.user.password.reset"],
|
|
},
|
|
{
|
|
"id": "desk-registration",
|
|
"area": "Aprovar cadastros",
|
|
"who": ["SU", "CO"],
|
|
"why": "CO autónomo — Roger 2026-06-29",
|
|
"groups": ["desk-auth"],
|
|
"action_ids": ["desk.auth.user.approve_registration", "desk.auth.user.reject_registration"],
|
|
},
|
|
{
|
|
"id": "desk-freeze-policy",
|
|
"area": "Congelar users (SSU nunca)",
|
|
"who": ["SU"],
|
|
"why": "Segregação comercial",
|
|
"groups": ["desk-auth"],
|
|
"action_ids": ["desk.auth.user.freeze"],
|
|
},
|
|
{
|
|
"id": "vm112-purge",
|
|
"area": "Purge domínio / dados cliente",
|
|
"who": ["SU", "CO"],
|
|
"why": "Irreversível — Spec 032",
|
|
"groups": ["vm112"],
|
|
"action_ids": ["vm112.domain.purge", "vm112.purge.job.recover"],
|
|
},
|
|
{
|
|
"id": "billing-validate",
|
|
"area": "Validar billing / faturação",
|
|
"who": ["SU", "CO", "FIN", "SAD"],
|
|
"why": "Segregação comercial vs financeira",
|
|
"groups": ["desk-ops"],
|
|
"action_ids": ["desk.billing.state.validate"],
|
|
},
|
|
{
|
|
"id": "foss-orders",
|
|
"area": "FOSS pedidos e clientes",
|
|
"who": ["SAD", "SSU", "PTR"],
|
|
"why": "Linha de frente comercial",
|
|
"groups": ["vm123-foss"],
|
|
"action_ids": ["vm123_foss.client.create", "vm123_foss.order.create"],
|
|
},
|
|
{
|
|
"id": "foss-void",
|
|
"area": "FOSS faturas / void",
|
|
"who": ["FIN", "SU"],
|
|
"why": "Risco fiscal",
|
|
"groups": ["vm123-foss"],
|
|
"action_ids": ["vm123_foss.invoice.void"],
|
|
},
|
|
{
|
|
"id": "openpanel-delete",
|
|
"area": "OpenPanel delete instância",
|
|
"who": ["SU", "CO"],
|
|
"why": "Downtime cliente — Roger 2026-06-29",
|
|
"groups": ["vm123-openpanel"],
|
|
"action_ids": ["vm123_openpanel.site.delete"],
|
|
},
|
|
{
|
|
"id": "openpanel-content",
|
|
"area": "OpenPanel conteúdo sites",
|
|
"who": ["CMS", "SEO", "MKT"],
|
|
"why": "Operação editorial",
|
|
"groups": ["vm123-openpanel"],
|
|
"action_ids": ["vm123_openpanel.site.create", "vm123_openpanel.ssl.manage"],
|
|
},
|
|
{
|
|
"id": "tickets-assist",
|
|
"area": "Tickets / assist",
|
|
"who": ["TEC", "CO", "SU"],
|
|
"why": "Menor privilégio",
|
|
"groups": ["desk-ops"],
|
|
"action_ids": ["desk.ticket.patch", "desk.assist.takeover"],
|
|
},
|
|
{
|
|
"id": "infra-deploy",
|
|
"area": "Infra / deploy",
|
|
"who": ["DVO", "DEV", "SU"],
|
|
"why": "Separação código vs infra",
|
|
"groups": ["desk-ops"],
|
|
"action_ids": ["desk.infra.deploy"],
|
|
},
|
|
{
|
|
"id": "agents-a7",
|
|
"area": "Agentes A7 remediação",
|
|
"who": ["AIO", "CO", "SU"],
|
|
"why": "Human-in-the-loop",
|
|
"groups": ["agents"],
|
|
"action_ids": ["desk.agent.runbook.approve"],
|
|
},
|
|
{
|
|
"id": "modules-toggle",
|
|
"area": "Módulos Desk ON/OFF",
|
|
"who": ["SU"],
|
|
"why": "Feature flags globais",
|
|
"groups": ["desk-auth"],
|
|
"action_ids": ["desk.auth.modules.toggle"],
|
|
},
|
|
{
|
|
"id": "console-ops",
|
|
"area": "Console (mesma matriz Desk)",
|
|
"who": ["SU", "CO", "TEC", "SOC"],
|
|
"why": "Handoff Desk → Console",
|
|
"groups": ["console"],
|
|
"action_ids": ["console.runbook.execute", "console.case.assign"],
|
|
},
|
|
{
|
|
"id": "rbac-custom",
|
|
"area": "RBAC custom (templates)",
|
|
"who": ["SU"],
|
|
"why": "Herda CO ou TEC only",
|
|
"groups": ["desk-auth"],
|
|
"action_ids": ["desk.auth.role.create"],
|
|
},
|
|
]
|
|
|
|
ROLE_CODES: dict[str, str] = {
|
|
"super_admin": "SU",
|
|
"ops_lead": "CO",
|
|
"technician": "TEC",
|
|
"noc": "NOC",
|
|
"sales_admin": "SAD",
|
|
"sales_support": "SSU",
|
|
"finance": "FIN",
|
|
"marketing": "MKT",
|
|
"seo": "SEO",
|
|
"developer": "DEV",
|
|
"devops": "DVO",
|
|
"security_analyst": "SOC",
|
|
"content_editor": "CMS",
|
|
"agentic_operator": "AIO",
|
|
"partner": "PTR",
|
|
"api_service": "SVC",
|
|
"agent_system": "AGT",
|
|
}
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _catalog_paths() -> list[Path]:
|
|
env = os.getenv("ACTION_CATALOG_PATH", "").strip()
|
|
paths: list[Path] = []
|
|
if env:
|
|
paths.append(Path(env))
|
|
paths.extend(
|
|
[
|
|
Path("/opt/ligbox-ops-platform/specs/039-ligbox-ops-authorization-catalog/contracts/action-catalog.yaml"),
|
|
Path(__file__).resolve().parent / "data" / "action-catalog.yaml",
|
|
]
|
|
)
|
|
return paths
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def load_base_catalog() -> dict[str, Any]:
|
|
for path in _catalog_paths():
|
|
if path.is_file():
|
|
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
if isinstance(raw, dict) and raw.get("actions"):
|
|
return raw
|
|
raise FileNotFoundError("action-catalog.yaml not found")
|
|
|
|
|
|
def init_schema(conn: sqlite3.Connection) -> None:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS rbac_action_overrides (
|
|
action_id TEXT NOT NULL,
|
|
role_id TEXT NOT NULL,
|
|
level TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
updated_by TEXT,
|
|
PRIMARY KEY (action_id, role_id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS rbac_action_audit (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
action_id TEXT NOT NULL,
|
|
role_id TEXT NOT NULL,
|
|
old_level TEXT,
|
|
new_level TEXT NOT NULL,
|
|
username TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def _load_overrides(conn: sqlite3.Connection) -> dict[tuple[str, str], str]:
|
|
rows = conn.execute("SELECT action_id, role_id, level FROM rbac_action_overrides").fetchall()
|
|
return {(r["action_id"], r["role_id"]): r["level"] for r in rows}
|
|
|
|
|
|
def effective_level(
|
|
action: dict[str, Any],
|
|
role_id: str,
|
|
overrides: dict[tuple[str, str], str],
|
|
) -> str:
|
|
key = (action["id"], role_id)
|
|
if key in overrides:
|
|
return overrides[key]
|
|
return (action.get("roles") or {}).get(role_id, "none")
|
|
|
|
|
|
def export_catalog(conn: sqlite3.Connection, *, edit_enabled: bool) -> dict[str, Any]:
|
|
base = load_base_catalog()
|
|
overrides = _load_overrides(conn)
|
|
roles_meta = base.get("roles") or {}
|
|
for rid, code in ROLE_CODES.items():
|
|
if rid not in roles_meta:
|
|
roles_meta[rid] = {"code": code, "label": rid.replace("_", " ").title()}
|
|
|
|
actions_out: list[dict[str, Any]] = []
|
|
for action in base.get("actions") or []:
|
|
aid = action.get("id")
|
|
if not aid:
|
|
continue
|
|
defaults = action.get("roles") or {}
|
|
effective = {role: effective_level(action, role, overrides) for role in roles_meta}
|
|
overridden = {
|
|
role
|
|
for role in roles_meta
|
|
if (aid, role) in overrides and overrides[(aid, role)] != defaults.get(role, "none")
|
|
}
|
|
actions_out.append(
|
|
{
|
|
"id": aid,
|
|
"group": action.get("group"),
|
|
"label": action.get("label"),
|
|
"api": action.get("api"),
|
|
"why": action.get("why"),
|
|
"gap": bool(action.get("gap")),
|
|
"defaults": defaults,
|
|
"effective": effective,
|
|
"overridden_roles": sorted(overridden),
|
|
}
|
|
)
|
|
|
|
groups = base.get("groups") or []
|
|
group_by_id = {g["id"]: g for g in groups if g.get("id")}
|
|
|
|
executive = []
|
|
for row in EXECUTIVE_ROWS:
|
|
executive.append({**row, "editable": edit_enabled})
|
|
|
|
recent_audit = conn.execute(
|
|
"""
|
|
SELECT action_id, role_id, old_level, new_level, username, created_at
|
|
FROM rbac_action_audit ORDER BY id DESC LIMIT 30
|
|
"""
|
|
).fetchall()
|
|
|
|
return {
|
|
"spec": "039",
|
|
"version": base.get("version", "1.0"),
|
|
"editable": edit_enabled,
|
|
"roles": roles_meta,
|
|
"role_codes": ROLE_CODES,
|
|
"access_levels": base.get("access_levels") or {},
|
|
"groups": groups,
|
|
"group_by_id": group_by_id,
|
|
"executive_map": executive,
|
|
"actions": actions_out,
|
|
"stats": {"action_count": len(actions_out), "override_count": len(overrides)},
|
|
"recent_audit": [dict(r) for r in recent_audit],
|
|
}
|
|
|
|
|
|
def set_action_override(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
action_id: str,
|
|
role_id: str,
|
|
level: str,
|
|
username: str,
|
|
reset: bool = False,
|
|
) -> dict[str, Any]:
|
|
if level not in VALID_LEVELS:
|
|
raise ValueError(f"invalid level: {level}")
|
|
|
|
base = load_base_catalog()
|
|
roles_meta = base.get("roles") or {}
|
|
if role_id not in roles_meta and role_id not in ROLE_CODES:
|
|
raise ValueError(f"unknown role: {role_id}")
|
|
|
|
action = next((a for a in base.get("actions") or [] if a.get("id") == action_id), None)
|
|
if not action:
|
|
raise ValueError(f"unknown action: {action_id}")
|
|
|
|
defaults = action.get("roles") or {}
|
|
default_level = defaults.get(role_id, "none")
|
|
overrides = _load_overrides(conn)
|
|
old = overrides.get((action_id, role_id), default_level)
|
|
|
|
if reset or level == default_level:
|
|
conn.execute(
|
|
"DELETE FROM rbac_action_overrides WHERE action_id = ? AND role_id = ?",
|
|
(action_id, role_id),
|
|
)
|
|
new_level = default_level
|
|
else:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO rbac_action_overrides (action_id, role_id, level, updated_at, updated_by)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(action_id, role_id) DO UPDATE SET
|
|
level = excluded.level,
|
|
updated_at = excluded.updated_at,
|
|
updated_by = excluded.updated_by
|
|
""",
|
|
(action_id, role_id, level, _now(), username),
|
|
)
|
|
new_level = level
|
|
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO rbac_action_audit (action_id, role_id, old_level, new_level, username, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(action_id, role_id, old, new_level, username, _now()),
|
|
)
|
|
conn.commit()
|
|
|
|
return {
|
|
"action_id": action_id,
|
|
"role_id": role_id,
|
|
"level": new_level,
|
|
"default_level": default_level,
|
|
"is_override": new_level != default_level,
|
|
}
|
|
|
|
|
|
def can_action(conn: sqlite3.Connection, role_id: str, action_id: str) -> bool:
|
|
"""True se nível efectivo != none."""
|
|
base = load_base_catalog()
|
|
action = next((a for a in base.get("actions") or [] if a.get("id") == action_id), None)
|
|
if not action:
|
|
return False
|
|
overrides = _load_overrides(conn)
|
|
return effective_level(action, role_id, overrides) != "none"
|