feat: comprehensive security, performance, UX and deployment audit
Security:
- CORS restricted to explicit methods/headers, configurable via CORS_ORIGINS env
- Auth added to /reports/{rid}/html and /compliance-report endpoints
- Ownership check on report downloads
- Rate limiting on login (10 attempts/5min per IP with threading.Lock)
- Non-root container user (agent via gosu entrypoint)
- Nginx security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy)
- .gitignore and .dockerignore for secret leak prevention
- .env.example with documentation
Performance:
- 16 SQLite indexes on foreign keys and frequently queried columns
- Pagination on chat messages (100), reports (50), audit log (100)
- subprocess.run wrapped in run_in_executor (3 async handlers)
- asyncio.wait_for timeouts on GenAI calls (300s) and Chromium PDF (120s)
- Thread pool reduced to 10 workers
- Code splitting with React.lazy (bundle: 1.3MB -> ~550KB initial)
- React.memo on TreeItem (recursive compartment tree)
Error handling:
- 13 bare except clauses replaced with logged Exception handlers
- Graceful shutdown handler (terminates subprocesses + executor)
- File upload validation (50MB max, extension whitelist per endpoint)
- Health check expanded (version, uptime, db_ok)
- Cache-Control on /api/genai/models (1h)
- Auto-cleanup audit_log > 30 days
Dead code removed:
- DownloadsPage.tsx, StubPage.tsx, MfaPage.tsx (moved to UsersPage)
- Legacy frontend/ directory
- 19 unused i18n keys, dist/ removed from git tracking
UX & i18n:
- 8 alert() calls replaced with styled error states (TerraformPage)
- 50+ hardcoded Portuguese strings localized to i18n (pt/en) across 11 files
- aria-label on all icon-only buttons (Chat, Explorer, Sidebar)
- focus-visible CSS for keyboard navigation
- Responsive grid fix (360px -> 280px for mobile)
- aria-hidden on decorative SVGs
Deployment:
- Docker resource limits (backend 4G, frontend 512M)
- Log rotation (json-file, 10MB x 3)
- Terraform version parameterized via ARG
This commit is contained in:
10
backend/.dockerignore
Normal file
10
backend/.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.gitignore
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
.env*
|
||||
*.db
|
||||
*.sqlite
|
||||
.DS_Store
|
||||
.idea
|
||||
@@ -2,12 +2,13 @@ FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system deps + Terraform CLI
|
||||
# Install system deps + Chromium + Terraform CLI
|
||||
ARG TERRAFORM_VERSION=1.7.5
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl gcc libffi-dev unzip \
|
||||
chromium && \
|
||||
ARCH=$(dpkg --print-architecture) && \
|
||||
curl -fsSL "https://releases.hashicorp.com/terraform/1.7.5/terraform_1.7.5_linux_${ARCH}.zip" -o /tmp/tf.zip && \
|
||||
curl -fsSL "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_${ARCH}.zip" -o /tmp/tf.zip && \
|
||||
unzip /tmp/tf.zip -d /usr/local/bin/ && rm /tmp/tf.zip && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -22,12 +23,21 @@ COPY cis_reports.py .
|
||||
COPY mcp_cis_server.py .
|
||||
COPY gen_tf_reference.py .
|
||||
|
||||
# Data volume
|
||||
RUN mkdir -p /data
|
||||
# Non-root user setup
|
||||
RUN groupadd -r agent && useradd -r -g agent -d /app -s /bin/bash agent && \
|
||||
mkdir -p /data && chown -R agent:agent /app /data
|
||||
|
||||
# Generate OCI Terraform resource reference at build time
|
||||
RUN python gen_tf_reference.py || echo "WARN: tf reference generation skipped (terraform init may need network)"
|
||||
|
||||
# Entrypoint: fix /data ownership then exec as agent user
|
||||
RUN printf '#!/bin/bash\nchown -R agent:agent /data 2>/dev/null || true\nexec gosu agent "$@"\n' > /entrypoint.sh && \
|
||||
chmod +x /entrypoint.sh && \
|
||||
apt-get update && apt-get install -y --no-install-recommends gosu && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
VOLUME /data
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "8"]
|
||||
|
||||
278
backend/app.py
278
backend/app.py
@@ -5,7 +5,7 @@ OCI Account Explorer, MCP Server registry with VectorDB tool integration,
|
||||
Autonomous DB vector storage, CIS reports, chat agent, audit log.
|
||||
"""
|
||||
import os, json, uuid, hashlib, hmac, time, base64, struct, secrets, subprocess
|
||||
import shutil, asyncio, sqlite3, logging, re, concurrent.futures
|
||||
import shutil, asyncio, sqlite3, logging, re, concurrent.futures, threading
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
@@ -33,6 +33,8 @@ REPORTS = DATA / "reports"
|
||||
MCP_DIR = DATA / "mcp_servers"
|
||||
WALLET_DIR = DATA / "wallets"
|
||||
VERSION = "1.1"
|
||||
_START_TIME = time.time()
|
||||
_MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50MB global limit for file uploads
|
||||
|
||||
for d in [DATA, OCI_DIR, REPORTS, MCP_DIR, WALLET_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
@@ -41,7 +43,7 @@ _running_reports: dict[str, asyncio.subprocess.Process] = {} # rid → subproce
|
||||
_running_terraform: dict[str, asyncio.subprocess.Process] = {} # wid → subprocess
|
||||
TERRAFORM_DIR = DATA / "terraform"
|
||||
TERRAFORM_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_chat_executor = concurrent.futures.ThreadPoolExecutor(max_workers=16, thread_name_prefix="chat")
|
||||
_chat_executor = concurrent.futures.ThreadPoolExecutor(max_workers=10, thread_name_prefix="chat")
|
||||
|
||||
# ── Chat Memory Compaction Settings ──
|
||||
COMPACT_TOKEN_THRESHOLD = 6000 # estimated tokens before triggering compaction
|
||||
@@ -52,9 +54,26 @@ COMPACT_MIN_MESSAGES = 8
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger("agent")
|
||||
|
||||
|
||||
async def _validate_upload(file: "UploadFile", allowed_exts: set[str] | None = None, max_bytes: int = _MAX_UPLOAD_BYTES):
|
||||
"""Validate uploaded file size and extension. Seeks back to 0 after size check."""
|
||||
data = await file.read()
|
||||
if len(data) > max_bytes:
|
||||
raise HTTPException(400, f"Arquivo excede o limite de {max_bytes // (1024*1024)}MB")
|
||||
if allowed_exts and file.filename:
|
||||
ext = Path(file.filename).suffix.lower()
|
||||
if ext not in allowed_exts:
|
||||
raise HTTPException(400, f"Extensão '{ext}' não permitida. Aceitas: {', '.join(sorted(allowed_exts))}")
|
||||
return data
|
||||
|
||||
|
||||
app = FastAPI(title="AI Agent - Infrastructure & Security Engineer", version=VERSION)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
|
||||
allow_methods=["*"], allow_headers=["*"])
|
||||
_CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "").split(",") if os.environ.get("CORS_ORIGINS") else []
|
||||
app.add_middleware(CORSMiddleware,
|
||||
allow_origins=_CORS_ORIGINS or ["http://localhost:8080", "http://127.0.0.1:8080"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization"])
|
||||
security = HTTPBearer()
|
||||
|
||||
# ── OCI GenAI Models Catalog ──────────────────────────────────────────────────
|
||||
@@ -363,6 +382,27 @@ def init_db():
|
||||
""")
|
||||
c.execute("DELETE FROM config_logs WHERE created_at < datetime('now', '-30 days')")
|
||||
c.execute("DELETE FROM chat_logs WHERE created_at < datetime('now', '-30 days')")
|
||||
c.execute("DELETE FROM audit_log WHERE created_at < datetime('now', '-30 days')")
|
||||
# ── Indexes for performance ──
|
||||
for idx in [
|
||||
"CREATE INDEX IF NOT EXISTS idx_chat_msg_session ON chat_messages(session_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_chat_msg_user ON chat_messages(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_chat_msg_status ON chat_messages(status)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_chat_sess_user ON chat_sessions(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_reports_user ON reports(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_report_files_report ON report_files(report_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_oci_cfg_user ON oci_configs(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_genai_cfg_user ON genai_configs(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_mcp_srv_user ON mcp_servers(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_adb_cfg_user ON adb_vector_configs(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_adb_tables_cfg ON adb_vector_tables(adb_config_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_tf_ws_user ON terraform_workspaces(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_tf_ws_session ON terraform_workspaces(session_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id)",
|
||||
]:
|
||||
c.execute(idx)
|
||||
# ── Migrations ──
|
||||
for col in ["system_prompt TEXT DEFAULT ''", "reasoning_effort TEXT"]:
|
||||
try:
|
||||
@@ -401,8 +441,8 @@ def init_db():
|
||||
if not c.execute("SELECT 1 FROM adb_vector_tables WHERE adb_config_id=? AND table_name=?", (cfg_row["id"], cfg_row["table_name"])).fetchone():
|
||||
c.execute("INSERT INTO adb_vector_tables (id, adb_config_id, table_name, description) VALUES (?,?,?,?)",
|
||||
(str(uuid.uuid4()), cfg_row["id"], cfg_row["table_name"], "Migrado automaticamente"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.warning(f"DB migration: adb_vector_tables backfill failed: {e}")
|
||||
# Backfill chat_sessions from existing chat_messages
|
||||
try:
|
||||
c.execute("""INSERT OR IGNORE INTO chat_sessions (id, user_id, agent_type, title, created_at, updated_at)
|
||||
@@ -414,8 +454,8 @@ def init_db():
|
||||
LEFT JOIN terraform_workspaces tw ON tw.session_id = cm.session_id
|
||||
WHERE cm.session_id NOT IN (SELECT id FROM chat_sessions)
|
||||
GROUP BY cm.session_id""")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.warning(f"DB migration: chat_sessions backfill failed: {e}")
|
||||
# Seed default system prompt if none exists for chat agent
|
||||
if not c.execute("SELECT 1 FROM system_prompts WHERE agent='chat'").fetchone():
|
||||
c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)",
|
||||
@@ -591,9 +631,28 @@ class MCPServerReq(BaseModel):
|
||||
module_path: Optional[str] = None; tools: Optional[List[dict]] = None
|
||||
linked_adb_id: Optional[str] = None
|
||||
|
||||
# ── Rate limiting (login brute-force protection) ─────────────────────────────
|
||||
_login_attempts: dict = {} # {ip: [timestamps]}
|
||||
_login_attempts_lock = threading.Lock()
|
||||
_LOGIN_WINDOW = 300 # 5 minutes
|
||||
_LOGIN_MAX = 10 # max attempts per window
|
||||
|
||||
def _check_rate_limit(ip: str):
|
||||
"""Raise 429 if too many login attempts from this IP."""
|
||||
import time
|
||||
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
|
||||
|
||||
# ── Auth endpoints ────────────────────────────────────────────────────────────
|
||||
@app.post("/api/auth/login")
|
||||
async def login(req: LoginReq, request: Request):
|
||||
_check_rate_limit(request.client.host if request.client else "unknown")
|
||||
with db() as c:
|
||||
u = c.execute("SELECT * FROM users WHERE username=? AND is_active=1", (req.username,)).fetchone()
|
||||
if not u or not _verify_pw(req.password, u["password_hash"]): raise HTTPException(401, "Credenciais inválidas")
|
||||
@@ -697,6 +756,13 @@ async def save_oci(
|
||||
):
|
||||
if auth_type not in ("api_key", "session_token"):
|
||||
raise HTTPException(400, "auth_type deve ser 'api_key' ou 'session_token'")
|
||||
_PEM_EXTS = {".pem", ".key"}
|
||||
if private_key and private_key.filename:
|
||||
await _validate_upload(private_key, _PEM_EXTS)
|
||||
private_key.file.seek(0)
|
||||
if public_key and public_key.filename:
|
||||
await _validate_upload(public_key, _PEM_EXTS)
|
||||
public_key.file.seek(0)
|
||||
cid = str(uuid.uuid4()); cdir = OCI_DIR / cid; cdir.mkdir(parents=True)
|
||||
kp = cdir / "oci_api_key.pem"
|
||||
|
||||
@@ -810,6 +876,13 @@ async def update_oci(
|
||||
private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None),
|
||||
u = Depends(require("admin","user"))
|
||||
):
|
||||
_PEM_EXTS = {".pem", ".key"}
|
||||
if private_key and private_key.filename:
|
||||
await _validate_upload(private_key, _PEM_EXTS)
|
||||
private_key.file.seek(0)
|
||||
if public_key and public_key.filename:
|
||||
await _validate_upload(public_key, _PEM_EXTS)
|
||||
public_key.file.seek(0)
|
||||
with db() as c:
|
||||
existing = c.execute("SELECT * FROM oci_configs WHERE id=?", (cid,)).fetchone()
|
||||
if not existing: raise HTTPException(404)
|
||||
@@ -874,8 +947,10 @@ async def test_oci(cid: str, u=Depends(require("admin","user"))):
|
||||
_config_log("oci", cid, cname, "error", "test", "Config não encontrada", u["id"], u["username"])
|
||||
return {"status":"error","message":"Config não encontrada"}
|
||||
try:
|
||||
r = subprocess.run(["oci","iam","region","list","--config-file",str(cp),"--output","json"],
|
||||
capture_output=True, text=True, timeout=30, stdin=subprocess.DEVNULL)
|
||||
loop = asyncio.get_event_loop()
|
||||
r = await loop.run_in_executor(None, partial(
|
||||
subprocess.run, ["oci","iam","region","list","--config-file",str(cp),"--output","json"],
|
||||
capture_output=True, text=True, timeout=30, stdin=subprocess.DEVNULL))
|
||||
if r.returncode == 0:
|
||||
_config_log("oci", cid, cname, "success", "test", "Conexão OK", u["id"], u["username"])
|
||||
return {"status":"success","message":"Conexão OK","data":json.loads(r.stdout)}
|
||||
@@ -1030,10 +1105,12 @@ async def explore_compartment_tree(cid: str, u=Depends(current_user)):
|
||||
cp = identity.get_compartment(user_comp).data
|
||||
tree.append({"id":cp.id, "name":cp.name, "parent_id":cp.compartment_id,
|
||||
"lifecycle_state":cp.lifecycle_state})
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to get compartment {user_comp}: {e}")
|
||||
tree.append({"id":user_comp, "name":"Meu Compartment", "parent_id":tenancy,
|
||||
"lifecycle_state":"ACTIVE"})
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to list compartments from tenancy: {e}")
|
||||
# If listing from tenancy fails (no permission), list from user's compartment
|
||||
if user_comp and user_comp != tenancy:
|
||||
try:
|
||||
@@ -1042,7 +1119,8 @@ async def explore_compartment_tree(cid: str, u=Depends(current_user)):
|
||||
sub = identity.list_compartments(user_comp, compartment_id_in_subtree=True).data
|
||||
tree += [{"id":s.id, "name":s.name, "parent_id":s.compartment_id,
|
||||
"lifecycle_state":s.lifecycle_state} for s in sub if s.lifecycle_state == "ACTIVE"]
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to list sub-compartments for {user_comp}: {e}")
|
||||
tree = [{"id":user_comp, "name":"Meu Compartment", "parent_id": None, "lifecycle_state":"ACTIVE"}]
|
||||
return tree
|
||||
except Exception as e:
|
||||
@@ -1851,7 +1929,10 @@ async def explore_network_firewall_policies(cid: str, compartment_id: str = Quer
|
||||
# ── OCI GenAI Config & Chat ───────────────────────────────────────────────────
|
||||
@app.get("/api/genai/models")
|
||||
async def list_genai_models(u=Depends(current_user)):
|
||||
return {"models": GENAI_MODELS, "regions": GENAI_REGIONS, "embedding_models": EMBEDDING_MODELS, "oci_regions": OCI_REGIONS}
|
||||
return JSONResponse(
|
||||
content={"models": GENAI_MODELS, "regions": GENAI_REGIONS, "embedding_models": EMBEDDING_MODELS, "oci_regions": OCI_REGIONS},
|
||||
headers={"Cache-Control": "max-age=3600"},
|
||||
)
|
||||
|
||||
@app.post("/api/genai/config")
|
||||
async def save_genai(req: GenAIConfigReq, u=Depends(require("admin","user"))):
|
||||
@@ -2281,7 +2362,7 @@ def _call_genai(gc: dict, message: str, history: list = None, tools: list = None
|
||||
args = {}
|
||||
if hasattr(tc, 'arguments') and tc.arguments:
|
||||
try: args = json.loads(tc.arguments) if isinstance(tc.arguments, str) else tc.arguments
|
||||
except: args = {}
|
||||
except Exception as e: log.warning(f"Failed to parse tool call arguments: {e}"); args = {}
|
||||
tool_calls.append({
|
||||
"id": tc.id if hasattr(tc, 'id') else tc.name,
|
||||
"name": tc.name if hasattr(tc, 'name') else "",
|
||||
@@ -2762,7 +2843,8 @@ def _get_table_embedding_dim(cfg: dict, table_name: str) -> int:
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return int(row[0]) if row and row[0] else 0
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to get embedding dimension for table '{table_name}': {e}")
|
||||
return 0
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -2818,7 +2900,8 @@ def _enrich_doc_content(doc: dict) -> str:
|
||||
if isinstance(meta, str) and meta:
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to parse document metadata JSON: {e}")
|
||||
meta = {}
|
||||
if isinstance(meta, dict):
|
||||
# If TEXT column is short/empty, use metadata.text instead
|
||||
@@ -2904,7 +2987,7 @@ async def list_mcp(u=Depends(current_user)):
|
||||
for k in ("args","env_vars","tools"):
|
||||
if d.get(k):
|
||||
try: d[k] = json.loads(d[k])
|
||||
except: pass
|
||||
except Exception as e: log.warning(f"Failed to parse MCP server field '{k}': {e}")
|
||||
res.append(d)
|
||||
return res
|
||||
|
||||
@@ -2940,6 +3023,8 @@ async def toggle_mcp(mid: str, u=Depends(require("admin","user"))):
|
||||
|
||||
@app.post("/api/mcp/servers/{mid}/upload")
|
||||
async def upload_mcp_file(mid: str, file: UploadFile = File(...), u=Depends(require("admin","user"))):
|
||||
await _validate_upload(file, {".py", ".js", ".ts", ".json", ".yaml", ".yml", ".toml", ".sh"})
|
||||
file.file.seek(0)
|
||||
sdir = MCP_DIR / mid; sdir.mkdir(parents=True, exist_ok=True)
|
||||
fp = sdir / file.filename
|
||||
fp.write_bytes(await file.read())
|
||||
@@ -2966,7 +3051,7 @@ async def discover_mcp_tools(mid: str, u=Depends(require("admin","user"))):
|
||||
existing = []
|
||||
if mcp_srv.get("tools"):
|
||||
try: existing = json.loads(mcp_srv["tools"]) if isinstance(mcp_srv["tools"], str) else mcp_srv["tools"]
|
||||
except: pass
|
||||
except Exception as e: log.warning(f"Failed to parse existing MCP tools JSON: {e}")
|
||||
existing_names = {t["name"] for t in existing if isinstance(t, dict)}
|
||||
for dt in discovered:
|
||||
if dt["name"] not in existing_names:
|
||||
@@ -3076,7 +3161,8 @@ def _get_active_mcp_tools(user_id: str) -> list[dict]:
|
||||
continue
|
||||
try:
|
||||
tools = json.loads(tools_raw) if isinstance(tools_raw, str) else tools_raw
|
||||
except:
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to parse tools JSON for MCP server '{srv.get('name', '?')}': {e}")
|
||||
continue
|
||||
for t in tools:
|
||||
if isinstance(t, dict) and t.get("name"):
|
||||
@@ -3087,6 +3173,8 @@ def _get_active_mcp_tools(user_id: str) -> list[dict]:
|
||||
@app.post("/api/adb/parse-wallet")
|
||||
async def parse_wallet(wallet: UploadFile = File(...), u=Depends(require("admin","user"))):
|
||||
"""Extract DSN names from tnsnames.ora inside wallet ZIP (temporary parse, no save)."""
|
||||
await _validate_upload(wallet, {".zip"})
|
||||
wallet.file.seek(0)
|
||||
import zipfile, tempfile
|
||||
try:
|
||||
data = await wallet.read()
|
||||
@@ -3115,6 +3203,9 @@ async def save_adb(
|
||||
wallet: Optional[UploadFile] = File(None),
|
||||
u=Depends(require("admin","user"))
|
||||
):
|
||||
if wallet and wallet.filename:
|
||||
await _validate_upload(wallet, {".zip"})
|
||||
wallet.file.seek(0)
|
||||
vid = str(uuid.uuid4())
|
||||
use_mtls_bool = use_mtls.lower() in ("true", "1", "yes")
|
||||
with db() as c:
|
||||
@@ -3138,6 +3229,8 @@ async def save_adb(
|
||||
|
||||
@app.post("/api/adb/{vid}/upload-wallet")
|
||||
async def upload_wallet(vid: str, wallet: UploadFile = File(...), u=Depends(require("admin","user"))):
|
||||
await _validate_upload(wallet, {".zip"})
|
||||
wallet.file.seek(0)
|
||||
cname = None
|
||||
with db() as c:
|
||||
row = c.execute("SELECT config_name FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||||
@@ -3249,6 +3342,9 @@ async def update_adb(
|
||||
wallet: Optional[UploadFile] = File(None),
|
||||
u=Depends(require("admin","user"))
|
||||
):
|
||||
if wallet and wallet.filename:
|
||||
await _validate_upload(wallet, {".zip"})
|
||||
wallet.file.seek(0)
|
||||
with db() as c:
|
||||
existing = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||||
if not existing: raise HTTPException(404)
|
||||
@@ -3631,6 +3727,8 @@ async def embed_upload(adb_config_id: str = Form(...), table_name: str = Form(""
|
||||
allowed = ('.txt', '.pdf', '.csv', '.json', '.md')
|
||||
if not any(fname.endswith(ext) for ext in allowed):
|
||||
raise HTTPException(400, f"Formatos aceitos: {', '.join(allowed)}")
|
||||
await _validate_upload(file, allowed)
|
||||
file.file.seek(0)
|
||||
raw = await file.read()
|
||||
if fname.endswith('.pdf'):
|
||||
content = _extract_pdf_text(raw)
|
||||
@@ -3965,11 +4063,11 @@ async def _exec_report(rid, cfg, regions, level=2, obp=False, raw=False, redact_
|
||||
_config_log("oci", cfg["id"], cfg["tenancy_name"], "error", "report", str(e)[:2000])
|
||||
|
||||
@app.get("/api/reports")
|
||||
async def list_reports(u=Depends(current_user)):
|
||||
async def list_reports(skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), u=Depends(current_user)):
|
||||
with db() as c:
|
||||
q = "SELECT id,user_id,tenancy_name,status,progress,level,obp_checks,raw_data,redact_output,created_at,completed_at,error_msg FROM reports"
|
||||
rows = c.execute(q+" ORDER BY created_at DESC").fetchall() if u["role"]=="admin" \
|
||||
else c.execute(q+" WHERE user_id=? ORDER BY created_at DESC",(u["id"],)).fetchall()
|
||||
rows = c.execute(q+" ORDER BY created_at DESC LIMIT ? OFFSET ?", (limit, skip)).fetchall() if u["role"]=="admin" \
|
||||
else c.execute(q+" WHERE user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?",(u["id"], limit, skip)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@app.get("/api/reports/{rid}/progress")
|
||||
@@ -4108,7 +4206,9 @@ async def download_report_file(rid: str, fid: str, token: str = Query(None), cre
|
||||
return FileResponse(p, filename=f["file_name"])
|
||||
|
||||
@app.get("/api/reports/{rid}/html")
|
||||
async def report_html(rid):
|
||||
async def report_html(rid, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
|
||||
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
|
||||
if not u: raise HTTPException(401, "Not authenticated")
|
||||
with db() as c: r=c.execute("SELECT html_path FROM reports WHERE id=?",(rid,)).fetchone()
|
||||
if not r or not r["html_path"] or not Path(r["html_path"]).exists(): raise HTTPException(404)
|
||||
return FileResponse(r["html_path"], media_type="text/html")
|
||||
@@ -4255,14 +4355,14 @@ def _search_cis_remediation(title: str, rec_num: str, user_id: str) -> dict:
|
||||
actual_dim = _get_table_embedding_dim(adb_cfg, tbl_name)
|
||||
if actual_dim and actual_dim in _DIM_TO_MODEL:
|
||||
tbl_model = _DIM_TO_MODEL[actual_dim]
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to detect embedding dimension for table '{tbl_name}': {e}")
|
||||
query_embedding = _embed_text(query, emb_genai, tbl_model)
|
||||
docs = _vector_search(adb_cfg, query_embedding, top_k=10, table_name=tbl_name)
|
||||
if docs:
|
||||
all_docs.extend(docs)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.warning(f"Vector search failed for table '{tbl_name}': {e}")
|
||||
|
||||
if all_docs:
|
||||
import re as _re
|
||||
@@ -4836,8 +4936,10 @@ def _generate_compliance_report(rid: str, user_id: str, force_rag: bool = True)
|
||||
|
||||
|
||||
@app.get("/api/reports/{rid}/compliance-report")
|
||||
async def compliance_report(rid: str, regen: int = Query(0)):
|
||||
async def compliance_report(rid: str, regen: int = Query(0), token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
|
||||
"""Serve cached LAD A-Team CIS Compliance Report, or return 404 if not generated yet."""
|
||||
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
|
||||
if not u: raise HTTPException(401, "Not authenticated")
|
||||
rdir = REPORTS / rid
|
||||
cache_path = rdir / "compliance_report.html"
|
||||
|
||||
@@ -4949,12 +5051,16 @@ async def compliance_report_download(rid: str, token: str = Query(None), cred: H
|
||||
tmp_html = Path(tmpdir) / "report.html"
|
||||
tmp_pdf = Path(tmpdir) / "report.pdf"
|
||||
tmp_html.write_text(html, encoding="utf-8")
|
||||
result = subprocess.run([
|
||||
"chromium", "--headless", "--no-sandbox", "--disable-gpu",
|
||||
"--disable-software-rasterizer", "--run-all-compositor-stages-before-draw",
|
||||
f"--print-to-pdf={tmp_pdf}", "--no-pdf-header-footer",
|
||||
f"file://{tmp_html}"
|
||||
], capture_output=True, timeout=120)
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, partial(
|
||||
subprocess.run, [
|
||||
"chromium", "--headless", "--no-sandbox", "--disable-gpu",
|
||||
"--disable-software-rasterizer", "--run-all-compositor-stages-before-draw",
|
||||
f"--print-to-pdf={tmp_pdf}", "--no-pdf-header-footer",
|
||||
f"file://{tmp_html}"
|
||||
], capture_output=True, timeout=120)),
|
||||
timeout=120)
|
||||
if tmp_pdf.exists():
|
||||
pdf_bytes = tmp_pdf.read_bytes()
|
||||
log.info(f"Chromium PDF generated: {len(pdf_bytes)} bytes")
|
||||
@@ -4990,6 +5096,7 @@ async def report_dl(rid, fmt: str = Query("json"), token: str = Query(None), cre
|
||||
if not u: raise HTTPException(401, "Not authenticated")
|
||||
with db() as c: r=c.execute("SELECT * FROM reports WHERE id=?",(rid,)).fetchone()
|
||||
if not r: raise HTTPException(404)
|
||||
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
|
||||
p = r["json_path"] if fmt=="json" else r["html_path"]
|
||||
if not p or not Path(p).exists(): raise HTTPException(404)
|
||||
return FileResponse(p, filename=f"cis_{r['tenancy_name']}_{rid[:8]}.{fmt}")
|
||||
@@ -5274,9 +5381,15 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
|
||||
|
||||
hist = history[:-1] if len(history) > 1 else None
|
||||
loop = asyncio.get_event_loop()
|
||||
resp_text, tool_calls, tool_calls_raw = await loop.run_in_executor(
|
||||
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
|
||||
tool_defs, None, None, attachments))
|
||||
try:
|
||||
resp_text, tool_calls, tool_calls_raw = await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
|
||||
tool_defs, None, None, attachments)),
|
||||
timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
log.error(f"GenAI call timed out after 300s for session {sid}")
|
||||
raise HTTPException(504, "O modelo de IA demorou demais para responder. Tente novamente.")
|
||||
|
||||
all_tool_results = []
|
||||
accumulated_msgs = []
|
||||
@@ -5314,9 +5427,15 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
|
||||
tr_obj.call = tc_obj
|
||||
tr_obj.outputs = [{"result": tr["content"]}]
|
||||
cohere_results.append(tr_obj)
|
||||
resp_text, tool_calls, tool_calls_raw = await loop.run_in_executor(
|
||||
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
|
||||
tool_defs, cohere_results))
|
||||
try:
|
||||
resp_text, tool_calls, tool_calls_raw = await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
|
||||
tool_defs, cohere_results)),
|
||||
timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
log.error(f"GenAI tool-use call timed out after 300s (Cohere, iteration {iterations})")
|
||||
raise HTTPException(504, "O modelo de IA demorou demais para responder. Tente novamente.")
|
||||
else:
|
||||
import oci
|
||||
assistant_msg = oci.generative_ai_inference.models.AssistantMessage()
|
||||
@@ -5329,9 +5448,15 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
|
||||
tool_content.text = tr["content"]
|
||||
tool_msg.content = [tool_content]
|
||||
accumulated_msgs.append(tool_msg)
|
||||
resp_text, tool_calls, tool_calls_raw = await loop.run_in_executor(
|
||||
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
|
||||
tool_defs, None, accumulated_msgs))
|
||||
try:
|
||||
resp_text, tool_calls, tool_calls_raw = await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
|
||||
tool_defs, None, accumulated_msgs)),
|
||||
timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
log.error(f"GenAI tool-use call timed out after 300s (Generic, iteration {iterations})")
|
||||
raise HTTPException(504, "O modelo de IA demorou demais para responder. Tente novamente.")
|
||||
|
||||
resp = resp_text
|
||||
if all_tool_results:
|
||||
@@ -5416,7 +5541,8 @@ async def chat_with_files(
|
||||
else:
|
||||
try:
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
log.warning(f"UTF-8 decode failed for file attachment, falling back to latin-1: {e}")
|
||||
text = data.decode("latin-1", errors="replace")
|
||||
message = f"{message}\n\n--- Conteúdo de {f.filename} ---\n{text[:50000]}"
|
||||
file_names.append(f.filename)
|
||||
@@ -5507,15 +5633,18 @@ async def list_chat_sessions(agent_type: str = "chat", limit: int = 50, u=Depend
|
||||
|
||||
|
||||
@app.get("/api/chat/sessions/{sid}/messages")
|
||||
async def get_session_messages(sid: str, u=Depends(current_user)):
|
||||
async def get_session_messages(sid: str, skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), u=Depends(current_user)):
|
||||
with db() as c:
|
||||
session = c.execute("SELECT * FROM chat_sessions WHERE id=? AND user_id=?", (sid, u["id"])).fetchone()
|
||||
if not session:
|
||||
raise HTTPException(404, "Session not found")
|
||||
total = c.execute(
|
||||
"SELECT COUNT(*) as cnt FROM chat_messages "
|
||||
"WHERE session_id=? AND status='done'", (sid,)).fetchone()["cnt"]
|
||||
msgs = c.execute(
|
||||
"SELECT id, role, content, model_id, status, created_at FROM chat_messages "
|
||||
"WHERE session_id=? AND status='done' ORDER BY created_at ASC", (sid,)).fetchall()
|
||||
return {"session": dict(session), "messages": [dict(m) for m in msgs]}
|
||||
"WHERE session_id=? AND status='done' ORDER BY created_at ASC LIMIT ? OFFSET ?", (sid, limit, skip)).fetchall()
|
||||
return {"session": dict(session), "messages": [dict(m) for m in msgs], "total": total}
|
||||
|
||||
|
||||
@app.put("/api/chat/sessions/{sid}/title")
|
||||
@@ -6105,7 +6234,13 @@ async def tf_delete_workspace(wid: str, u=Depends(current_user)):
|
||||
async def tf_refresh_reference(u=Depends(current_user)):
|
||||
"""Regenerate the OCI Terraform resource reference from provider schema (internal, triggered by UI button)."""
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(_chat_executor, _regenerate_tf_reference)
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
loop.run_in_executor(_chat_executor, _regenerate_tf_reference),
|
||||
timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
log.error("TF reference regeneration timed out after 300s")
|
||||
raise HTTPException(504, "Geração de referência Terraform demorou demais. Tente novamente.")
|
||||
# Add status info
|
||||
p = Path(_TF_RESOURCE_REF_PATH)
|
||||
if p.exists():
|
||||
@@ -6548,8 +6683,8 @@ provider "oci" {{
|
||||
|
||||
# ── Audit ─────────────────────────────────────────────────────────────────────
|
||||
@app.get("/api/audit-log")
|
||||
async def audit_log(limit:int=Query(100,le=500), u=Depends(require("admin"))):
|
||||
with db() as c: rows=c.execute("SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ?",(limit,)).fetchall()
|
||||
async def audit_log(skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), u=Depends(require("admin"))):
|
||||
with db() as c: rows=c.execute("SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?",(limit, skip)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── Config Logs ───────────────────────────────────────────────────────────────
|
||||
@@ -6693,6 +6828,8 @@ async def export_config(u=Depends(require("admin"))):
|
||||
@app.post("/api/config/import")
|
||||
async def import_config(file: UploadFile = File(...), u=Depends(require("admin"))):
|
||||
"""Import configurations from exported JSON. Merges by ID (skip existing)."""
|
||||
await _validate_upload(file, {".json"})
|
||||
file.file.seek(0)
|
||||
content = await file.read()
|
||||
try:
|
||||
data = json.loads(content)
|
||||
@@ -6843,7 +6980,40 @@ async def cis_engine_update(u=Depends(require("admin"))):
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status":"ok","ts":datetime.utcnow().isoformat(),"version":VERSION}
|
||||
db_ok = False
|
||||
try:
|
||||
with db() as c:
|
||||
c.execute("SELECT 1")
|
||||
db_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": VERSION,
|
||||
"uptime_seconds": int(time.time() - _START_TIME),
|
||||
"db_ok": db_ok,
|
||||
"ts": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown():
|
||||
"""Graceful shutdown: terminate running subprocesses and thread pool."""
|
||||
for rid, proc in list(_running_reports.items()):
|
||||
try:
|
||||
proc.terminate()
|
||||
log.info(f"Terminated report process {rid}")
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to terminate report process {rid}: {e}")
|
||||
_running_reports.clear()
|
||||
for wid, proc in list(_running_terraform.items()):
|
||||
try:
|
||||
proc.terminate()
|
||||
log.info(f"Terminated terraform process {wid}")
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to terminate terraform process {wid}: {e}")
|
||||
_running_terraform.clear()
|
||||
_chat_executor.shutdown(wait=False)
|
||||
log.info("Graceful shutdown completed")
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
@@ -6885,7 +7055,9 @@ async def startup():
|
||||
ref_path = Path(_TF_RESOURCE_REF_PATH)
|
||||
if not ref_path.exists():
|
||||
try:
|
||||
result = subprocess.run(["python", "/app/gen_tf_reference.py"], capture_output=True, text=True, timeout=300)
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, partial(
|
||||
subprocess.run, ["python", "/app/gen_tf_reference.py"], capture_output=True, text=True, timeout=300))
|
||||
if result.returncode == 0 and ref_path.exists():
|
||||
log.info(f"Generated TF resource reference at startup: {ref_path.stat().st_size} bytes")
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user