refactor: decompose monolith — app.py 10,600→143 lines, 30+ modules

Fase 0 complete: extract all business logic, auth, database, and 176
endpoints from monolithic app.py into dedicated modules.

Structure:
- app.py: FastAPI hub (CORS, routers, startup/shutdown)
- models.py: 13 Pydantic request models
- utils.py: shared utilities (embed status, upload validation, process registries)
- config.py: constants, env vars, model catalogs
- auth/: crypto, jwt_auth, oidc, rate_limit
- database/: db(), init_db(), schema DDL
- services/: genai, compliance, chat, terraform, embeddings, cis_reports, mcp
- routes/: 13 APIRouter modules (auth, users, oci_config, oci_explorer,
  genai, mcp, adb, embeddings, reports, chat, terraform, settings, cis_engine)

Also: README updated with OCIR auth instructions for manual docker run.

86 tests passing.
This commit is contained in:
nogueiraguh
2026-04-06 15:20:10 -03:00
parent 426bde563e
commit 1135e9d6a9
36 changed files with 11179 additions and 10514 deletions

0
backend/auth/__init__.py Normal file
View File

91
backend/auth/crypto.py Normal file
View File

@@ -0,0 +1,91 @@
"""
Cryptographic helpers — password hashing, Fernet encryption, TOTP, JWT tokens.
"""
import base64
import hashlib
import hmac
import secrets
import struct
import time
from datetime import datetime, timedelta
import jwt as pyjwt
from cryptography.fernet import Fernet as _Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC as _PBKDF2HMAC
from cryptography.hazmat.primitives import hashes as _hashes
from config import APP_SECRET, JWT_ALG, JWT_EXP_H
# ── Password Hashing (PBKDF2-SHA256, 100k iterations) ───────────────────────
def _hash_pw(pw):
salt = secrets.token_hex(16)
h = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt.encode(), 100_000)
return f"{salt}:{h.hex()}"
def _verify_pw(pw, stored):
salt, h = stored.split(":")
return hmac.compare_digest(
hashlib.pbkdf2_hmac("sha256", pw.encode(), salt.encode(), 100_000).hex(), h
)
# ── TOTP (RFC 6238, Google Authenticator compatible) ─────────────────────────
def _totp_secret():
return base64.b32encode(secrets.token_bytes(20)).decode()
def _totp_verify(secret, code, window=1):
key = base64.b32decode(secret)
now = int(time.time()) // 30
for off in range(-window, window + 1):
msg = struct.pack(">Q", now + off)
h = hmac.new(key, msg, hashlib.sha1).digest()
o = h[-1] & 0x0F
c = str((struct.unpack(">I", h[o:o+4])[0] & 0x7FFFFFFF) % 1_000_000).zfill(6)
if hmac.compare_digest(c, code):
return True
return False
def _totp_uri(secret, user):
return f"otpauth://totp/AI-Agent:{user}?secret={secret}&issuer=AI-Agent"
# ── JWT Token ────────────────────────────────────────────────────────────────
def _make_token(uid, role, sid):
return pyjwt.encode(
{"sub": uid, "role": role, "sid": sid,
"exp": datetime.utcnow() + timedelta(hours=JWT_EXP_H),
"iat": datetime.utcnow()},
APP_SECRET, algorithm=JWT_ALG
)
# ── Fernet Encryption (AES-128-CBC + HMAC-SHA256) ───────────────────────────
_FERNET_PREFIX = "fernet:"
def _derive_fernet_key(secret: str) -> bytes:
kdf = _PBKDF2HMAC(
algorithm=_hashes.SHA256(), length=32,
salt=b"oci-cis-agent-fernet-v1", iterations=100_000
)
return base64.urlsafe_b64encode(kdf.derive(secret.encode()))
_fernet = _Fernet(_derive_fernet_key(APP_SECRET))
def _enc(v):
if not v: return v
return _FERNET_PREFIX + _fernet.encrypt(v.encode()).decode()
def _dec(v):
if not v: return v
if v.startswith(_FERNET_PREFIX):
return _fernet.decrypt(v[len(_FERNET_PREFIX):].encode()).decode()
return base64.b64decode(v.encode()).decode()
def _mask(v, show=6):
if not v or len(v) <= show:
return "\u2022" * 8
return "\u2022" * min(len(v) - show, 20) + v[-show:]
def _safe_dec(v):
if not v: return v
try:
return _dec(v)
except Exception:
return v

105
backend/auth/jwt_auth.py Normal file
View File

@@ -0,0 +1,105 @@
"""JWT authentication — current_user dependency, require roles, audit/log helpers."""
import uuid
import jwt as pyjwt
from fastapi import HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from config import APP_SECRET, JWT_ALG
from database import db
security = HTTPBearer()
async def current_user(cred: HTTPAuthorizationCredentials = Depends(security)):
try:
p = pyjwt.decode(cred.credentials, APP_SECRET, algorithms=[JWT_ALG])
except pyjwt.ExpiredSignatureError:
raise HTTPException(401, "Token expirado")
except pyjwt.InvalidTokenError:
raise HTTPException(401, "Token inválido")
with db() as c:
u = c.execute("SELECT * FROM users WHERE id=? AND is_active=1", (p["sub"],)).fetchone()
s = c.execute("SELECT * FROM sessions WHERE id=? AND is_active=1", (p["sid"],)).fetchone()
if not u or not s:
raise HTTPException(401, "Sessão inválida")
return dict(u)
def _user_from_token(token: str):
try:
p = pyjwt.decode(token, APP_SECRET, algorithms=[JWT_ALG])
except Exception:
raise HTTPException(401, "Token inválido")
with db() as c:
u = c.execute("SELECT * FROM users WHERE id=? AND is_active=1", (p["sub"],)).fetchone()
s = c.execute("SELECT * FROM sessions WHERE id=? AND is_active=1", (p["sid"],)).fetchone()
if not u or not s:
raise HTTPException(401, "Sessão inválida")
return dict(u)
def require(*roles):
async def dep(u=Depends(current_user)):
if u["role"] not in roles:
raise HTTPException(403, f"Requer role: {', '.join(roles)}")
return u
return dep
def _audit(uid, uname, action, resource=None, details=None, ip=None):
with db() as c:
c.execute(
"INSERT INTO audit_log (id,user_id,username,action,resource,details,ip) VALUES (?,?,?,?,?,?,?)",
(str(uuid.uuid4()), uid, uname, action, resource, details, ip))
def _config_log(config_type, config_id, config_name, severity, action, message, uid=None, uname=None):
with db() as c:
c.execute(
"INSERT INTO config_logs (id,config_type,config_id,config_name,severity,action,message,user_id,username) VALUES (?,?,?,?,?,?,?,?,?)",
(str(uuid.uuid4()), config_type, config_id, config_name or "", severity, action, str(message)[:2000], uid, uname))
def _chat_log(sid, mid, uid, severity, source, action, message):
with db() as c:
c.execute(
"INSERT INTO chat_logs (id,session_id,message_id,user_id,severity,source,action,message) VALUES (?,?,?,?,?,?,?,?)",
(str(uuid.uuid4()), sid, mid, uid, severity, source, action, str(message)[:2000]))
# ── Access Control ───────────────────────────────────────────────────────────
_CONFIG_TABLES = {
"oci": "oci_configs",
"genai": "genai_configs",
"adb": "adb_vector_configs",
"mcp": "mcp_servers",
}
def _verify_config_access(config_type: str, config_id: str, user: dict, *, require_owner: bool = False) -> dict:
table = _CONFIG_TABLES.get(config_type)
if not table:
raise HTTPException(400, f"Tipo de config inválido: {config_type}")
with db() as c:
row = c.execute(f"SELECT * FROM {table} WHERE id=?", (config_id,)).fetchone()
if not row:
raise HTTPException(404)
row = dict(row)
if user["role"] == "admin":
return row
is_owner = row.get("user_id") == user["id"]
is_global = row.get("is_global", 0)
if not is_owner and not is_global:
raise HTTPException(404)
if require_owner and not is_owner:
raise HTTPException(403, "Apenas o dono pode modificar este recurso")
return row
def _verify_report_access(report_id: str, user: dict) -> dict:
with db() as c:
row = c.execute("SELECT * FROM reports WHERE id=? AND user_id=?", (report_id, user["id"])).fetchone()
if not row:
raise HTTPException(404)
return dict(row)

93
backend/auth/oidc.py Normal file
View File

@@ -0,0 +1,93 @@
"""OIDC helpers — discovery, JWKS, state management, token validation."""
import json
import time
import threading
import jwt as pyjwt
from fastapi import HTTPException
from database import db
from auth.crypto import _safe_dec
# ── Caches ───────────────────────────────────────────────────────────────────
_oidc_discovery_cache: dict = {}
_oidc_jwks_cache: dict = {}
_OIDC_CACHE_TTL = 3600
_oidc_pending: dict = {}
_oidc_pending_lock = threading.Lock()
_OIDC_STATE_TTL = 300
def _get_oidc_config() -> dict:
keys = ("auth_mode", "oidc_issuer", "oidc_client_id", "oidc_client_secret",
"oidc_redirect_uri", "oidc_group_admin", "oidc_group_user", "oidc_group_viewer")
cfg = {}
with db() as c:
for k in keys:
row = c.execute("SELECT value FROM app_settings WHERE key=?", (k,)).fetchone()
cfg[k] = row["value"] if row else ""
if cfg.get("oidc_client_secret"):
cfg["oidc_client_secret"] = _safe_dec(cfg["oidc_client_secret"])
return cfg
def _oidc_discover(issuer: str) -> dict:
import requests as req
now = time.time()
cached = _oidc_discovery_cache.get(issuer)
if cached and now - cached[1] < _OIDC_CACHE_TTL:
return cached[0]
url = issuer.rstrip("/") + "/.well-known/openid-configuration"
resp = req.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
_oidc_discovery_cache[issuer] = (data, now)
return data
def _oidc_get_jwks(jwks_uri: str) -> list:
import requests as req
now = time.time()
cached = _oidc_jwks_cache.get(jwks_uri)
if cached and now - cached[1] < _OIDC_CACHE_TTL:
return cached[0]
resp = req.get(jwks_uri, timeout=10)
resp.raise_for_status()
keys = resp.json().get("keys", [])
_oidc_jwks_cache[jwks_uri] = (keys, now)
return keys
def _oidc_store_state(state: str, nonce: str):
with _oidc_pending_lock:
now = time.time()
expired = [k for k, v in _oidc_pending.items() if now - v["created"] > _OIDC_STATE_TTL]
for k in expired:
del _oidc_pending[k]
_oidc_pending[state] = {"nonce": nonce, "created": now}
def _oidc_pop_state(state: str) -> dict | None:
with _oidc_pending_lock:
return _oidc_pending.pop(state, None)
def _validate_id_token(id_token: str, issuer: str, client_id: str, nonce: str) -> dict:
from jwt.algorithms import RSAAlgorithm
disco = _oidc_discover(issuer)
jwks = _oidc_get_jwks(disco["jwks_uri"])
header = pyjwt.get_unverified_header(id_token)
kid = header.get("kid")
key_data = None
for k in jwks:
if k.get("kid") == kid:
key_data = k
break
if not key_data:
raise HTTPException(401, "OIDC: chave não encontrada no JWKS")
public_key = RSAAlgorithm.from_jwk(json.dumps(key_data))
claims = pyjwt.decode(id_token, public_key, algorithms=["RS256"], audience=client_id, issuer=issuer)
if claims.get("nonce") != nonce:
raise HTTPException(401, "OIDC: nonce inválido")
return claims

View File

@@ -0,0 +1,20 @@
"""Rate limiting for login brute-force protection."""
import time
import threading
from fastapi import HTTPException
_login_attempts: dict = {}
_login_attempts_lock = threading.Lock()
_LOGIN_WINDOW = 300 # 5 minutes
_LOGIN_MAX = 10 # max attempts per window
def _check_rate_limit(ip: str):
now = time.time()
with _login_attempts_lock:
attempts = _login_attempts.get(ip, [])
attempts = [t for t in attempts if now - t < _LOGIN_WINDOW]
if len(attempts) >= _LOGIN_MAX:
raise HTTPException(429, "Muitas tentativas de login. Aguarde 5 minutos.")
attempts.append(now)
_login_attempts[ip] = attempts