1 Commits
Author SHA1 Message Date
zemion ec240ed219 feat: require explicit dashboard read permission
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:42 +02:00
11 changed files with 165 additions and 12 deletions
+4
View File
@@ -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
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.19"
__version__ = "0.1.20"
+89 -4
View File
@@ -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",
@@ -0,0 +1,6 @@
"""Dashboard permission scope constants."""
READ_SCOPE = "dashboard:dashboard:read"
__all__ = ["READ_SCOPE"]
+13 -1
View File
@@ -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,
+28 -1
View File
@@ -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)
+18 -1
View File
@@ -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"])
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/dashboard-webui",
"version": "0.1.19",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "src/index.ts",
+3 -1
View File
@@ -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 })