From bc7024c95dd099735f45518a8f7fcb5447a325b3 Mon Sep 17 00:00:00 2001 From: nogueiraguh Date: Wed, 1 Apr 2026 01:05:54 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20user=20isolation=20=E2=80=94=20ownershi?= =?UTF-8?q?p=20checks,=20is=5Fglobal,=20per-user=20embeddings,=20private?= =?UTF-8?q?=20reports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add is_global column to adb_vector_configs and mcp_servers (schema + migration) - Add _verify_config_access() and _verify_report_access() helpers - Apply ownership checks to ~70 endpoints (OCI explore, actions, GenAI, MCP, ADB, reports, embeddings, terraform) - Reports are 100% private (even admin can't see others') - Add user_id to embedding metadata + filter in _vector_search/_vector_search_multi - Remove cross-user ADB fallback in _get_active_adb_configs - Add auth (token query param) to report HTML and compliance report iframes - Audit log: admin sees all, user/viewer sees only own - Embedding task status mapped to user - Frontend: GLOBAL badge on ADB/MCP configs, hide edit/delete for non-admin on global resources - Export/import includes is_global field --- backend/app.py | 261 +++++++++++++++--- frontend-react/src/api/endpoints/reports.ts | 10 +- .../src/pages/config/AdbConfigPage.tsx | 43 +-- .../src/pages/config/McpServersPage.tsx | 53 ++-- 4 files changed, 282 insertions(+), 85 deletions(-) diff --git a/backend/app.py b/backend/app.py index 53d039d..8e6994f 100644 --- a/backend/app.py +++ b/backend/app.py @@ -277,6 +277,7 @@ def init_db(): table_name TEXT DEFAULT '', use_mtls INTEGER DEFAULT 1, is_active INTEGER DEFAULT 1, + is_global INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS adb_vector_tables ( @@ -298,6 +299,7 @@ def init_db(): tools TEXT, linked_adb_id TEXT, is_active INTEGER DEFAULT 1, + is_global INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS chat_messages ( @@ -432,11 +434,16 @@ def init_db(): c.execute(f"ALTER TABLE genai_configs ADD COLUMN {col}") except sqlite3.OperationalError: pass - for col in ["genai_config_id TEXT", "embedding_model_id TEXT DEFAULT 'cohere.embed-v4.0'"]: + for col in ["genai_config_id TEXT", "embedding_model_id TEXT DEFAULT 'cohere.embed-v4.0'", "is_global INTEGER DEFAULT 0"]: try: c.execute(f"ALTER TABLE adb_vector_configs ADD COLUMN {col}") except sqlite3.OperationalError: pass + for col in ["is_global INTEGER DEFAULT 0"]: + try: + c.execute(f"ALTER TABLE mcp_servers ADD COLUMN {col}") + except sqlite3.OperationalError: + pass for col in ["progress TEXT DEFAULT ''", "level INTEGER DEFAULT 2", "obp_checks INTEGER DEFAULT 0", "raw_data INTEGER DEFAULT 0", "redact_output INTEGER DEFAULT 0", "worker_pid INTEGER"]: try: @@ -614,6 +621,47 @@ def _chat_log(sid, mid, uid, severity, source, action, message): 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 Helpers ─────────────────────────────────────────────────── +_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: + """Verify user has access to a config. Returns the config row as dict. + - admin: always has access + - user/viewer: owns the config OR config is_global=1 (read access) + - require_owner=True: only owner or admin can proceed (for write ops on global) + Raises 404 if not found, 403 if no access.""" + 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: + """Verify user owns the report. Reports are 100% private — even admin can't see others'. + Raises 404 if not found or not owner.""" + 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) + # ── Models ──────────────────────────────────────────────────────────────────── class LoginReq(BaseModel): username: str; password: str; totp_code: Optional[str] = None @@ -657,7 +705,7 @@ class MCPServerReq(BaseModel): command: Optional[str] = None; args: Optional[List[str]] = None env_vars: Optional[Dict[str,str]] = None; url: Optional[str] = None module_path: Optional[str] = None; tools: Optional[List[dict]] = None - linked_adb_id: Optional[str] = None + linked_adb_id: Optional[str] = None; is_global: bool = False # ── Rate limiting (login brute-force protection) ───────────────────────────── _login_attempts: dict = {} # {ip: [timestamps]} @@ -967,6 +1015,7 @@ async def update_oci( @app.post("/api/oci/test/{cid}") async def test_oci(cid: str, u=Depends(require("admin","user"))): + _verify_config_access("oci", cid, u) cp = OCI_DIR / cid / "config" cname = None with db() as c: @@ -1001,6 +1050,7 @@ async def test_oci(cid: str, u=Depends(require("admin","user"))): # ── OCI Account Explorer ────────────────────────────────────────────────────── @app.get("/api/oci/explore/{cid}/compartments") async def explore_compartments(cid: str, u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1015,6 +1065,7 @@ async def explore_compartments(cid: str, u=Depends(current_user)): @app.get("/api/oci/explore/{cid}/regions") async def explore_regions(cid: str, u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1051,6 +1102,7 @@ def _explore_comp(cid): @app.get("/api/oci/explore/{cid}/counts") async def explore_counts_cached(cid: str, compartment_id: str = Query(...), regions: str = Query(""), u=Depends(current_user)): """Return cached resource counts from DB (instant).""" + _verify_config_access("oci", cid, u) with db() as c: rows = c.execute( "SELECT resource_type, count, updated_at FROM explorer_counts_cache WHERE oci_config_id=? AND compartment_id=? AND regions=?", @@ -1060,6 +1112,7 @@ async def explore_counts_cached(cid: str, compartment_id: str = Query(...), regi @app.post("/api/oci/explore/{cid}/counts/refresh") async def explore_counts_refresh(cid: str, req: dict, u=Depends(current_user)): """Refresh all resource counts in background thread and save to DB.""" + _verify_config_access("oci", cid, u) compartment_id = req.get("compartment_id") regions_list = req.get("regions", []) if not compartment_id: @@ -1111,6 +1164,7 @@ async def explore_counts_refresh(cid: str, req: dict, u=Depends(current_user)): @app.get("/api/oci/explore/{cid}/compartment-tree") async def explore_compartment_tree(cid: str, u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1157,6 +1211,7 @@ async def explore_compartment_tree(cid: str, u=Depends(current_user)): @app.get("/api/oci/explore/{cid}/vcns") async def explore_vcns(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1174,6 +1229,7 @@ async def explore_vcns(cid: str, compartment_id: str = Query(None), region: str @app.get("/api/oci/explore/{cid}/instances") async def explore_instances(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1188,6 +1244,7 @@ async def explore_instances(cid: str, compartment_id: str = Query(None), region: @app.get("/api/oci/explore/{cid}/databases") async def explore_databases(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1204,6 +1261,7 @@ async def explore_databases(cid: str, compartment_id: str = Query(None), region: @app.get("/api/oci/explore/{cid}/buckets") async def explore_buckets(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1222,6 +1280,7 @@ async def explore_buckets(cid: str, compartment_id: str = Query(None), region: s @app.get("/api/oci/explore/{cid}/subnets") async def explore_subnets(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1240,6 +1299,7 @@ async def explore_subnets(cid: str, compartment_id: str = Query(None), region: s @app.get("/api/oci/explore/{cid}/security_lists") async def explore_security_lists(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1275,6 +1335,7 @@ async def explore_security_lists(cid: str, compartment_id: str = Query(None), re @app.post("/api/oci/explore/{cid}/security_lists/{sl_id}/rules") async def add_security_list_rule(cid: str, sl_id: str, req: dict, u=Depends(require("admin", "user"))): """Add a rule to a Security List (appends to existing rules).""" + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1321,6 +1382,7 @@ async def add_security_list_rule(cid: str, sl_id: str, req: dict, u=Depends(requ @app.delete("/api/oci/explore/{cid}/security_lists/{sl_id}/rules") async def remove_security_list_rule(cid: str, sl_id: str, req: dict, u=Depends(require("admin", "user"))): """Remove a rule from a Security List by index.""" + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1355,6 +1417,7 @@ async def remove_security_list_rule(cid: str, sl_id: str, req: dict, u=Depends(r @app.get("/api/oci/explore/{cid}/block_volumes") async def explore_block_volumes(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1370,6 +1433,7 @@ async def explore_block_volumes(cid: str, compartment_id: str = Query(None), reg @app.get("/api/oci/explore/{cid}/load_balancers") async def explore_load_balancers(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1398,6 +1462,7 @@ async def explore_load_balancers(cid: str, compartment_id: str = Query(None), re @app.get("/api/oci/explore/{cid}/functions") async def explore_functions(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1421,6 +1486,7 @@ async def explore_functions(cid: str, compartment_id: str = Query(None), region: @app.get("/api/oci/explore/{cid}/container_instances") async def explore_container_instances(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1437,6 +1503,7 @@ async def explore_container_instances(cid: str, compartment_id: str = Query(None @app.get("/api/oci/explore/{cid}/nsgs") async def explore_nsgs(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1486,6 +1553,7 @@ async def explore_nsgs(cid: str, compartment_id: str = Query(None), region: str @app.post("/api/oci/explore/{cid}/nsgs/{nsg_id}/rules") async def add_nsg_rule(cid: str, nsg_id: str, req: dict, u=Depends(require("admin", "user"))): """Add a security rule to an NSG.""" + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1525,6 +1593,7 @@ async def add_nsg_rule(cid: str, nsg_id: str, req: dict, u=Depends(require("admi @app.delete("/api/oci/explore/{cid}/nsgs/{nsg_id}/rules/{rule_id}") async def remove_nsg_rule(cid: str, nsg_id: str, rule_id: str, region: str = Query(None), u=Depends(require("admin", "user"))): """Remove a security rule from an NSG.""" + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1539,6 +1608,7 @@ async def remove_nsg_rule(cid: str, nsg_id: str, rule_id: str, region: str = Que @app.get("/api/oci/explore/{cid}/route_tables") async def explore_route_tables(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1564,6 +1634,7 @@ async def explore_route_tables(cid: str, compartment_id: str = Query(None), regi @app.get("/api/oci/explore/{cid}/nat_gateways") async def explore_nat_gateways(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1579,6 +1650,7 @@ async def explore_nat_gateways(cid: str, compartment_id: str = Query(None), regi @app.get("/api/oci/explore/{cid}/internet_gateways") async def explore_internet_gateways(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1594,6 +1666,7 @@ async def explore_internet_gateways(cid: str, compartment_id: str = Query(None), @app.get("/api/oci/explore/{cid}/service_gateways") async def explore_service_gateways(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1609,6 +1682,7 @@ async def explore_service_gateways(cid: str, compartment_id: str = Query(None), @app.get("/api/oci/explore/{cid}/public_ips") async def explore_public_ips(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1625,6 +1699,7 @@ async def explore_public_ips(cid: str, compartment_id: str = Query(None), region @app.get("/api/oci/explore/{cid}/boot_volumes") async def explore_boot_volumes(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1648,6 +1723,7 @@ async def explore_boot_volumes(cid: str, compartment_id: str = Query(None), regi @app.get("/api/oci/explore/{cid}/instance_pools") async def explore_instance_pools(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1663,6 +1739,7 @@ async def explore_instance_pools(cid: str, compartment_id: str = Query(None), re @app.get("/api/oci/explore/{cid}/oke_clusters") async def explore_oke_clusters(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1679,6 +1756,7 @@ async def explore_oke_clusters(cid: str, compartment_id: str = Query(None), regi @app.get("/api/oci/explore/{cid}/file_systems") async def explore_file_systems(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1702,6 +1780,7 @@ async def explore_file_systems(cid: str, compartment_id: str = Query(None), regi @app.get("/api/oci/explore/{cid}/mount_targets") async def explore_mount_targets(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1725,6 +1804,7 @@ async def explore_mount_targets(cid: str, compartment_id: str = Query(None), reg @app.get("/api/oci/explore/{cid}/db_systems") async def explore_db_systems(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1741,6 +1821,7 @@ async def explore_db_systems(cid: str, compartment_id: str = Query(None), region @app.get("/api/oci/explore/{cid}/mysql_db_systems") async def explore_mysql_db_systems(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1757,6 +1838,7 @@ async def explore_mysql_db_systems(cid: str, compartment_id: str = Query(None), @app.get("/api/oci/explore/{cid}/alarms") async def explore_alarms(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1772,6 +1854,7 @@ async def explore_alarms(cid: str, compartment_id: str = Query(None), region: st @app.get("/api/oci/explore/{cid}/log_groups") async def explore_log_groups(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1787,6 +1870,7 @@ async def explore_log_groups(cid: str, compartment_id: str = Query(None), region @app.get("/api/oci/explore/{cid}/iam_users") async def explore_iam_users(cid: str, u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1802,6 +1886,7 @@ async def explore_iam_users(cid: str, u=Depends(current_user)): @app.get("/api/oci/explore/{cid}/iam_groups") async def explore_iam_groups(cid: str, u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1816,6 +1901,7 @@ async def explore_iam_groups(cid: str, u=Depends(current_user)): @app.get("/api/oci/explore/{cid}/iam_policies") async def explore_iam_policies(cid: str, compartment_id: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1830,6 +1916,7 @@ async def explore_iam_policies(cid: str, compartment_id: str = Query(None), u=De @app.get("/api/oci/explore/{cid}/iam_dynamic_groups") async def explore_iam_dynamic_groups(cid: str, u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1844,6 +1931,7 @@ async def explore_iam_dynamic_groups(cid: str, u=Depends(current_user)): @app.get("/api/oci/explore/{cid}/vaults") async def explore_vaults(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1860,6 +1948,7 @@ async def explore_vaults(cid: str, compartment_id: str = Query(None), region: st @app.get("/api/oci/explore/{cid}/dns_zones") async def explore_dns_zones(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1876,6 +1965,7 @@ async def explore_dns_zones(cid: str, compartment_id: str = Query(None), region: @app.get("/api/oci/explore/{cid}/api_gateways") async def explore_api_gateways(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1892,6 +1982,7 @@ async def explore_api_gateways(cid: str, compartment_id: str = Query(None), regi @app.get("/api/oci/explore/{cid}/notification_topics") async def explore_notification_topics(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1908,6 +1999,7 @@ async def explore_notification_topics(cid: str, compartment_id: str = Query(None @app.get("/api/oci/explore/{cid}/events_rules") async def explore_events_rules(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1924,6 +2016,7 @@ async def explore_events_rules(cid: str, compartment_id: str = Query(None), regi @app.get("/api/oci/explore/{cid}/network_firewalls") async def explore_network_firewalls(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1941,6 +2034,7 @@ async def explore_network_firewalls(cid: str, compartment_id: str = Query(None), @app.get("/api/oci/explore/{cid}/network_firewall_policies") async def explore_network_firewall_policies(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)): + _verify_config_access("oci", cid, u) try: import oci config = _get_oci_config(cid) @@ -1991,6 +2085,7 @@ async def list_genai(u=Depends(current_user)): @app.delete("/api/genai/configs/{gid}") async def del_genai(gid: str, u=Depends(require("admin","user"))): + _verify_config_access("genai", gid, u, require_owner=True) with db() as c: c.execute("DELETE FROM genai_configs WHERE id=?", (gid,)) return {"ok": True} @@ -2018,6 +2113,7 @@ async def update_genai(gid: str, req: GenAIConfigReq, u=Depends(require("admin", @app.post("/api/genai/test/{gid}") async def test_genai(gid: str, u=Depends(require("admin","user"))): + _verify_config_access("genai", gid, u) with db() as c: gc = c.execute("SELECT * FROM genai_configs WHERE id=?",(gid,)).fetchone() if not gc: raise HTTPException(404) @@ -2945,9 +3041,10 @@ def _get_table_embedding_dim(cfg: dict, table_name: str) -> int: _GLOBAL_TABLES = {"cisrecom", "engineerknowledgebase"} # Tables without tenancy filter (generic knowledge) -def _vector_search(cfg: dict, query_embedding: list, top_k: int = 5, table_name: str = None, tenancy: str = None, text_filter: str = None) -> list: +def _vector_search(cfg: dict, query_embedding: list, top_k: int = 5, table_name: str = None, tenancy: str = None, text_filter: str = None, user_id: str = None) -> list: """Search ADB vector store using cosine similarity. Returns top-K documents. If tenancy is provided and table is not global, filters by tenancy. + If user_id is provided and table is not global, filters by user_id (legacy docs without user_id are included). If text_filter is provided, also filters TEXT content with LIKE.""" import array table_name = table_name or cfg.get("table_name", "") @@ -2957,7 +3054,9 @@ def _vector_search(cfg: dict, query_embedding: list, top_k: int = 5, table_name: cur = conn.cursor() vec = array.array('f', query_embedding) limit = int(top_k) - use_tenancy_filter = tenancy and table_name.lower() not in _GLOBAL_TABLES + is_non_global = table_name.lower() not in _GLOBAL_TABLES + use_tenancy_filter = tenancy and is_non_global + use_user_filter = user_id and is_non_global # Build WHERE clauses conditions = [] params = [vec] @@ -2966,6 +3065,10 @@ def _vector_search(cfg: dict, query_embedding: list, top_k: int = 5, table_name: conditions.append(f"JSON_VALUE(METADATA, '$.tenancy') = :{param_idx}") params.append(tenancy) param_idx += 1 + if use_user_filter: + conditions.append(f"(JSON_VALUE(METADATA, '$.user_id') = :{param_idx} OR JSON_VALUE(METADATA, '$.user_id') IS NULL)") + params.append(user_id) + param_idx += 1 if text_filter: conditions.append(f"TEXT LIKE :{param_idx}") params.append(f"%{text_filter}%") @@ -3030,7 +3133,7 @@ def _relevant_tables(query: str, tables: list[str]) -> list[str]: def _vector_search_multi(cfg: dict, query_embedding: list, tables: list[str], top_k_per_table: int = 3, - tenancy: str = None, text_filter: str = None) -> list: + tenancy: str = None, text_filter: str = None, user_id: str = None) -> list: """Search multiple tables using a SINGLE ADB connection. Returns all documents with source.""" import array conn = _get_adb_connection(cfg) @@ -3042,7 +3145,9 @@ def _vector_search_multi(cfg: dict, query_embedding: list, tables: list[str], to try: cur = conn.cursor() limit = int(top_k_per_table) - use_tenancy = tenancy and table_name.lower() not in _GLOBAL_TABLES + is_non_global = table_name.lower() not in _GLOBAL_TABLES + use_tenancy = tenancy and is_non_global + use_user_filter = user_id and is_non_global conditions = [] params = [vec] param_idx = 2 @@ -3050,7 +3155,11 @@ def _vector_search_multi(cfg: dict, query_embedding: list, tables: list[str], to conditions.append(f"JSON_VALUE(METADATA, '$.tenancy') = :{param_idx}") params.append(tenancy) param_idx += 1 - tbl_filter = text_filter if (text_filter and table_name.lower() not in _GLOBAL_TABLES) else None + if use_user_filter: + conditions.append(f"(JSON_VALUE(METADATA, '$.user_id') = :{param_idx} OR JSON_VALUE(METADATA, '$.user_id') IS NULL)") + params.append(user_id) + param_idx += 1 + tbl_filter = text_filter if (text_filter and is_non_global) else None if tbl_filter: conditions.append(f"TEXT LIKE :{param_idx}") params.append(f"%{tbl_filter}%") @@ -3146,16 +3255,12 @@ def _build_rag_context(documents: list, max_total_chars: int = 12000) -> str: return "\n\n---\n\n".join(parts) def _get_active_adb_configs(user_id: str) -> list[dict]: - """Get all active ADB vector configs. GenAI config is resolved automatically if not linked.""" + """Get all active ADB vector configs accessible to this user (own + global). No cross-user fallback.""" with db() as c: rows = c.execute( - "SELECT * FROM adb_vector_configs WHERE user_id=? AND is_active=1 ORDER BY created_at DESC", + "SELECT * FROM adb_vector_configs WHERE is_active=1 AND (user_id=? OR is_global=1) ORDER BY is_global DESC, created_at DESC", (user_id,) ).fetchall() - if not rows: - rows = c.execute( - "SELECT * FROM adb_vector_configs WHERE is_active=1 ORDER BY created_at DESC" - ).fetchall() return [dict(r) for r in rows] def _get_tables_for_config(adb_config_id: str, active_only: bool = True) -> list[dict]: @@ -3172,13 +3277,14 @@ def _get_tables_for_config(adb_config_id: str, active_only: bool = True) -> list @app.post("/api/mcp/servers") async def register_mcp(req: MCPServerReq, u=Depends(require("admin","user"))): mid = str(uuid.uuid4()) + is_global_val = 1 if (req.is_global and u["role"] == "admin") else 0 with db() as c: c.execute( - "INSERT INTO mcp_servers (id,user_id,name,description,server_type,command,args,env_vars,url,module_path,tools,linked_adb_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO mcp_servers (id,user_id,name,description,server_type,command,args,env_vars,url,module_path,tools,linked_adb_id,is_global) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", (mid, u["id"], req.name, req.description, req.server_type, req.command, json.dumps(req.args) if req.args else None, json.dumps(req.env_vars) if req.env_vars else None, req.url, req.module_path, - json.dumps(req.tools) if req.tools else None, req.linked_adb_id)) + json.dumps(req.tools) if req.tools else None, req.linked_adb_id, is_global_val)) _audit(u["id"], u["username"], "register_mcp", mid, req.name) _config_log("mcp", mid, req.name, "success", "save", f"MCP registrado: {req.name} ({req.server_type})", u["id"], u["username"]) return {"id": mid, "name": req.name, "server_type": req.server_type} @@ -3187,7 +3293,7 @@ async def register_mcp(req: MCPServerReq, u=Depends(require("admin","user"))): async def list_mcp(u=Depends(current_user)): with db() as c: if u["role"]=="admin": rows=c.execute("SELECT * FROM mcp_servers ORDER BY created_at DESC").fetchall() - else: rows=c.execute("SELECT * FROM mcp_servers WHERE user_id=? ORDER BY created_at DESC",(u["id"],)).fetchall() + else: rows=c.execute("SELECT * FROM mcp_servers WHERE user_id=? OR is_global=1 ORDER BY created_at DESC",(u["id"],)).fetchall() res = [] for r in rows: d = dict(r) @@ -3200,6 +3306,7 @@ async def list_mcp(u=Depends(current_user)): @app.delete("/api/mcp/servers/{mid}") async def del_mcp(mid: str, u=Depends(require("admin","user"))): + _verify_config_access("mcp", mid, u, require_owner=True) with db() as c: c.execute("DELETE FROM mcp_servers WHERE id=?", (mid,)) return {"ok": True} @@ -3209,19 +3316,21 @@ async def update_mcp(mid: str, req: MCPServerReq, u=Depends(require("admin","use existing = c.execute("SELECT * FROM mcp_servers WHERE id=?", (mid,)).fetchone() if not existing: raise HTTPException(404) if u["role"] != "admin" and existing["user_id"] != u["id"]: raise HTTPException(403) + is_global_val = 1 if (req.is_global and u["role"] == "admin") else (existing["is_global"] if u["role"] != "admin" else (1 if req.is_global else 0)) with db() as c: c.execute( - "UPDATE mcp_servers SET name=?,description=?,server_type=?,command=?,args=?,env_vars=?,url=?,tools=?,linked_adb_id=? WHERE id=?", + "UPDATE mcp_servers SET name=?,description=?,server_type=?,command=?,args=?,env_vars=?,url=?,tools=?,linked_adb_id=?,is_global=? WHERE id=?", (req.name, req.description, req.server_type, req.command, json.dumps(req.args) if req.args else None, json.dumps(req.env_vars) if req.env_vars else None, req.url, - json.dumps(req.tools) if req.tools else None, req.linked_adb_id, mid)) + json.dumps(req.tools) if req.tools else None, req.linked_adb_id, is_global_val, mid)) _audit(u["id"], u["username"], "update_mcp", mid, req.name) _config_log("mcp", mid, req.name, "success", "save", f"MCP atualizado: {req.name} ({req.server_type})", u["id"], u["username"]) return {"id": mid, "name": req.name, "server_type": req.server_type} @app.put("/api/mcp/servers/{mid}/toggle") async def toggle_mcp(mid: str, u=Depends(require("admin","user"))): + _verify_config_access("mcp", mid, u, require_owner=True) with db() as c: cur = c.execute("SELECT is_active FROM mcp_servers WHERE id=?",(mid,)).fetchone() if not cur: raise HTTPException(404) @@ -3357,8 +3466,8 @@ def _get_active_mcp_tools(user_id: str) -> list[dict]: """Get all tools from active MCP servers for a user, with server reference.""" with db() as c: rows = c.execute( - "SELECT * FROM mcp_servers WHERE is_active=1 AND (user_id=? OR EXISTS (SELECT 1 FROM users WHERE id=? AND role='admin'))", - (user_id, user_id) + "SELECT * FROM mcp_servers WHERE is_active=1 AND (user_id=? OR is_global=1)", + (user_id,) ).fetchall() result = [] for r in rows: @@ -3407,6 +3516,7 @@ async def save_adb( password: str = Form(...), wallet_password: str = Form(""), table_name: str = Form(""), use_mtls: str = Form("true"), genai_config_id: str = Form(""), embedding_model_id: str = Form("cohere.embed-v4.0"), + is_global: str = Form("false"), wallet: Optional[UploadFile] = File(None), u=Depends(require("admin","user")) ): @@ -3415,12 +3525,13 @@ async def save_adb( wallet.file.seek(0) vid = str(uuid.uuid4()) use_mtls_bool = use_mtls.lower() in ("true", "1", "yes") + is_global_val = 1 if (is_global.lower() in ("true", "1", "yes") and u["role"] == "admin") else 0 with db() as c: c.execute( - "INSERT INTO adb_vector_configs (id,user_id,config_name,dsn,username,password_enc,wallet_password_enc,table_name,use_mtls,genai_config_id,embedding_model_id) VALUES (?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO adb_vector_configs (id,user_id,config_name,dsn,username,password_enc,wallet_password_enc,table_name,use_mtls,genai_config_id,embedding_model_id,is_global) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", (vid, u["id"], config_name, dsn, username, _enc(password), _enc(wallet_password) if wallet_password else None, table_name, int(use_mtls_bool), - genai_config_id or None, embedding_model_id)) + genai_config_id or None, embedding_model_id, is_global_val)) # Auto-save wallet if provided if wallet and wallet.filename: import zipfile @@ -3436,6 +3547,7 @@ 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"))): + _verify_config_access("adb", vid, u, require_owner=True) await _validate_upload(wallet, {".zip"}) wallet.file.seek(0) cname = None @@ -3459,9 +3571,9 @@ async def upload_wallet(vid: str, wallet: UploadFile = File(...), u=Depends(requ @app.get("/api/adb/configs") async def list_adb(u=Depends(current_user)): with db() as c: - cols = "id,config_name,dsn,username,table_name,use_mtls,is_active,wallet_dir,genai_config_id,embedding_model_id,created_at" + cols = "id,user_id,config_name,dsn,username,table_name,use_mtls,is_active,is_global,wallet_dir,genai_config_id,embedding_model_id,created_at" if u["role"]=="admin": rows=c.execute(f"SELECT {cols} FROM adb_vector_configs").fetchall() - else: rows=c.execute(f"SELECT {cols} FROM adb_vector_configs WHERE user_id=?",(u["id"],)).fetchall() + else: rows=c.execute(f"SELECT {cols} FROM adb_vector_configs WHERE user_id=? OR is_global=1",(u["id"],)).fetchall() result = [] for r in rows: d = dict(r) @@ -3471,6 +3583,7 @@ async def list_adb(u=Depends(current_user)): @app.post("/api/adb/test/{vid}") async def test_adb(vid: str, u=Depends(require("admin","user"))): + _verify_config_access("adb", vid, u) with db() as c: cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?",(vid,)).fetchone() if not cfg: raise HTTPException(404) @@ -3491,6 +3604,7 @@ async def test_adb(vid: str, u=Depends(require("admin","user"))): @app.post("/api/adb/{vid}/tables/check") async def check_adb_tables(vid: str, u=Depends(require("admin","user"))): """Check which registered tables exist in ADB and list all user tables.""" + _verify_config_access("adb", vid, u) with db() as c: cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone() if not cfg: raise HTTPException(404) @@ -3534,6 +3648,7 @@ async def check_adb_tables(vid: str, u=Depends(require("admin","user"))): @app.delete("/api/adb/configs/{vid}") async def del_adb(vid: str, u=Depends(require("admin","user"))): + _verify_config_access("adb", vid, u, require_owner=True) with db() as c: c.execute("DELETE FROM adb_vector_configs WHERE id=?", (vid,)) d = WALLET_DIR / vid if d.exists(): shutil.rmtree(d) @@ -3546,6 +3661,7 @@ async def update_adb( password: str = Form(""), wallet_password: str = Form(""), table_name: str = Form(""), use_mtls: str = Form("true"), genai_config_id: str = Form(""), embedding_model_id: str = Form("cohere.embed-v4.0"), + is_global: str = Form(""), wallet: Optional[UploadFile] = File(None), u=Depends(require("admin","user")) ): @@ -3556,9 +3672,14 @@ async def update_adb( existing = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone() if not existing: raise HTTPException(404) if u["role"] != "admin" and existing["user_id"] != u["id"]: raise HTTPException(403) + if u["role"] != "admin" and existing.get("is_global"): raise HTTPException(403, "Não é possível editar configuração global") use_mtls_bool = use_mtls.lower() in ("true", "1", "yes") sets = "config_name=?,dsn=?,username=?,table_name=?,use_mtls=?,genai_config_id=?,embedding_model_id=?" vals = [config_name, dsn, username, table_name, int(use_mtls_bool), genai_config_id or None, embedding_model_id] + if u["role"] == "admin" and is_global: + is_global_val = 1 if is_global.lower() in ("true", "1", "yes") else 0 + sets += ",is_global=?" + vals.append(is_global_val) if password: sets += ",password_enc=?" vals.append(_enc(password)) @@ -3584,6 +3705,7 @@ _TABLE_NAME_RE = re.compile(r'^[A-Za-z][A-Za-z0-9_]{0,127}$') @app.get("/api/adb/{vid}/tables") async def list_adb_tables(vid: str, u=Depends(current_user)): + _verify_config_access("adb", vid, u) with db() as c: cfg = c.execute("SELECT id FROM adb_vector_configs WHERE id=?", (vid,)).fetchone() if not cfg: raise HTTPException(404) @@ -3591,6 +3713,7 @@ async def list_adb_tables(vid: str, u=Depends(current_user)): @app.post("/api/adb/{vid}/tables") async def add_adb_table(vid: str, req: dict, u=Depends(require("admin","user"))): + _verify_config_access("adb", vid, u, require_owner=True) table_name = req.get("table_name", "").strip() description = req.get("description", "").strip() if not table_name: raise HTTPException(400, "table_name é obrigatório") @@ -3610,6 +3733,7 @@ async def add_adb_table(vid: str, req: dict, u=Depends(require("admin","user"))) @app.delete("/api/adb/{vid}/tables/{tid}") async def remove_adb_table(vid: str, tid: str, u=Depends(require("admin","user"))): + _verify_config_access("adb", vid, u, require_owner=True) with db() as c: row = c.execute("SELECT * FROM adb_vector_tables WHERE id=? AND adb_config_id=?", (tid, vid)).fetchone() if not row: raise HTTPException(404, "Table entry not found") @@ -3619,6 +3743,7 @@ async def remove_adb_table(vid: str, tid: str, u=Depends(require("admin","user") @app.put("/api/adb/{vid}/tables/{tid}") async def update_adb_table(vid: str, tid: str, req: dict, u=Depends(require("admin","user"))): + _verify_config_access("adb", vid, u, require_owner=True) with db() as c: row = c.execute("SELECT * FROM adb_vector_tables WHERE id=? AND adb_config_id=?", (tid, vid)).fetchone() if not row: raise HTTPException(404, "Table entry not found") @@ -3643,6 +3768,7 @@ async def update_adb_table(vid: str, tid: str, req: dict, u=Depends(require("adm @app.post("/api/adb/{vid}/ingest") async def ingest_documents(vid: str, req: IngestDocReq, bg: BackgroundTasks, u=Depends(require("admin","user"))): + _verify_config_access("adb", vid, u) with db() as c: cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone() if not cfg: raise HTTPException(404, "ADB config not found") @@ -3656,7 +3782,7 @@ async def ingest_documents(vid: str, req: IngestDocReq, bg: BackgroundTasks, u=D return {"ok": True, "message": f"Ingestão iniciada para {len(req.documents)} documentos", "config_id": vid} def _build_metadata_json(tenancy: str = "", compartments: str = "", section: str = "", - report_date: str = "", extra: dict = None) -> str: + report_date: str = "", user_id: str = "", extra: dict = None) -> str: """Build a structured JSON metadata string for vector embeddings.""" meta = {} if tenancy: @@ -3667,6 +3793,8 @@ def _build_metadata_json(tenancy: str = "", compartments: str = "", section: str meta["section"] = section if report_date: meta["report_date"] = report_date + if user_id: + meta["user_id"] = user_id if extra: meta.update(extra) return json.dumps(meta, ensure_ascii=False) if meta else "" @@ -3706,7 +3834,7 @@ def _ingest_documents_task(cfg: dict, genai_cfg: dict, documents: list, user_id: # Track status if task_id: _set_embed_status(task_id, {"status": "running", "table": table_name, "tenancy": tenancy or "", - "inserted": 0, "total": total, "message": "Iniciando embedding..."}) + "inserted": 0, "total": total, "user_id": user_id, "message": "Iniciando embedding..."}) # Auto-register table so it appears in multi-table RAG search _auto_register_table(cfg["id"], table_name) conn = _get_adb_connection(cfg) @@ -3727,6 +3855,7 @@ def _ingest_documents_task(cfg: dict, genai_cfg: dict, documents: list, user_id: compartments=doc_compartments, section=doc.get("section", ""), report_date=report_date or "", + user_id=user_id, extra={"legacy_metadata": doc.get("metadata", "")} if doc.get("metadata") else None ) cur.execute(f""" @@ -4017,6 +4146,7 @@ def _get_adb_and_genai(vid: str, oci_config_id: str = None): @app.get("/api/embeddings/preview/{rid}") async def preview_report_chunks(rid: str, u=Depends(current_user)): """Preview the chunks that will be generated from a CIS report before embedding.""" + _verify_report_access(rid, u) with db() as c: r = c.execute("SELECT json_path,tenancy_name FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone() if not r: raise HTTPException(404, "Report not found or not completed") @@ -4210,8 +4340,10 @@ def _chunk_findings_csv(csv_path: str, tenancy: str, extract_date: str, max_char @app.post("/api/embeddings/report/{rid}") async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(require("admin","user"))): """Auto-embed all CIS report CSVs into their mapped ADB vector tables.""" + _verify_report_access(rid, u) vid = req.get("adb_config_id") if not vid: raise HTTPException(400, "adb_config_id is required") + _verify_config_access("adb", vid, u) with db() as c: r = c.execute("SELECT tenancy_name, config_id FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone() if not r: raise HTTPException(404, "Report not found or not completed") @@ -4266,7 +4398,7 @@ async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(requi tables_used = list(table_docs.keys()) _set_embed_status(task_id, { "status": "running", "table": ", ".join(tables_used), "tenancy": tenancy, - "inserted": 0, "total": total_docs, + "inserted": 0, "total": total_docs, "user_id": u["id"], "message": f"Embedding {total_docs} documentos em {len(tables_used)} tabelas..." }) @@ -4315,6 +4447,7 @@ async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(requi tenancy=doc.get("tenancy", tenancy), section=doc.get("section", ""), report_date=extract_date, + user_id=u["id"], ) cur.execute(f'INSERT INTO "{tbl}" (ID, TEXT, EMBEDDING, METADATA) VALUES (HEXTORAW(:1), :2, :3, :4)', [uuid.uuid4().hex.upper(), content, vec, metadata]) @@ -4373,12 +4506,15 @@ async def embedding_status(task_id: str, u=Depends(current_user)): st = _get_embed_status(task_id) if not st: return {"status": "unknown", "message": "Task not found"} - return st + if st.get("user_id") and st["user_id"] != u["id"] and u["role"] != "admin": + return {"status": "unknown", "message": "Task not found"} + return {k: v for k, v in st.items() if k != "user_id"} @app.post("/api/embeddings/report/{rid}/section") async def embed_report_section(rid: str, req: dict, bg: BackgroundTasks, u=Depends(require("admin","user"))): """Embed all CSV files from a specific report section into the mapped ADB table.""" + _verify_report_access(rid, u) import csv as csvmod vid = req.get("adb_config_id") file_names: list = req.get("file_names", []) @@ -4441,7 +4577,7 @@ async def embed_report_section(rid: str, req: dict, bg: BackgroundTasks, u=Depen task_id = str(uuid.uuid4()) _set_embed_status(task_id, { "status": "running", "table": ", ".join(tables_used), "tenancy": tenancy, - "inserted": 0, "total": total_docs, "message": f"Embedding {total_docs} docs em {', '.join(tables_used)}..." + "inserted": 0, "total": total_docs, "user_id": u["id"], "message": f"Embedding {total_docs} docs em {', '.join(tables_used)}..." }) def _bg(): @@ -4473,7 +4609,7 @@ async def embed_report_section(rid: str, req: dict, bg: BackgroundTasks, u=Depen continue embedding = _embed_text(content, gc, emb_model) vec = array.array('f', [float(x) for x in embedding]) - metadata = _build_metadata_json(tenancy=doc.get("tenancy", tenancy), section=doc.get("section", ""), report_date=extract_date) + metadata = _build_metadata_json(tenancy=doc.get("tenancy", tenancy), section=doc.get("section", ""), report_date=extract_date, user_id=u["id"]) cur.execute(f'INSERT INTO "{tbl}" (ID, TEXT, EMBEDDING, METADATA) VALUES (HEXTORAW(:1), :2, :3, :4)', [uuid.uuid4().hex.upper(), content, vec, metadata]) inserted += 1 @@ -4508,8 +4644,10 @@ async def embed_report_section(rid: str, req: dict, bg: BackgroundTasks, u=Depen @app.post("/api/embeddings/report/{rid}/file/{fid}") async def embed_report_file(rid: str, fid: str, req: dict, bg: BackgroundTasks, u=Depends(require("admin","user"))): """Embed a specific report file (CSV, JSON, TXT, etc.) into ADB vector store.""" + _verify_report_access(rid, u) vid = req.get("adb_config_id") if not vid: raise HTTPException(400, "adb_config_id is required") + _verify_config_access("adb", vid, u) with db() as c: r = c.execute("SELECT tenancy_name, config_id FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone() if not r: raise HTTPException(404, "Report not found or not completed") @@ -4566,6 +4704,7 @@ def _extract_pdf_text(file_bytes: bytes) -> str: @app.post("/api/embeddings/upload") async def embed_upload(adb_config_id: str = Form(...), table_name: str = Form(""), file: UploadFile = File(...), bg: BackgroundTasks = None, u=Depends(require("admin","user"))): + _verify_config_access("adb", adb_config_id, u) fname = file.filename.lower() allowed = ('.txt', '.pdf', '.csv', '.json', '.md') if not any(fname.endswith(ext) for ext in allowed): @@ -4611,6 +4750,7 @@ async def embed_upload_url( bg: BackgroundTasks = None, u=Depends(require("admin", "user")) ): + _verify_config_access("adb", adb_config_id, u) import requests as req url = url.strip() if not url.startswith(("http://", "https://")): @@ -4701,7 +4841,7 @@ async def consult_embeddings(req: ConsultQuery, u=Depends(current_user)): tbl_top_k = 10 if cis_text_filter else 3 docs = _vector_search_multi(adb_cfg, query_embedding, relevant, top_k_per_table=tbl_top_k, tenancy=rag_tenancy, - text_filter=cis_text_filter) + text_filter=cis_text_filter, user_id=u["id"]) all_docs.extend(docs) if docs: sources = {} @@ -4774,6 +4914,7 @@ async def consult_embeddings(req: ConsultQuery, u=Depends(current_user)): @app.get("/api/embeddings/{vid}/list") async def list_embeddings(vid: str, table_name: str = Query(""), limit: int = Query(50), offset: int = Query(0), u=Depends(current_user)): + _verify_config_access("adb", vid, u) with db() as c: cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone() if not cfg: raise HTTPException(404) @@ -4801,6 +4942,7 @@ async def list_embeddings(vid: str, table_name: str = Query(""), limit: int = Qu @app.delete("/api/embeddings/{vid}/{doc_id}") async def delete_embedding(vid: str, doc_id: str, table_name: str = Query(""), u=Depends(require("admin","user"))): + _verify_config_access("adb", vid, u, require_owner=True) with db() as c: cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone() if not cfg: raise HTTPException(404) @@ -4819,6 +4961,7 @@ async def delete_embedding(vid: str, doc_id: str, table_name: str = Query(""), u @app.post("/api/embeddings/{vid}/purge") async def purge_embeddings(vid: str, req: dict, u=Depends(require("admin","user"))): """Delete old embeddings from a table, optionally filtered by tenancy.""" + _verify_config_access("adb", vid, u, require_owner=True) table_name = req.get("table_name", "").strip() tenancy = req.get("tenancy", "").strip() if not table_name: raise HTTPException(400, "table_name é obrigatório") @@ -4864,6 +5007,7 @@ def _classify_report_file(fname: str) -> str: @app.post("/api/reports/run") async def run_report(req: RunReportReq, bg: BackgroundTasks, u=Depends(require("admin","user"))): if req.level not in (1, 2): raise HTTPException(400, "Level must be 1 or 2") + _verify_config_access("oci", req.config_id, u) with db() as c: cfg = c.execute("SELECT * FROM oci_configs WHERE id=?",(req.config_id,)).fetchone() if not cfg: raise HTTPException(404, "Config não encontrada") @@ -4948,12 +5092,12 @@ async def _exec_report(rid, cfg, regions, level=2, obp=False, raw=False, redact_ 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,config_id,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 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() + rows = 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") async def get_report_progress(rid: str, u=Depends(current_user)): + _verify_report_access(rid, u) with db() as c: r = c.execute("SELECT status,progress,created_at,completed_at,error_msg FROM reports WHERE id=?", (rid,)).fetchone() if not r: raise HTTPException(404) @@ -4961,6 +5105,7 @@ async def get_report_progress(rid: str, u=Depends(current_user)): @app.post("/api/reports/{rid}/cancel") async def cancel_report(rid: str, u=Depends(require("admin", "user"))): + _verify_report_access(rid, u) with db() as c: r = c.execute("SELECT status, worker_pid FROM reports WHERE id=?", (rid,)).fetchone() if not r: raise HTTPException(404) @@ -5088,7 +5233,10 @@ async def download_report_file(rid: str, fid: str, token: str = Query(None), cre return FileResponse(p, filename=f["file_name"]) @app.api_route("/api/reports/{rid}/html", methods=["GET", "HEAD"]) -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") + _verify_report_access(rid, u) 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") @@ -5296,7 +5444,7 @@ def _search_cis_remediation(title: str, rec_num: str, user_id: str) -> dict: 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) + docs = _vector_search(adb_cfg, query_embedding, top_k=10, table_name=tbl_name, user_id=user_id) if docs: all_docs.extend(docs) except Exception as e: @@ -6529,6 +6677,7 @@ _OCI_SERVICE_NAMES = ["Vulnerability Scanning Service", "Data Safe", "Cloud Guar @app.get("/api/oci-services/{config_id}") async def get_oci_services_status(config_id: str, detect: int = Query(1), u=Depends(current_user)): """Get OCI services status (auto-detected + user overrides). Use detect=0 to skip auto-detect.""" + _verify_config_access("oci", config_id, u) import json as _json # Load user overrides with db() as c: @@ -6561,6 +6710,7 @@ async def get_oci_services_status(config_id: str, detect: int = Query(1), u=Depe @app.put("/api/oci-services/{config_id}") async def set_oci_services_status(config_id: str, request: Request, u=Depends(current_user)): """Save user overrides for OCI services status.""" + _verify_config_access("oci", config_id, u) import json as _json body = await request.json() overrides = {} @@ -6575,8 +6725,11 @@ async def set_oci_services_status(config_id: str, request: Request, u=Depends(cu return {"ok": True} @app.api_route("/api/reports/{rid}/compliance-report", methods=["GET", "HEAD"]) -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") + _verify_report_access(rid, u) rdir = REPORTS / rid cache_path = rdir / "compliance_report.html" @@ -6604,6 +6757,7 @@ async def compliance_report(rid: str, regen: int = Query(0)): @app.post("/api/reports/{rid}/compliance-report/generate") async def generate_compliance_report(rid: str, bg: BackgroundTasks, u=Depends(current_user)): """Start generating the compliance report in background.""" + _verify_report_access(rid, u) with db() as c: report = c.execute("SELECT * FROM reports WHERE id=?", (rid,)).fetchone() if not report: raise HTTPException(404, "Report not found") @@ -6668,8 +6822,9 @@ async def generate_compliance_report(rid: str, bg: BackgroundTasks, u=Depends(cu @app.get("/api/reports/{rid}/compliance-report/status") -async def compliance_report_status(rid: str): +async def compliance_report_status(rid: str, u=Depends(current_user)): """Check compliance report status: ready, generating, or not started.""" + _verify_report_access(rid, u) rdir = REPORTS / rid cache_path = rdir / "compliance_report.html" marker = rdir / ".compliance_generating" @@ -7694,6 +7849,7 @@ async def compliance_report_docx(rid: str, token: str = Query(None), cred: HTTPA import re as _re u = _user_from_token(token) if token else (await current_user(cred) if cred else None) if not u: raise HTTPException(401, "Not authenticated") + _verify_report_access(rid, u) with db() as c: report = c.execute("SELECT tenancy_name FROM reports WHERE id=?", (rid,)).fetchone() if not report: raise HTTPException(404) @@ -7723,6 +7879,7 @@ async def compliance_report_download(rid: str, token: str = Query(None), cred: H import zipfile, io, re as _re, subprocess, tempfile u = _user_from_token(token) if token else (await current_user(cred) if cred else None) if not u: raise HTTPException(401, "Not authenticated") + _verify_report_access(rid, u) rdir = REPORTS / rid html_path = rdir / "compliance_report.html" @@ -7809,9 +7966,9 @@ async def compliance_report_download(rid: str, token: str = Query(None), cred: H async def report_dl(rid, fmt: str = Query("json"), 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") + _verify_report_access(rid, u) 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}") @@ -7972,7 +8129,7 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c tbl_top_k = 10 if cis_chat_filter else 3 docs = _vector_search_multi(adb_cfg, query_embedding, relevant, top_k_per_table=tbl_top_k, tenancy=rag_tenancy, - text_filter=cis_chat_filter) + text_filter=cis_chat_filter, user_id=user["id"]) if docs: all_documents.extend(docs) sources = {} @@ -8526,6 +8683,7 @@ async def oci_instance_action(instance_id: str, req: dict, u=Depends(current_use raise HTTPException(400, "Ação inválida. Use START ou STOP.") if not oci_config_id: raise HTTPException(400, "oci_config_id obrigatório") + _verify_config_access("oci", oci_config_id, u) try: import oci config = _get_oci_config(oci_config_id) @@ -8548,6 +8706,7 @@ async def oci_adb_action(adb_id: str, req: dict, u=Depends(current_user)): raise HTTPException(400, "Ação inválida. Use start ou stop.") if not oci_config_id: raise HTTPException(400, "oci_config_id obrigatório") + _verify_config_access("oci", oci_config_id, u) try: import oci config = _get_oci_config(oci_config_id) @@ -8571,6 +8730,7 @@ async def oci_adb_update_network(adb_id: str, req: dict, u=Depends(current_user) region = req.get("region") if not oci_config_id: raise HTTPException(400, "oci_config_id obrigatório") + _verify_config_access("oci", oci_config_id, u) if not ip: raise HTTPException(400, "ip obrigatório") try: @@ -8602,6 +8762,7 @@ async def oci_db_system_action(db_system_id: str, req: dict, u=Depends(current_u raise HTTPException(400, "Ação inválida. Use START ou STOP.") if not oci_config_id: raise HTTPException(400, "oci_config_id obrigatório") + _verify_config_access("oci", oci_config_id, u) try: import oci config = _get_oci_config(oci_config_id) @@ -8626,6 +8787,7 @@ async def oci_mysql_action(db_system_id: str, req: dict, u=Depends(current_user) raise HTTPException(400, "Ação inválida. Use start ou stop.") if not oci_config_id: raise HTTPException(400, "oci_config_id obrigatório") + _verify_config_access("oci", oci_config_id, u) try: import oci config = _get_oci_config(oci_config_id) @@ -8651,6 +8813,7 @@ async def oci_container_instance_action(ci_id: str, req: dict, u=Depends(current raise HTTPException(400, "Ação inválida. Use start ou stop.") if not oci_config_id: raise HTTPException(400, "oci_config_id obrigatório") + _verify_config_access("oci", oci_config_id, u) try: import oci config = _get_oci_config(oci_config_id) @@ -8677,6 +8840,7 @@ async def get_my_ip(request: Request): @app.get("/api/terraform/resources") async def tf_list_resources(oci_config_id: str = Query(...), compartment_id: str = Query(...), region: str = Query(None), u=Depends(current_user)): """List existing OCI resources in a compartment for terraform context.""" + _verify_config_access("oci", oci_config_id, u) try: loop = asyncio.get_event_loop() resources = await loop.run_in_executor( @@ -8969,6 +9133,9 @@ async def tf_download(wid: str, token: str = Query(None), cred: HTTPAuthorizatio @app.post("/api/terraform/workspaces/{wid}/cancel") async def tf_cancel(wid: str, u=Depends(current_user)): + with db() as c: + ws = c.execute("SELECT user_id FROM terraform_workspaces WHERE id=?", (wid,)).fetchone() + if not ws or ws["user_id"] != u["id"]: raise HTTPException(404) proc = _running_terraform.get(wid) if proc: proc.terminate() @@ -9446,8 +9613,12 @@ provider "oci" {{ # ── Audit ───────────────────────────────────────────────────────────────────── @app.get("/api/audit-log") -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() +async def audit_log(skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), u=Depends(current_user)): + with db() as c: + if u["role"] == "admin": + rows = c.execute("SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?", (limit, skip)).fetchall() + else: + rows = c.execute("SELECT * FROM audit_log WHERE user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?", (u["id"], limit, skip)).fetchall() return [dict(r) for r in rows] # ── Config Logs ─────────────────────────────────────────────────────────────── @@ -9670,10 +9841,10 @@ async def import_config(file: UploadFile = File(...), u=Depends(require("admin") for srv in data.get("mcp_servers", []): if c.execute("SELECT 1 FROM mcp_servers WHERE id=?", (srv["id"],)).fetchone(): counts["skipped"] += 1; continue - c.execute("INSERT INTO mcp_servers (id,user_id,name,description,server_type,command,args,env_vars,url,module_path,tools,linked_adb_id,is_active,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + c.execute("INSERT INTO mcp_servers (id,user_id,name,description,server_type,command,args,env_vars,url,module_path,tools,linked_adb_id,is_active,is_global,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (srv["id"], srv.get("user_id", u["id"]), srv.get("name",""), srv.get("description"), srv.get("server_type","stdio"), srv.get("command"), srv.get("args"), srv.get("env_vars"), srv.get("url"), srv.get("module_path"), - srv.get("tools"), srv.get("linked_adb_id"), srv.get("is_active",1), srv.get("created_at"))) + srv.get("tools"), srv.get("linked_adb_id"), srv.get("is_active",1), srv.get("is_global",0), srv.get("created_at"))) counts["mcp_servers"] += 1 # System prompts for p in data.get("system_prompts", []): @@ -9691,10 +9862,10 @@ async def import_config(file: UploadFile = File(...), u=Depends(require("admin") for cfg in data.get("adb_vector_configs", []): if c.execute("SELECT 1 FROM adb_vector_configs WHERE id=?", (cfg["id"],)).fetchone(): counts["skipped"] += 1; continue - c.execute("INSERT INTO adb_vector_configs (id,user_id,config_name,dsn,username,password_enc,wallet_dir,wallet_password_enc,table_name,use_mtls,is_active,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + c.execute("INSERT INTO adb_vector_configs (id,user_id,config_name,dsn,username,password_enc,wallet_dir,wallet_password_enc,table_name,use_mtls,is_active,is_global,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", (cfg["id"], cfg.get("user_id", u["id"]), cfg.get("config_name",""), cfg.get("dsn",""), cfg.get("username",""), cfg.get("password_enc",""), cfg.get("wallet_dir"), cfg.get("wallet_password_enc"), cfg.get("table_name","CIS_EMBEDDINGS"), - cfg.get("use_mtls",1), cfg.get("is_active",1), cfg.get("created_at"))) + cfg.get("use_mtls",1), cfg.get("is_active",1), cfg.get("is_global",0), cfg.get("created_at"))) counts["adb_vector_configs"] += 1 # ADB vector tables for t in data.get("adb_vector_tables", []): diff --git a/frontend-react/src/api/endpoints/reports.ts b/frontend-react/src/api/endpoints/reports.ts index 5f00746..7348b2c 100644 --- a/frontend-react/src/api/endpoints/reports.ts +++ b/frontend-react/src/api/endpoints/reports.ts @@ -127,9 +127,15 @@ export const reportsApi = { summary: (rid: string) => client.get(`/reports/${rid}/summary`) as unknown as Promise, - htmlUrl: (rid: string) => `/api/reports/${rid}/html?_t=${Date.now()}`, + htmlUrl: (rid: string) => { + const token = localStorage.getItem('t') || ''; + return `/api/reports/${rid}/html?token=${encodeURIComponent(token)}&_t=${Date.now()}`; + }, - complianceReportUrl: (rid: string) => `/api/reports/${rid}/compliance-report?_t=${Date.now()}`, + complianceReportUrl: (rid: string) => { + const token = localStorage.getItem('t') || ''; + return `/api/reports/${rid}/compliance-report?token=${encodeURIComponent(token)}&_t=${Date.now()}`; + }, generateComplianceReport: (rid: string) => client.post(`/reports/${rid}/compliance-report/generate`) as unknown as Promise<{ status: string; has_rag: boolean }>, diff --git a/frontend-react/src/pages/config/AdbConfigPage.tsx b/frontend-react/src/pages/config/AdbConfigPage.tsx index b4ea031..5b0d770 100644 --- a/frontend-react/src/pages/config/AdbConfigPage.tsx +++ b/frontend-react/src/pages/config/AdbConfigPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useAppStore } from '@/stores/app'; +import { useAuthStore } from '@/stores/auth'; import { useI18n } from '@/i18n'; import { adbApi, type AdbConfigFull } from '@/api/endpoints/adb'; import { @@ -24,6 +25,7 @@ function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) { /* ── Main ── */ export default function AdbConfigPage() { const { genaiCfg, embModels } = useAppStore(); + const isAdmin = useAuthStore((s) => s.user?.role === 'admin'); const { t } = useI18n(); const [configs, setConfigs] = useState([]); @@ -321,7 +323,12 @@ export default function AdbConfigPage() { onMouseLeave={(e) => { if (editing?.id !== c.id) e.currentTarget.style.background = ''; }} > - {c.config_name} +
+ {c.config_name} + {(c as any).is_global === 1 && ( + GLOBAL + )} +
{c.dsn} @@ -422,25 +429,29 @@ export default function AdbConfigPage() {
- + {(isAdmin || !(c as any).is_global) && ( + + )} - {confirmDelete === c.id ? ( -
- + +
+ ) : ( + - -
- ) : ( - + ) )}
{testResults[c.id] && ( diff --git a/frontend-react/src/pages/config/McpServersPage.tsx b/frontend-react/src/pages/config/McpServersPage.tsx index 49e2fb0..fb83bc5 100644 --- a/frontend-react/src/pages/config/McpServersPage.tsx +++ b/frontend-react/src/pages/config/McpServersPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useAppStore } from '@/stores/app'; +import { useAuthStore } from '@/stores/auth'; import { useI18n } from '@/i18n'; import { mcpApi, type McpServerFull, type McpServerPayload, type McpToolDef } from '@/api/endpoints/mcp'; import { @@ -39,6 +40,7 @@ function TypeBadge({ type }: { type: string }) { /* ── Main ── */ export default function McpServersPage() { const { adbCfg } = useAppStore(); + const isAdmin = useAuthStore((s) => s.user?.role === 'admin'); const { t } = useI18n(); const [servers, setServers] = useState([]); @@ -271,6 +273,9 @@ export default function McpServersPage() {
{srv.name} + {(srv as any).is_global === 1 && ( + GLOBAL + )}
- - - {confirmDelete === srv.id ? ( -
- - -
- ) : ( - + {confirmDelete === srv.id ? ( +
+ + +
+ ) : ( + + )} + )}