feat(rbac): hide unauthorized menu sections and enforce route guards
This commit is contained in:
128
backend/src/core/__tests__/test_superset_profile_lookup.py
Normal file
128
backend/src/core/__tests__/test_superset_profile_lookup.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# [DEF:backend.src.core.__tests__.test_superset_profile_lookup:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: tests, superset, profile, lookup, fallback, sorting
|
||||
# @PURPOSE: Verifies Superset profile lookup adapter payload normalization and fallback error precedence.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: TESTS -> backend.src.core.superset_profile_lookup
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
backend_dir = str(Path(__file__).parent.parent.parent.parent.resolve())
|
||||
if backend_dir not in sys.path:
|
||||
sys.path.insert(0, backend_dir)
|
||||
|
||||
from src.core.superset_profile_lookup import SupersetAccountLookupAdapter
|
||||
from src.core.utils.network import AuthenticationError, SupersetAPIError
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:_RecordingNetworkClient:Class]
|
||||
# @PURPOSE: Records request payloads and returns scripted responses for deterministic adapter tests.
|
||||
class _RecordingNetworkClient:
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes scripted network responses.
|
||||
# @PRE: scripted_responses is ordered per expected request sequence.
|
||||
# @POST: Instance stores response script and captures subsequent request calls.
|
||||
def __init__(self, scripted_responses: List[Any]):
|
||||
self._scripted_responses = scripted_responses
|
||||
self.calls: List[Dict[str, Any]] = []
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:request:Function]
|
||||
# @PURPOSE: Mimics APIClient.request while capturing call arguments.
|
||||
# @PRE: method and endpoint are provided.
|
||||
# @POST: Returns scripted response or raises scripted exception.
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
self.calls.append(
|
||||
{
|
||||
"method": method,
|
||||
"endpoint": endpoint,
|
||||
"params": params or {},
|
||||
}
|
||||
)
|
||||
index = len(self.calls) - 1
|
||||
response = self._scripted_responses[index]
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
return response
|
||||
# [/DEF:request:Function]
|
||||
# [/DEF:_RecordingNetworkClient:Class]
|
||||
|
||||
|
||||
# [DEF:test_get_users_page_sends_lowercase_order_direction:Function]
|
||||
# @PURPOSE: Ensures adapter sends lowercase order_direction compatible with Superset rison schema.
|
||||
# @PRE: Adapter is initialized with recording network client.
|
||||
# @POST: First request query payload contains order_direction='asc' for asc sort.
|
||||
def test_get_users_page_sends_lowercase_order_direction():
|
||||
client = _RecordingNetworkClient(
|
||||
scripted_responses=[{"result": [{"username": "admin"}], "count": 1}]
|
||||
)
|
||||
adapter = SupersetAccountLookupAdapter(network_client=client, environment_id="ss-dev")
|
||||
|
||||
adapter.get_users_page(
|
||||
search="admin",
|
||||
page_index=0,
|
||||
page_size=20,
|
||||
sort_column="username",
|
||||
sort_order="asc",
|
||||
)
|
||||
|
||||
sent_query = json.loads(client.calls[0]["params"]["q"])
|
||||
assert sent_query["order_direction"] == "asc"
|
||||
# [/DEF:test_get_users_page_sends_lowercase_order_direction:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_users_page_preserves_primary_schema_error_over_fallback_auth_error:Function]
|
||||
# @PURPOSE: Ensures fallback auth error does not mask primary schema/query failure.
|
||||
# @PRE: Primary endpoint fails with SupersetAPIError and fallback fails with AuthenticationError.
|
||||
# @POST: Raised exception remains primary SupersetAPIError (non-auth) to preserve root cause.
|
||||
def test_get_users_page_preserves_primary_schema_error_over_fallback_auth_error():
|
||||
client = _RecordingNetworkClient(
|
||||
scripted_responses=[
|
||||
SupersetAPIError("API Error 400: bad rison schema"),
|
||||
AuthenticationError(),
|
||||
]
|
||||
)
|
||||
adapter = SupersetAccountLookupAdapter(network_client=client, environment_id="ss-dev")
|
||||
|
||||
with pytest.raises(SupersetAPIError) as exc_info:
|
||||
adapter.get_users_page(sort_order="asc")
|
||||
|
||||
assert "API Error 400" in str(exc_info.value)
|
||||
assert not isinstance(exc_info.value, AuthenticationError)
|
||||
# [/DEF:test_get_users_page_preserves_primary_schema_error_over_fallback_auth_error:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_users_page_uses_fallback_endpoint_when_primary_fails:Function]
|
||||
# @PURPOSE: Verifies adapter retries second users endpoint and succeeds when fallback is healthy.
|
||||
# @PRE: Primary endpoint fails; fallback returns valid users payload.
|
||||
# @POST: Result status is success and both endpoints were attempted in order.
|
||||
def test_get_users_page_uses_fallback_endpoint_when_primary_fails():
|
||||
client = _RecordingNetworkClient(
|
||||
scripted_responses=[
|
||||
SupersetAPIError("Primary endpoint failed"),
|
||||
{"result": [{"username": "admin"}], "count": 1},
|
||||
]
|
||||
)
|
||||
adapter = SupersetAccountLookupAdapter(network_client=client, environment_id="ss-dev")
|
||||
|
||||
result = adapter.get_users_page()
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert [call["endpoint"] for call in client.calls] == ["/security/users/", "/security/users"]
|
||||
# [/DEF:test_get_users_page_uses_fallback_endpoint_when_primary_fails:Function]
|
||||
|
||||
|
||||
# [/DEF:backend.src.core.__tests__.test_superset_profile_lookup:Module]
|
||||
@@ -12,6 +12,7 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from ...models.auth import User, Role, Permission
|
||||
from ...models.profile import UserDashboardPreference
|
||||
from ..logger import belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
@@ -109,6 +110,38 @@ class AuthRepository:
|
||||
).first()
|
||||
# [/DEF:get_permission_by_resource_action:Function]
|
||||
|
||||
# [DEF:get_user_dashboard_preference:Function]
|
||||
# @PURPOSE: Retrieves dashboard preference by owner user ID.
|
||||
# @PRE: user_id is a string.
|
||||
# @POST: Returns UserDashboardPreference if found, else None.
|
||||
# @PARAM: user_id (str) - Preference owner identifier.
|
||||
# @RETURN: Optional[UserDashboardPreference] - Found preference or None.
|
||||
def get_user_dashboard_preference(self, user_id: str) -> Optional[UserDashboardPreference]:
|
||||
with belief_scope("AuthRepository.get_user_dashboard_preference"):
|
||||
return (
|
||||
self.db.query(UserDashboardPreference)
|
||||
.filter(UserDashboardPreference.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
# [/DEF:get_user_dashboard_preference:Function]
|
||||
|
||||
# [DEF:save_user_dashboard_preference:Function]
|
||||
# @PURPOSE: Persists dashboard preference entity and returns refreshed row.
|
||||
# @PRE: preference is a valid UserDashboardPreference entity.
|
||||
# @POST: Preference is committed and refreshed in database.
|
||||
# @PARAM: preference (UserDashboardPreference) - Preference entity to persist.
|
||||
# @RETURN: UserDashboardPreference - Persisted preference row.
|
||||
def save_user_dashboard_preference(
|
||||
self,
|
||||
preference: UserDashboardPreference,
|
||||
) -> UserDashboardPreference:
|
||||
with belief_scope("AuthRepository.save_user_dashboard_preference"):
|
||||
self.db.add(preference)
|
||||
self.db.commit()
|
||||
self.db.refresh(preference)
|
||||
return preference
|
||||
# [/DEF:save_user_dashboard_preference:Function]
|
||||
|
||||
# [DEF:list_permissions:Function]
|
||||
# @PURPOSE: Lists all available permissions.
|
||||
# @POST: Returns a list of all Permission objects.
|
||||
|
||||
@@ -20,6 +20,7 @@ from ..models import auth as _auth_models # noqa: F401
|
||||
from ..models import config as _config_models # noqa: F401
|
||||
from ..models import llm as _llm_models # noqa: F401
|
||||
from ..models import assistant as _assistant_models # noqa: F401
|
||||
from ..models import profile as _profile_models # noqa: F401
|
||||
from .logger import belief_scope
|
||||
from .auth.config import auth_config
|
||||
import os
|
||||
|
||||
@@ -159,14 +159,37 @@ class SupersetClient:
|
||||
|
||||
# Map fields to DashboardMetadata schema
|
||||
result = []
|
||||
for dash in dashboards:
|
||||
owners = self._extract_owner_labels(dash.get("owners"))
|
||||
max_debug_samples = 12
|
||||
for index, dash in enumerate(dashboards):
|
||||
raw_owners = dash.get("owners")
|
||||
raw_created_by = dash.get("created_by")
|
||||
raw_changed_by = dash.get("changed_by")
|
||||
raw_changed_by_name = dash.get("changed_by_name")
|
||||
|
||||
owners = self._extract_owner_labels(raw_owners)
|
||||
# No per-dashboard detail requests here: keep list endpoint O(1).
|
||||
if not owners:
|
||||
owners = self._extract_owner_labels(
|
||||
[dash.get("created_by"), dash.get("changed_by")],
|
||||
[raw_created_by, raw_changed_by],
|
||||
)
|
||||
|
||||
projected_created_by = self._extract_user_display(
|
||||
None,
|
||||
raw_created_by,
|
||||
)
|
||||
projected_modified_by = self._extract_user_display(
|
||||
raw_changed_by_name,
|
||||
raw_changed_by,
|
||||
)
|
||||
|
||||
raw_owner_usernames: List[str] = []
|
||||
if isinstance(raw_owners, list):
|
||||
for owner_payload in raw_owners:
|
||||
if isinstance(owner_payload, dict):
|
||||
owner_username = self._sanitize_user_text(owner_payload.get("username"))
|
||||
if owner_username:
|
||||
raw_owner_usernames.append(owner_username)
|
||||
|
||||
result.append({
|
||||
"id": dash.get("id"),
|
||||
"slug": dash.get("slug"),
|
||||
@@ -174,16 +197,26 @@ class SupersetClient:
|
||||
"url": dash.get("url"),
|
||||
"last_modified": dash.get("changed_on_utc"),
|
||||
"status": "published" if dash.get("published") else "draft",
|
||||
"created_by": self._extract_user_display(
|
||||
None,
|
||||
dash.get("created_by"),
|
||||
),
|
||||
"modified_by": self._extract_user_display(
|
||||
dash.get("changed_by_name"),
|
||||
dash.get("changed_by"),
|
||||
),
|
||||
"created_by": projected_created_by,
|
||||
"modified_by": projected_modified_by,
|
||||
"owners": owners,
|
||||
})
|
||||
|
||||
if index < max_debug_samples:
|
||||
app_logger.reflect(
|
||||
"[REFLECT] Dashboard actor projection sample "
|
||||
f"(env={getattr(self.env, 'id', None)}, dashboard_id={dash.get('id')}, "
|
||||
f"raw_owners={raw_owners!r}, raw_owner_usernames={raw_owner_usernames!r}, "
|
||||
f"raw_created_by={raw_created_by!r}, raw_changed_by={raw_changed_by!r}, "
|
||||
f"raw_changed_by_name={raw_changed_by_name!r}, projected_owners={owners!r}, "
|
||||
f"projected_created_by={projected_created_by!r}, projected_modified_by={projected_modified_by!r})"
|
||||
)
|
||||
|
||||
app_logger.reflect(
|
||||
"[REFLECT] Dashboard actor projection summary "
|
||||
f"(env={getattr(self.env, 'id', None)}, dashboards={len(result)}, "
|
||||
f"sampled={min(len(result), max_debug_samples)})"
|
||||
)
|
||||
return result
|
||||
# [/DEF:get_dashboards_summary:Function]
|
||||
|
||||
|
||||
238
backend/src/core/superset_profile_lookup.py
Normal file
238
backend/src/core/superset_profile_lookup.py
Normal file
@@ -0,0 +1,238 @@
|
||||
# [DEF:backend.src.core.superset_profile_lookup:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: superset, users, lookup, profile, pagination, normalization
|
||||
# @PURPOSE: Provides environment-scoped Superset account lookup adapter with stable normalized output.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.utils.network.APIClient
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.logger
|
||||
#
|
||||
# @INVARIANT: Adapter never leaks raw upstream payload shape to API consumers.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .logger import logger, belief_scope
|
||||
from .utils.network import APIClient, AuthenticationError, SupersetAPIError
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:SupersetAccountLookupAdapter:Class]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Lookup Superset users and normalize candidates for profile binding.
|
||||
class SupersetAccountLookupAdapter:
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes lookup adapter with authenticated API client and environment context.
|
||||
# @PRE: network_client supports request(method, endpoint, params=...).
|
||||
# @POST: Adapter is ready to perform users lookup requests.
|
||||
def __init__(self, network_client: APIClient, environment_id: str):
|
||||
self.network_client = network_client
|
||||
self.environment_id = str(environment_id or "")
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:get_users_page:Function]
|
||||
# @PURPOSE: Fetch one users page from Superset with passthrough search/sort parameters.
|
||||
# @PRE: page_index >= 0 and page_size >= 1.
|
||||
# @POST: Returns deterministic payload with normalized items and total count.
|
||||
# @RETURN: Dict[str, Any]
|
||||
def get_users_page(
|
||||
self,
|
||||
search: Optional[str] = None,
|
||||
page_index: int = 0,
|
||||
page_size: int = 20,
|
||||
sort_column: str = "username",
|
||||
sort_order: str = "desc",
|
||||
) -> Dict[str, Any]:
|
||||
with belief_scope("SupersetAccountLookupAdapter.get_users_page"):
|
||||
normalized_page_index = max(int(page_index), 0)
|
||||
normalized_page_size = max(int(page_size), 1)
|
||||
|
||||
normalized_sort_column = str(sort_column or "username").strip().lower() or "username"
|
||||
normalized_sort_order = str(sort_order or "desc").strip().lower()
|
||||
if normalized_sort_order not in {"asc", "desc"}:
|
||||
normalized_sort_order = "desc"
|
||||
|
||||
query: Dict[str, Any] = {
|
||||
"page": normalized_page_index,
|
||||
"page_size": normalized_page_size,
|
||||
"order_column": normalized_sort_column,
|
||||
"order_direction": normalized_sort_order,
|
||||
}
|
||||
|
||||
normalized_search = str(search or "").strip()
|
||||
if normalized_search:
|
||||
query["filters"] = [{"col": "username", "opr": "ct", "value": normalized_search}]
|
||||
|
||||
logger.reason(
|
||||
"[REASON] Lookup Superset users "
|
||||
f"(env={self.environment_id}, page={normalized_page_index}, page_size={normalized_page_size})"
|
||||
)
|
||||
logger.reflect(
|
||||
"[REFLECT] Prepared Superset users lookup query "
|
||||
f"(env={self.environment_id}, order_column={normalized_sort_column}, "
|
||||
f"normalized_sort_order={normalized_sort_order}, "
|
||||
f"payload_order_direction={query.get('order_direction')})"
|
||||
)
|
||||
|
||||
primary_error: Optional[Exception] = None
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt_index, endpoint in enumerate(("/security/users/", "/security/users"), start=1):
|
||||
try:
|
||||
logger.reason(
|
||||
"[REASON] Users lookup request attempt "
|
||||
f"(env={self.environment_id}, attempt={attempt_index}, endpoint={endpoint})"
|
||||
)
|
||||
response = self.network_client.request(
|
||||
method="GET",
|
||||
endpoint=endpoint,
|
||||
params={"q": json.dumps(query)},
|
||||
)
|
||||
logger.reflect(
|
||||
"[REFLECT] Users lookup endpoint succeeded "
|
||||
f"(env={self.environment_id}, attempt={attempt_index}, endpoint={endpoint})"
|
||||
)
|
||||
return self._normalize_lookup_payload(
|
||||
response=response,
|
||||
page_index=normalized_page_index,
|
||||
page_size=normalized_page_size,
|
||||
)
|
||||
except Exception as exc:
|
||||
if primary_error is None:
|
||||
primary_error = exc
|
||||
last_error = exc
|
||||
cause = getattr(exc, "__cause__", None)
|
||||
cause_response = getattr(cause, "response", None)
|
||||
status_code = getattr(cause_response, "status_code", None)
|
||||
logger.explore(
|
||||
"[EXPLORE] Users lookup endpoint failed "
|
||||
f"(env={self.environment_id}, attempt={attempt_index}, endpoint={endpoint}, "
|
||||
f"error_type={type(exc).__name__}, status_code={status_code}, "
|
||||
f"payload_order_direction={query.get('order_direction')}): {exc}"
|
||||
)
|
||||
|
||||
if last_error is not None:
|
||||
selected_error: Exception = last_error
|
||||
if (
|
||||
primary_error is not None
|
||||
and primary_error is not last_error
|
||||
and isinstance(last_error, AuthenticationError)
|
||||
and not isinstance(primary_error, AuthenticationError)
|
||||
):
|
||||
selected_error = primary_error
|
||||
logger.reflect(
|
||||
"[REFLECT] Preserving primary lookup failure over fallback auth error "
|
||||
f"(env={self.environment_id}, primary_error_type={type(primary_error).__name__}, "
|
||||
f"fallback_error_type={type(last_error).__name__})"
|
||||
)
|
||||
|
||||
logger.explore(
|
||||
"[EXPLORE] All Superset users lookup endpoints failed "
|
||||
f"(env={self.environment_id}, payload_order_direction={query.get('order_direction')}, "
|
||||
f"selected_error_type={type(selected_error).__name__})"
|
||||
)
|
||||
raise selected_error
|
||||
raise SupersetAPIError("Superset users lookup failed without explicit error")
|
||||
# [/DEF:get_users_page:Function]
|
||||
|
||||
# [DEF:_normalize_lookup_payload:Function]
|
||||
# @PURPOSE: Convert Superset users response variants into stable candidates payload.
|
||||
# @PRE: response can be dict/list in any supported upstream shape.
|
||||
# @POST: Output contains canonical keys: status, environment_id, page_index, page_size, total, items.
|
||||
# @RETURN: Dict[str, Any]
|
||||
def _normalize_lookup_payload(
|
||||
self,
|
||||
response: Any,
|
||||
page_index: int,
|
||||
page_size: int,
|
||||
) -> Dict[str, Any]:
|
||||
with belief_scope("SupersetAccountLookupAdapter._normalize_lookup_payload"):
|
||||
payload = response
|
||||
if isinstance(payload, dict) and isinstance(payload.get("result"), dict):
|
||||
payload = payload.get("result")
|
||||
|
||||
raw_items: List[Any] = []
|
||||
total = 0
|
||||
|
||||
if isinstance(payload, dict):
|
||||
if isinstance(payload.get("result"), list):
|
||||
raw_items = payload.get("result") or []
|
||||
total = int(payload.get("count", len(raw_items)) or 0)
|
||||
elif isinstance(payload.get("users"), list):
|
||||
raw_items = payload.get("users") or []
|
||||
total = int(payload.get("total", len(raw_items)) or 0)
|
||||
elif isinstance(payload.get("items"), list):
|
||||
raw_items = payload.get("items") or []
|
||||
total = int(payload.get("total", len(raw_items)) or 0)
|
||||
elif isinstance(payload, list):
|
||||
raw_items = payload
|
||||
total = len(raw_items)
|
||||
|
||||
normalized_items: List[Dict[str, Any]] = []
|
||||
seen_usernames = set()
|
||||
|
||||
for raw_user in raw_items:
|
||||
candidate = self.normalize_user_payload(raw_user)
|
||||
username_key = str(candidate.get("username") or "").strip().lower()
|
||||
if not username_key:
|
||||
continue
|
||||
if username_key in seen_usernames:
|
||||
continue
|
||||
seen_usernames.add(username_key)
|
||||
normalized_items.append(candidate)
|
||||
|
||||
logger.reflect(
|
||||
"[REFLECT] Normalized lookup payload "
|
||||
f"(env={self.environment_id}, items={len(normalized_items)}, total={max(total, len(normalized_items))})"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"environment_id": self.environment_id,
|
||||
"page_index": max(int(page_index), 0),
|
||||
"page_size": max(int(page_size), 1),
|
||||
"total": max(int(total), len(normalized_items)),
|
||||
"items": normalized_items,
|
||||
}
|
||||
# [/DEF:_normalize_lookup_payload:Function]
|
||||
|
||||
# [DEF:normalize_user_payload:Function]
|
||||
# @PURPOSE: Project raw Superset user object to canonical candidate shape.
|
||||
# @PRE: raw_user may have heterogenous key names between Superset versions.
|
||||
# @POST: Returns normalized candidate keys (environment_id, username, display_name, email, is_active).
|
||||
# @RETURN: Dict[str, Any]
|
||||
def normalize_user_payload(self, raw_user: Any) -> Dict[str, Any]:
|
||||
if not isinstance(raw_user, dict):
|
||||
raw_user = {}
|
||||
|
||||
username = str(
|
||||
raw_user.get("username")
|
||||
or raw_user.get("userName")
|
||||
or raw_user.get("name")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
full_name = str(raw_user.get("full_name") or "").strip()
|
||||
first_name = str(raw_user.get("first_name") or "").strip()
|
||||
last_name = str(raw_user.get("last_name") or "").strip()
|
||||
display_name = full_name or " ".join(
|
||||
part for part in [first_name, last_name] if part
|
||||
).strip()
|
||||
if not display_name:
|
||||
display_name = username or None
|
||||
|
||||
email = str(raw_user.get("email") or "").strip() or None
|
||||
is_active_raw = raw_user.get("is_active")
|
||||
is_active = bool(is_active_raw) if is_active_raw is not None else None
|
||||
|
||||
return {
|
||||
"environment_id": self.environment_id,
|
||||
"username": username,
|
||||
"display_name": display_name,
|
||||
"email": email,
|
||||
"is_active": is_active,
|
||||
}
|
||||
# [/DEF:normalize_user_payload:Function]
|
||||
# [/DEF:SupersetAccountLookupAdapter:Class]
|
||||
|
||||
# [/DEF:backend.src.core.superset_profile_lookup:Module]
|
||||
Reference in New Issue
Block a user