feat(rbac): hide unauthorized menu sections and enforce route guards

This commit is contained in:
2026-03-06 10:50:28 +03:00
parent a5086f3eef
commit e7cb5237d3
43 changed files with 4071 additions and 245 deletions

View File

@@ -11,9 +11,11 @@ from fastapi.testclient import TestClient
from src.app import app
from src.api.routes.dashboards import DashboardsResponse
from src.dependencies import get_current_user, has_permission, get_config_manager, get_task_manager, get_resource_service, get_mapping_service
from src.core.database import get_db
# Global mock user for get_current_user dependency overrides
mock_user = MagicMock()
mock_user.id = "u-1"
mock_user.username = "testuser"
mock_user.roles = []
admin_role = MagicMock()
@@ -27,11 +29,14 @@ def mock_deps():
resource_service = MagicMock()
mapping_service = MagicMock()
db = MagicMock()
app.dependency_overrides[get_config_manager] = lambda: config_manager
app.dependency_overrides[get_task_manager] = lambda: task_manager
app.dependency_overrides[get_resource_service] = lambda: resource_service
app.dependency_overrides[get_mapping_service] = lambda: mapping_service
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[has_permission("plugin:migration", "READ")] = lambda: mock_user
app.dependency_overrides[has_permission("plugin:migration", "EXECUTE")] = lambda: mock_user
@@ -42,7 +47,8 @@ def mock_deps():
"config": config_manager,
"task": task_manager,
"resource": resource_service,
"mapping": mapping_service
"mapping": mapping_service,
"db": db,
}
app.dependency_overrides.clear()
@@ -495,4 +501,309 @@ def test_get_dashboard_thumbnail_success(mock_deps):
# [/DEF:test_get_dashboard_thumbnail_success:Function]
# [DEF:_build_profile_preference_stub:Function]
# @PURPOSE: Creates profile preference payload stub for dashboards filter contract tests.
# @PRE: username can be empty; enabled indicates profile-default toggle state.
# @POST: Returns object compatible with ProfileService.get_my_preference contract.
def _build_profile_preference_stub(username: str, enabled: bool):
preference = MagicMock()
preference.superset_username = username
preference.superset_username_normalized = str(username or "").strip().lower() or None
preference.show_only_my_dashboards = bool(enabled)
payload = MagicMock()
payload.preference = preference
return payload
# [/DEF:_build_profile_preference_stub:Function]
# [DEF:_matches_actor_case_insensitive:Function]
# @PURPOSE: Applies trim + case-insensitive owners OR modified_by matching used by route contract tests.
# @PRE: owners can be None or list-like values.
# @POST: Returns True when bound username matches any owner or modified_by.
def _matches_actor_case_insensitive(bound_username, owners, modified_by):
normalized_bound = str(bound_username or "").strip().lower()
if not normalized_bound:
return False
owner_tokens = []
for owner in owners or []:
token = str(owner or "").strip().lower()
if token:
owner_tokens.append(token)
modified_token = str(modified_by or "").strip().lower()
return normalized_bound in owner_tokens or bool(modified_token and modified_token == normalized_bound)
# [/DEF:_matches_actor_case_insensitive:Function]
# [DEF:test_get_dashboards_profile_filter_contract_owners_or_modified_by:Function]
# @TEST: GET /api/dashboards applies profile-default filter with owners OR modified_by trim+case-insensitive semantics.
# @PRE: Current user has enabled profile-default preference and bound username.
# @POST: Response includes only matching dashboards and effective_profile_filter metadata.
def test_get_dashboards_profile_filter_contract_owners_or_modified_by(mock_deps):
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_deps["task"].get_all_tasks.return_value = []
mock_deps["resource"].get_dashboards_with_status = AsyncMock(return_value=[
{
"id": 1,
"title": "Owner Match",
"slug": "owner-match",
"owners": [" John_Doe "],
"modified_by": "someone_else",
},
{
"id": 2,
"title": "Modifier Match",
"slug": "modifier-match",
"owners": ["analytics-team"],
"modified_by": " JOHN_DOE ",
},
{
"id": 3,
"title": "No Match",
"slug": "no-match",
"owners": ["another-user"],
"modified_by": "nobody",
},
])
with patch("src.api.routes.dashboards.ProfileService") as profile_service_cls:
profile_service = MagicMock()
profile_service.get_my_preference.return_value = _build_profile_preference_stub(
username=" JOHN_DOE ",
enabled=True,
)
profile_service.matches_dashboard_actor.side_effect = _matches_actor_case_insensitive
profile_service_cls.return_value = profile_service
response = client.get(
"/api/dashboards?env_id=prod&page_context=dashboards_main&apply_profile_default=true"
)
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 2
assert {item["id"] for item in payload["dashboards"]} == {1, 2}
assert payload["effective_profile_filter"]["applied"] is True
assert payload["effective_profile_filter"]["source_page"] == "dashboards_main"
assert payload["effective_profile_filter"]["override_show_all"] is False
assert payload["effective_profile_filter"]["username"] == "john_doe"
assert payload["effective_profile_filter"]["match_logic"] == "owners_or_modified_by"
# [/DEF:test_get_dashboards_profile_filter_contract_owners_or_modified_by:Function]
# [DEF:test_get_dashboards_override_show_all_contract:Function]
# @TEST: GET /api/dashboards honors override_show_all and disables profile-default filter for current page.
# @PRE: Profile-default preference exists but override_show_all=true query is provided.
# @POST: Response remains unfiltered and effective_profile_filter.applied is false.
def test_get_dashboards_override_show_all_contract(mock_deps):
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_deps["task"].get_all_tasks.return_value = []
mock_deps["resource"].get_dashboards_with_status = AsyncMock(return_value=[
{"id": 1, "title": "Dash A", "slug": "dash-a", "owners": ["john_doe"], "modified_by": "john_doe"},
{"id": 2, "title": "Dash B", "slug": "dash-b", "owners": ["other"], "modified_by": "other"},
])
with patch("src.api.routes.dashboards.ProfileService") as profile_service_cls:
profile_service = MagicMock()
profile_service.get_my_preference.return_value = _build_profile_preference_stub(
username="john_doe",
enabled=True,
)
profile_service.matches_dashboard_actor.side_effect = _matches_actor_case_insensitive
profile_service_cls.return_value = profile_service
response = client.get(
"/api/dashboards?env_id=prod&page_context=dashboards_main&apply_profile_default=true&override_show_all=true"
)
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 2
assert {item["id"] for item in payload["dashboards"]} == {1, 2}
assert payload["effective_profile_filter"]["applied"] is False
assert payload["effective_profile_filter"]["source_page"] == "dashboards_main"
assert payload["effective_profile_filter"]["override_show_all"] is True
assert payload["effective_profile_filter"]["username"] is None
assert payload["effective_profile_filter"]["match_logic"] is None
profile_service.matches_dashboard_actor.assert_not_called()
# [/DEF:test_get_dashboards_override_show_all_contract:Function]
# [DEF:test_get_dashboards_profile_filter_no_match_results_contract:Function]
# @TEST: GET /api/dashboards returns empty result set when profile-default filter is active and no dashboard actors match.
# @PRE: Profile-default preference is enabled with bound username and all dashboards are non-matching.
# @POST: Response total is 0 with deterministic pagination and active effective_profile_filter metadata.
def test_get_dashboards_profile_filter_no_match_results_contract(mock_deps):
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_deps["task"].get_all_tasks.return_value = []
mock_deps["resource"].get_dashboards_with_status = AsyncMock(return_value=[
{
"id": 101,
"title": "Team Dashboard",
"slug": "team-dashboard",
"owners": ["analytics-team"],
"modified_by": "someone_else",
},
{
"id": 102,
"title": "Ops Dashboard",
"slug": "ops-dashboard",
"owners": ["ops-user"],
"modified_by": "ops-user",
},
])
with patch("src.api.routes.dashboards.ProfileService") as profile_service_cls:
profile_service = MagicMock()
profile_service.get_my_preference.return_value = _build_profile_preference_stub(
username="john_doe",
enabled=True,
)
profile_service.matches_dashboard_actor.side_effect = _matches_actor_case_insensitive
profile_service_cls.return_value = profile_service
response = client.get(
"/api/dashboards?env_id=prod&page_context=dashboards_main&apply_profile_default=true"
)
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 0
assert payload["dashboards"] == []
assert payload["page"] == 1
assert payload["page_size"] == 10
assert payload["total_pages"] == 1
assert payload["effective_profile_filter"]["applied"] is True
assert payload["effective_profile_filter"]["source_page"] == "dashboards_main"
assert payload["effective_profile_filter"]["override_show_all"] is False
assert payload["effective_profile_filter"]["username"] == "john_doe"
assert payload["effective_profile_filter"]["match_logic"] == "owners_or_modified_by"
# [/DEF:test_get_dashboards_profile_filter_no_match_results_contract:Function]
# [DEF:test_get_dashboards_page_context_other_disables_profile_default:Function]
# @TEST: GET /api/dashboards does not auto-apply profile-default filter outside dashboards_main page context.
# @PRE: Profile-default preference exists but page_context=other query is provided.
# @POST: Response remains unfiltered and metadata reflects source_page=other.
def test_get_dashboards_page_context_other_disables_profile_default(mock_deps):
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_deps["task"].get_all_tasks.return_value = []
mock_deps["resource"].get_dashboards_with_status = AsyncMock(return_value=[
{"id": 1, "title": "Dash A", "slug": "dash-a", "owners": ["john_doe"], "modified_by": "john_doe"},
{"id": 2, "title": "Dash B", "slug": "dash-b", "owners": ["other"], "modified_by": "other"},
])
with patch("src.api.routes.dashboards.ProfileService") as profile_service_cls:
profile_service = MagicMock()
profile_service.get_my_preference.return_value = _build_profile_preference_stub(
username="john_doe",
enabled=True,
)
profile_service.matches_dashboard_actor.side_effect = _matches_actor_case_insensitive
profile_service_cls.return_value = profile_service
response = client.get(
"/api/dashboards?env_id=prod&page_context=other&apply_profile_default=true"
)
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 2
assert {item["id"] for item in payload["dashboards"]} == {1, 2}
assert payload["effective_profile_filter"]["applied"] is False
assert payload["effective_profile_filter"]["source_page"] == "other"
assert payload["effective_profile_filter"]["override_show_all"] is False
assert payload["effective_profile_filter"]["username"] is None
assert payload["effective_profile_filter"]["match_logic"] is None
profile_service.matches_dashboard_actor.assert_not_called()
# [/DEF:test_get_dashboards_page_context_other_disables_profile_default:Function]
# [DEF:test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout:Function]
# @TEST: GET /api/dashboards resolves Superset display-name alias once and filters without per-dashboard detail calls.
# @PRE: Profile-default filter is active, bound username is `admin`, dashboard actors contain display labels.
# @POST: Route matches by alias (`Superset Admin`) and does not call `SupersetClient.get_dashboard` in list filter path.
def test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout(mock_deps):
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
mock_deps["task"].get_all_tasks.return_value = []
mock_deps["resource"].get_dashboards_with_status = AsyncMock(return_value=[
{
"id": 5,
"title": "Alias Match",
"slug": "alias-match",
"owners": [],
"created_by": None,
"modified_by": "Superset Admin",
},
{
"id": 6,
"title": "Alias No Match",
"slug": "alias-no-match",
"owners": [],
"created_by": None,
"modified_by": "Other User",
},
])
with patch("src.api.routes.dashboards.ProfileService") as profile_service_cls, patch(
"src.api.routes.dashboards.SupersetClient"
) as superset_client_cls, patch(
"src.api.routes.dashboards.SupersetAccountLookupAdapter"
) as lookup_adapter_cls:
profile_service = MagicMock()
profile_service.get_my_preference.return_value = _build_profile_preference_stub(
username="admin",
enabled=True,
)
profile_service.matches_dashboard_actor.side_effect = _matches_actor_case_insensitive
profile_service_cls.return_value = profile_service
superset_client = MagicMock()
superset_client_cls.return_value = superset_client
lookup_adapter = MagicMock()
lookup_adapter.get_users_page.return_value = {
"items": [
{
"environment_id": "prod",
"username": "admin",
"display_name": "Superset Admin",
"email": "admin@example.com",
"is_active": True,
}
],
"total": 1,
}
lookup_adapter_cls.return_value = lookup_adapter
response = client.get(
"/api/dashboards?env_id=prod&page_context=dashboards_main&apply_profile_default=true"
)
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 1
assert {item["id"] for item in payload["dashboards"]} == {5}
assert payload["effective_profile_filter"]["applied"] is True
lookup_adapter.get_users_page.assert_called_once()
superset_client.get_dashboard.assert_not_called()
# [/DEF:test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout:Function]
# [/DEF:backend.src.api.routes.__tests__.test_dashboards:Module]