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>
336 lines
11 KiB
Python
336 lines
11 KiB
Python
"""Governance API — user wizard, audit. Spec 040 · DS-API-002."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import secrets
|
|
import string
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app import auth, mail_notify
|
|
from app.desk_governance_store import (
|
|
ACCESS_LEVELS,
|
|
GOVERNANCE_MODULES,
|
|
ROLE_GROUPS,
|
|
default_module_permissions,
|
|
ensure_user_meta,
|
|
get_user_meta,
|
|
group_for_role,
|
|
init_governance_schema,
|
|
list_audit,
|
|
log_audit,
|
|
upsert_user_meta,
|
|
)
|
|
from app.permissions import ROLES, can_manage_users
|
|
|
|
router = APIRouter(prefix="/api/v1/governance", tags=["governance"])
|
|
|
|
|
|
class ModulePermissions(BaseModel):
|
|
desk: str = "none"
|
|
openpanel: str = "none"
|
|
billing: str = "none"
|
|
api: str = "none"
|
|
security: str = "none"
|
|
ai_agents: str = "none"
|
|
|
|
|
|
class UserWizardRequest(BaseModel):
|
|
display_name: str = Field(min_length=1, max_length=120)
|
|
email: str = Field(min_length=3, max_length=320)
|
|
phone: str | None = Field(default=None, max_length=32)
|
|
password: str = Field(min_length=6)
|
|
account_status: str = Field(default="active", pattern="^(active|frozen|pending|invited)$")
|
|
force_password_change: bool = False
|
|
mfa_required: bool = False
|
|
notifications_enabled: bool = True
|
|
api_access: bool = False
|
|
role: str
|
|
main_group: str | None = None
|
|
secondary_groups: list[str] = Field(default_factory=list)
|
|
module_permissions: ModulePermissions | None = None
|
|
notes: str | None = Field(default=None, max_length=200)
|
|
send_invite_email: bool = True
|
|
activate_account: bool = True
|
|
|
|
|
|
def _db():
|
|
conn = auth.db()
|
|
try:
|
|
init_governance_schema(conn)
|
|
yield conn
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _validate_levels(perms: dict[str, str]) -> None:
|
|
for mod, lv in perms.items():
|
|
if mod not in GOVERNANCE_MODULES:
|
|
raise HTTPException(400, f"módulo inválido: {mod}")
|
|
if lv not in ACCESS_LEVELS:
|
|
raise HTTPException(400, f"nível inválido: {lv}")
|
|
|
|
|
|
@router.get("/modules")
|
|
def governance_modules(user: auth.DeskUser = Depends(auth.get_current_user)):
|
|
if not can_manage_users(user.role):
|
|
raise HTTPException(403, "insufficient permissions")
|
|
return {
|
|
"modules": list(GOVERNANCE_MODULES),
|
|
"levels": list(ACCESS_LEVELS),
|
|
"groups": sorted(set(ROLE_GROUPS.values())),
|
|
}
|
|
|
|
|
|
@router.get("/audit")
|
|
def governance_audit(
|
|
target_type: str | None = Query(default=None),
|
|
target_id: str | None = Query(default=None),
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
|
conn=Depends(_db),
|
|
):
|
|
if not can_manage_users(user.role):
|
|
raise HTTPException(403, "insufficient permissions")
|
|
return {"events": list_audit(conn, target_type=target_type, target_id=target_id, limit=limit)}
|
|
|
|
|
|
@router.get("/users/{username}/meta")
|
|
def user_meta(
|
|
username: str,
|
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
|
conn=Depends(_db),
|
|
):
|
|
if not can_manage_users(user.role):
|
|
raise HTTPException(403, "insufficient permissions")
|
|
meta = get_user_meta(conn, username)
|
|
if not meta:
|
|
row = auth._user_row(username)
|
|
if not row:
|
|
raise HTTPException(404, "user not found")
|
|
meta = ensure_user_meta(conn, username, row["role"])
|
|
conn.commit()
|
|
return {"meta": meta}
|
|
|
|
|
|
@router.get("/users/stats")
|
|
def users_stats(
|
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
|
conn=Depends(_db),
|
|
):
|
|
if not can_manage_users(user.role):
|
|
raise HTTPException(403, "insufficient permissions")
|
|
rows = conn.execute(
|
|
"SELECT username, role, active FROM desk_users"
|
|
).fetchall()
|
|
total = len(rows)
|
|
active = sum(1 for r in rows if r["active"])
|
|
frozen = total - active
|
|
super_admin = sum(1 for r in rows if r["role"] == "super_admin")
|
|
return {
|
|
"total": total,
|
|
"active": active,
|
|
"frozen": frozen,
|
|
"super_admin": super_admin,
|
|
}
|
|
|
|
|
|
@router.post("/users/wizard")
|
|
def create_user_wizard(
|
|
body: UserWizardRequest,
|
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
|
conn=Depends(_db),
|
|
):
|
|
if not can_manage_users(user.role):
|
|
raise HTTPException(403, "insufficient permissions")
|
|
email = body.email.strip().lower()
|
|
if "@" not in email:
|
|
raise HTTPException(400, "email inválido")
|
|
if body.role not in ROLES:
|
|
raise HTTPException(400, "invalid role")
|
|
if body.role == "super_admin" and user.role != "super_admin":
|
|
raise HTTPException(403, "apenas Super Admin pode criar Super Admin")
|
|
|
|
perms = (
|
|
body.module_permissions.model_dump()
|
|
if body.module_permissions
|
|
else default_module_permissions(body.role)
|
|
)
|
|
_validate_levels(perms)
|
|
|
|
main_group = body.main_group or group_for_role(body.role)
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
active = body.activate_account and body.account_status == "active"
|
|
invite_token = secrets.token_urlsafe(24)
|
|
|
|
exists = conn.execute(
|
|
"SELECT 1 FROM desk_users WHERE username = ? OR email = ?",
|
|
(email, email),
|
|
).fetchone()
|
|
if exists:
|
|
raise HTTPException(409, "utilizador já existe")
|
|
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO desk_users
|
|
(username, password_hash, role, display_name, email, phone,
|
|
mfa_enabled, totp_secret, totp_enabled, active, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?, ?)
|
|
""",
|
|
(
|
|
email,
|
|
auth.hash_password(body.password),
|
|
body.role,
|
|
body.display_name.strip(),
|
|
email,
|
|
body.phone,
|
|
1 if body.mfa_required else 0,
|
|
1 if active else 0,
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
|
|
internal_id = f"LB-{secrets.token_hex(4).upper()}"
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO desk_user_meta
|
|
(username, internal_id, main_group, secondary_groups_json, account_status,
|
|
force_password_change, api_access, notifications_enabled, module_permissions_json,
|
|
invite_token, notes, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
email,
|
|
internal_id,
|
|
main_group,
|
|
json.dumps(body.secondary_groups),
|
|
body.account_status,
|
|
1 if body.force_password_change else 0,
|
|
1 if body.api_access else 0,
|
|
1 if body.notifications_enabled else 0,
|
|
json.dumps(perms),
|
|
invite_token,
|
|
body.notes,
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
|
|
actor_label = user.display_name or user.username
|
|
audit = log_audit(
|
|
conn,
|
|
actor_username=user.username,
|
|
actor_role=user.role,
|
|
action="user.created",
|
|
target_type="user",
|
|
target_id=email,
|
|
summary=f"Created by {actor_label}",
|
|
payload={"role": body.role, "internal_id": internal_id},
|
|
)
|
|
conn.commit()
|
|
|
|
invite_link = f"https://desk.ligbox.com.br/register.html?invite={invite_token}"
|
|
invite_email_sent = False
|
|
if body.send_invite_email:
|
|
try:
|
|
invite_email_sent = mail_notify.send_email(
|
|
email,
|
|
"Convite Ligbox Ops Desk",
|
|
f"Olá {body.display_name},\n\nFoi criada a sua conta.\nDefina a sua senha: {invite_link}\n",
|
|
)
|
|
except Exception:
|
|
invite_email_sent = False
|
|
|
|
row = conn.execute(
|
|
"""
|
|
SELECT u.username, u.role, u.display_name, u.active, u.last_login_at,
|
|
u.created_at, u.updated_at, u.email, u.phone, u.mfa_enabled, u.totp_enabled
|
|
FROM desk_users u WHERE u.username = ?
|
|
""",
|
|
(email,),
|
|
).fetchone()
|
|
public = auth.user_public_dict(row)
|
|
meta = get_user_meta(conn, email)
|
|
|
|
return {
|
|
"user": public,
|
|
"meta": meta,
|
|
"audit": audit,
|
|
"internal_id": internal_id,
|
|
"invite_link": invite_link,
|
|
"invite_email_sent": invite_email_sent if body.send_invite_email else None,
|
|
"message": "Utilizador criado",
|
|
}
|
|
|
|
|
|
@router.post("/users/{username}/freeze")
|
|
def freeze_user(
|
|
username: str,
|
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
|
conn=Depends(_db),
|
|
):
|
|
if not can_manage_users(user.role):
|
|
raise HTTPException(403, "insufficient permissions")
|
|
target = username.strip().lower() if username.lower() != "root" else "root"
|
|
if target == "root":
|
|
raise HTTPException(400, "não é possível congelar root")
|
|
row = auth._user_row(target)
|
|
if not row:
|
|
raise HTTPException(404, "user not found")
|
|
new_active = not bool(row["active"])
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
conn.execute(
|
|
"UPDATE desk_users SET active = ?, updated_at = ? WHERE username = ?",
|
|
(1 if new_active else 0, now, target),
|
|
)
|
|
meta = ensure_user_meta(conn, target, row["role"])
|
|
upsert_user_meta(conn, target, account_status="active" if new_active else "frozen")
|
|
verb = "Activated" if new_active else "Frozen"
|
|
actor_label = user.display_name or user.username
|
|
audit = log_audit(
|
|
conn,
|
|
actor_username=user.username,
|
|
actor_role=user.role,
|
|
action="user.frozen" if not new_active else "user.activated",
|
|
target_type="user",
|
|
target_id=target,
|
|
summary=f"{verb} by {actor_label}",
|
|
)
|
|
conn.commit()
|
|
return {"user": auth.user_public_dict(auth._user_row(target)), "audit": audit}
|
|
|
|
|
|
@router.post("/users/{username}/reset-password")
|
|
def admin_reset_password(
|
|
username: str,
|
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
|
conn=Depends(_db),
|
|
):
|
|
if not can_manage_users(user.role):
|
|
raise HTTPException(403, "insufficient permissions")
|
|
target = username.strip().lower() if username.lower() != "root" else "root"
|
|
row = auth._user_row(target)
|
|
if not row:
|
|
raise HTTPException(404, "user not found")
|
|
pwd = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12))
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
conn.execute(
|
|
"UPDATE desk_users SET password_hash = ?, updated_at = ? WHERE username = ?",
|
|
(auth.hash_password(pwd), now, target),
|
|
)
|
|
actor_label = user.display_name or user.username
|
|
audit = log_audit(
|
|
conn,
|
|
actor_username=user.username,
|
|
actor_role=user.role,
|
|
action="user.password.reset",
|
|
target_type="user",
|
|
target_id=target,
|
|
summary=f"Password reset by {actor_label}",
|
|
)
|
|
conn.commit()
|
|
return {"ok": True, "generated_password": pwd, "audit": audit}
|