From 449053bf8a3a77f1e48f54dfc9289e21c475e2db Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr Date: Thu, 9 Apr 2026 16:30:24 +0200 Subject: [PATCH] fix: enforce per-table access control on catalog profile endpoints Add can_access_table check to GET /api/catalog/profile/{table_name} and POST /api/catalog/profile/{table_name}/refresh, returning 403 for unauthorized tables. Update test_api_complete to cover new 403 behaviour and fix the existing 404 test to use admin token. --- app/api/catalog.py | 6 ++++++ tests/test_api_complete.py | 21 ++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/api/catalog.py b/app/api/catalog.py index 82c1d87..a8e82a1 100644 --- a/app/api/catalog.py +++ b/app/api/catalog.py @@ -22,6 +22,9 @@ async def get_table_profile( conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Get profiler data for a specific table.""" + # Check table-level access + if not can_access_table(user, table_name, conn): + raise HTTPException(status_code=403, detail=f"Access denied to table '{table_name}'") repo = ProfileRepository(conn) profile = repo.get(table_name) if not profile: @@ -100,6 +103,9 @@ async def refresh_profile( conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Re-generate profile for a table on demand.""" + # Check table-level access + if not can_access_table(user, table_name, conn): + raise HTTPException(status_code=403, detail=f"Access denied to table '{table_name}'") from src.profiler import profile_table, TableInfo data_dir = _get_data_dir() diff --git a/tests/test_api_complete.py b/tests/test_api_complete.py index 7937303..56595fb 100644 --- a/tests/test_api_complete.py +++ b/tests/test_api_complete.py @@ -53,9 +53,28 @@ class TestCatalog: assert resp.status_code == 200 def test_catalog_profile_not_found(self, client): - resp = client["client"].get("/api/catalog/profile/nonexistent", headers=_h(client["analyst"])) + # Admin can see 404 for truly missing tables (bypasses access control) + resp = client["client"].get("/api/catalog/profile/nonexistent", headers=_h(client["admin"])) assert resp.status_code == 404 + def test_catalog_profile_access_denied_for_analyst(self, client): + # Non-registered (non-public) table returns 403 for analyst + resp = client["client"].get("/api/catalog/profile/private_table", headers=_h(client["analyst"])) + assert resp.status_code == 403 + + def test_catalog_profile_refresh_access_denied_for_analyst(self, client): + # Refresh endpoint also enforces access control + resp = client["client"].post("/api/catalog/profile/private_table/refresh", headers=_h(client["analyst"])) + assert resp.status_code == 403 + + def test_catalog_profile_public_table_accessible_to_analyst(self, client): + # Register a public table — analyst can access its profile (404 since no profile data) + client["client"].post("/api/admin/register-table", + json={"name": "public_table", "source_type": "keboola"}, + headers=_h(client["admin"])) + resp = client["client"].get("/api/catalog/profile/public_table", headers=_h(client["analyst"])) + assert resp.status_code == 404 # access granted, but no profile data yet + # ---- Telegram ----