feat: require explicit dashboard read permission
Module Package Release / publish-packages (push) Successful in 11s
Module Package Release / publish-packages (push) Successful in 11s
This commit is contained in:
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.19"
|
||||
__version__ = "0.1.20"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user