From ec240ed2199637a68751e348887908a82d4cfe65 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 24 Aug 2026 01:15:42 +0200 Subject: [PATCH] feat: require explicit dashboard read permission --- README.md | 4 + package.json | 2 +- pyproject.toml | 2 +- src/govoplan_dashboard/__init__.py | 2 +- src/govoplan_dashboard/backend/manifest.py | 93 ++++++++++++++++++- src/govoplan_dashboard/backend/permissions.py | 6 ++ src/govoplan_dashboard/backend/router.py | 14 ++- tests/test_dashboard_layouts.py | 29 +++++- .../test_interface_documentation_contract.py | 19 +++- webui/package.json | 2 +- webui/src/module.ts | 4 +- 11 files changed, 165 insertions(+), 12 deletions(-) create mode 100644 src/govoplan_dashboard/backend/permissions.py diff --git a/README.md b/README.md index 0cdcc3e..2ab16b1 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@ Configurable dashboard module for GovOPlaN. The module owns the `/dashboard` route when installed. Core keeps only a minimal fallback home for installations where this module is absent. +Opening the route and using its personal-layout API requires the grantable +`dashboard:dashboard:read` tenant permission. The `dashboard_user` role template +contains this permission; widgets retain their own provider-specific access checks. + Modules contribute widgets through the `dashboard.widgets` WebUI capability. Personal widget layouts are stored by the backend for each tenant, account, and active View. Configure mode supports adding, removing, ordering, sizing, and diff --git a/package.json b/package.json index 841c772..d5716c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/dashboard-webui", - "version": "0.1.19", + "version": "0.1.20", "private": true, "type": "module", "main": "webui/src/index.ts", diff --git a/pyproject.toml b/pyproject.toml index dd6c031..5d8e9de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-dashboard" -version = "0.1.19" +version = "0.1.20" description = "GovOPlaN configurable dashboard module." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_dashboard/__init__.py b/src/govoplan_dashboard/__init__.py index a011fb9..00edba1 100644 --- a/src/govoplan_dashboard/__init__.py +++ b/src/govoplan_dashboard/__init__.py @@ -2,4 +2,4 @@ __all__ = ["__version__"] -__version__ = "0.1.19" +__version__ = "0.1.20" diff --git a/src/govoplan_dashboard/backend/manifest.py b/src/govoplan_dashboard/backend/manifest.py index d7559a5..e13ce26 100644 --- a/src/govoplan_dashboard/backend/manifest.py +++ b/src/govoplan_dashboard/backend/manifest.py @@ -9,6 +9,7 @@ from govoplan_core.core.module_guards import ( ) from govoplan_core.core.modules import ( CapabilityDocumentation, + DocumentationCondition, DocumentationTopic, FrontendModule, FrontendRoute, @@ -17,6 +18,8 @@ from govoplan_core.core.modules import ( ModuleInterfaceProvider, ModuleManifest, NavItem, + PermissionDefinition, + RoleTemplate, ) from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.views import ViewSurface @@ -26,6 +29,21 @@ from govoplan_dashboard.backend.dsar_provider import ( DASHBOARD_DSAR_CAPABILITY, DashboardDsarProvider, ) +from govoplan_dashboard.backend.permissions import READ_SCOPE + + +def _permission(scope: str, label: str, description: str) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category="Dashboard", + level="tenant", + module_id=module_id, + resource=resource, + action=action, + ) def _dashboard_router(_context): @@ -51,7 +69,7 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]: manifest = ModuleManifest( id="dashboard", name="Dashboard", - version="0.1.19", + version="0.1.20", required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), optional_dependencies=("ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"), provides_interfaces=( @@ -70,6 +88,21 @@ manifest = ModuleManifest( ), }, tenant_summary_providers=(_tenant_summary,), + permissions=( + _permission( + READ_SCOPE, + "View dashboard", + "Open the Dashboard and read, save, reset, or remove the current user's personal layout.", + ), + ), + role_templates=( + RoleTemplate( + slug="dashboard_user", + name="Dashboard user", + description="Open and personalize the Dashboard.", + permissions=(READ_SCOPE,), + ), + ), migration_spec=MigrationSpec( module_id="dashboard", metadata=Base.metadata, @@ -90,12 +123,35 @@ manifest = ModuleManifest( label="Dashboard", ), ), - nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),), + nav_items=( + NavItem( + path="/dashboard", + label="Dashboard", + icon="dashboard", + required_all=(READ_SCOPE,), + order=10, + ), + ), frontend=FrontendModule( module_id="dashboard", package_name="@govoplan/dashboard-webui", - routes=(FrontendRoute(path="/dashboard", component="DashboardPage", order=10),), - nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),), + routes=( + FrontendRoute( + path="/dashboard", + component="DashboardPage", + required_all=(READ_SCOPE,), + order=10, + ), + ), + nav_items=( + NavItem( + path="/dashboard", + label="Dashboard", + icon="dashboard", + required_all=(READ_SCOPE,), + order=10, + ), + ), view_surfaces=( ViewSurface( id="dashboard.page", @@ -228,7 +284,9 @@ manifest = ModuleManifest( audience=("user", "tenant_admin", "operator"), related_modules=("core", "ops"), order=20, + conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),), metadata={ + "kind": "workflow", "help_contexts": [ "dashboard.page", "dashboard.summary", @@ -237,6 +295,17 @@ manifest = ModuleManifest( "dashboard.state.browser-fallback", "dashboard.state.view-specific", ], + "prerequisites": [ + "Your tenant has enabled the Dashboard module.", + "Your role grants the dashboard read permission.", + ], + "steps": [ + "Open Dashboard from the main navigation.", + "Add, configure, resize, or remove widgets for the active View.", + "Save the layout after reviewing the unsaved-change indicator.", + ], + "outcome": "The personal layout is available for the active tenant, account, and View.", + "verification": "Reload the Dashboard and confirm that the saved widget arrangement returns.", }, translations={ "de": { @@ -253,6 +322,22 @@ manifest = ModuleManifest( ), } }, + structured_translation_version="1", + structured_translations={ + "de": { + "prerequisites": [ + "Ihr Mandant hat das Dashboard-Modul aktiviert.", + "Ihre Rolle gewährt die Dashboard-Leseberechtigung.", + ], + "steps": [ + "Öffnen Sie das Dashboard über die Hauptnavigation.", + "Fügen Sie Widgets für den aktiven View hinzu, konfigurieren oder skalieren Sie sie oder entfernen Sie Widgets.", + "Speichern Sie das Layout, nachdem Sie die Anzeige ungespeicherter Änderungen geprüft haben.", + ], + "outcome": "Das persönliche Layout ist für den aktiven Mandanten, das Konto und den View verfügbar.", + "verification": "Laden Sie das Dashboard neu und prüfen Sie, ob die gespeicherte Widget-Anordnung wiederhergestellt wird.", + } + }, ), DocumentationTopic( id="dashboard.reference.layout-and-widgets", diff --git a/src/govoplan_dashboard/backend/permissions.py b/src/govoplan_dashboard/backend/permissions.py new file mode 100644 index 0000000..9668938 --- /dev/null +++ b/src/govoplan_dashboard/backend/permissions.py @@ -0,0 +1,6 @@ +"""Dashboard permission scope constants.""" + +READ_SCOPE = "dashboard:dashboard:read" + + +__all__ = ["READ_SCOPE"] diff --git a/src/govoplan_dashboard/backend/router.py b/src/govoplan_dashboard/backend/router.py index 7374f6d..8073d7e 100644 --- a/src/govoplan_dashboard/backend/router.py +++ b/src/govoplan_dashboard/backend/router.py @@ -4,9 +4,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from govoplan_core.auth import ApiPrincipal, get_api_principal +from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.db.session import get_session from govoplan_dashboard.backend.db.models import DashboardLayout +from govoplan_dashboard.backend.permissions import READ_SCOPE from govoplan_dashboard.backend.schemas import ( DashboardLayoutResponse, DashboardLayoutUpdateRequest, @@ -24,6 +25,14 @@ from govoplan_dashboard.backend.service import ( router = APIRouter(prefix="/dashboard", tags=["dashboard"]) +def _require_read(principal: ApiPrincipal) -> None: + if not has_scope(principal, READ_SCOPE): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required scope: {READ_SCOPE}", + ) + + def _response( layout: DashboardLayout | None, *, @@ -51,6 +60,7 @@ def api_get_dashboard_layout( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> DashboardLayoutResponse: + _require_read(principal) return _response( get_dashboard_layout( session, @@ -69,6 +79,7 @@ def api_save_dashboard_layout( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> DashboardLayoutResponse: + _require_read(principal) try: layout = save_dashboard_layout( session, @@ -111,6 +122,7 @@ def api_delete_dashboard_layout( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> None: + _require_read(principal) delete_dashboard_layout( session, tenant_id=principal.tenant_id, diff --git a/tests/test_dashboard_layouts.py b/tests/test_dashboard_layouts.py index e381c7e..22ce238 100644 --- a/tests/test_dashboard_layouts.py +++ b/tests/test_dashboard_layouts.py @@ -12,6 +12,7 @@ from govoplan_core.auth import ApiPrincipal, get_api_principal from govoplan_core.core.access import PrincipalRef from govoplan_core.db.session import get_session from govoplan_dashboard.backend.db.models import DashboardLayout +from govoplan_dashboard.backend.manifest import READ_SCOPE from govoplan_dashboard.backend.router import router @@ -19,13 +20,14 @@ def principal( *, tenant_id: str = "tenant-1", account_id: str = "account-1", + scopes: frozenset[str] = frozenset({READ_SCOPE}), ) -> ApiPrincipal: return ApiPrincipal( principal=PrincipalRef( account_id=account_id, membership_id=f"membership:{tenant_id}:{account_id}", tenant_id=tenant_id, - scopes=frozenset(), + scopes=scopes, group_ids=frozenset(), ), account=object(), @@ -67,6 +69,31 @@ class DashboardLayoutApiTests(unittest.TestCase): self.client.close() self.engine.dispose() + def test_layout_endpoints_require_dashboard_read_permission(self) -> None: + self.active_principal = principal(scopes=frozenset()) + + for method in ("get", "put", "delete"): + response = getattr(self.client, method)( + "/api/v1/dashboard/layout", + **( + { + "json": { + "expected_revision": 0, + "layout_version": 1, + "placements": [], + "known_widget_ids": [], + } + } + if method == "put" + else {} + ), + ) + self.assertEqual(403, response.status_code) + self.assertEqual( + f"Missing required scope: {READ_SCOPE}", + response.json()["detail"], + ) + def test_layouts_are_isolated_by_account_and_view(self) -> None: initial = self.client.get("/api/v1/dashboard/layout") self.assertEqual(200, initial.status_code) diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py index 7a7839f..235fb7e 100644 --- a/tests/test_interface_documentation_contract.py +++ b/tests/test_interface_documentation_contract.py @@ -7,13 +7,28 @@ from govoplan_core.core.modules import ( documentation_structured_translation_issues, localizable_documentation_metadata_keys, ) -from govoplan_dashboard.backend.manifest import get_manifest +from govoplan_dashboard.backend.manifest import READ_SCOPE, get_manifest REPO_ROOT = Path(__file__).resolve().parents[1] class DashboardInterfaceDocumentationContractTests(unittest.TestCase): + def test_dashboard_surface_requires_explicit_read_permission(self) -> None: + manifest = get_manifest() + self.assertEqual({READ_SCOPE}, {item.scope for item in manifest.permissions}) + self.assertIn( + READ_SCOPE, + next( + item.permissions + for item in manifest.role_templates + if item.slug == "dashboard_user" + ), + ) + self.assertEqual((READ_SCOPE,), manifest.nav_items[0].required_all) + self.assertEqual((READ_SCOPE,), manifest.frontend.routes[0].required_all) # type: ignore[union-attr] + self.assertEqual((READ_SCOPE,), manifest.frontend.nav_items[0].required_all) # type: ignore[union-attr] + def test_surface_hierarchy_remains_declared(self) -> None: frontend = get_manifest().frontend self.assertIsNotNone(frontend) @@ -39,6 +54,8 @@ class DashboardInterfaceDocumentationContractTests(unittest.TestCase): reference = topics["dashboard.reference.layout-and-widgets"] self.assertIn("dashboard.state.browser-fallback", home.metadata["help_contexts"]) + self.assertEqual("workflow", home.metadata["kind"]) + self.assertEqual((READ_SCOPE,), home.conditions[0].required_scopes) self.assertIn("dashboard.field.widget-size", reference.metadata["help_contexts"]) self.assertIn("save_layout", reference.metadata["consequence_classes"]) self.assertIn("remove_widget", reference.metadata["consequence_classes"]) diff --git a/webui/package.json b/webui/package.json index 4fd8d42..d206c85 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/dashboard-webui", - "version": "0.1.19", + "version": "0.1.20", "private": true, "type": "module", "main": "src/index.ts", diff --git a/webui/src/module.ts b/webui/src/module.ts index 25c982c..e7c8e00 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -5,6 +5,7 @@ import { generatedTranslations } from "./i18n/generatedTranslations"; import "./styles/dashboard.css"; const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage")); +const dashboardRead = ["dashboard:dashboard:read"]; const dashboardWidgets: DashboardWidgetsUiCapability = { widgets: [ @@ -117,10 +118,11 @@ export const dashboardModule: PlatformWebModule = { order: 10 } ], - navItems: [{ to: "/dashboard", label: "i18n:govoplan-dashboard.dashboard.3f8b4df2", iconName: "dashboard", order: 10 }], + navItems: [{ to: "/dashboard", label: "i18n:govoplan-dashboard.dashboard.3f8b4df2", iconName: "dashboard", anyOf: dashboardRead, order: 10 }], routes: [ { path: "/dashboard", + anyOf: dashboardRead, order: 10, surfaceId: "dashboard.page", render: ({ settings, auth }) => createElement(DashboardPage, { settings, auth })