From 0f41e024284ee8c7152e9a8887e500d89653d2f5 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 21 Jul 2026 18:39:06 +0200 Subject: [PATCH] fix(docs): secure adaptive handbook projections --- package.json | 2 +- pyproject.toml | 2 +- src/govoplan_docs/__init__.py | 2 +- src/govoplan_docs/backend/api/v1/routes.py | 224 ++++++++++++++++++--- src/govoplan_docs/backend/manifest.py | 19 +- tests/test_docs_context.py | 112 ++++++++++- webui/package.json | 2 +- webui/src/api/docs.ts | 7 +- webui/src/features/docs/DocsPage.tsx | 54 ++++- webui/src/module.ts | 6 +- 10 files changed, 376 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index bf5fb19..c5d633b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/docs-webui", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "main": "webui/src/index.ts", diff --git a/pyproject.toml b/pyproject.toml index ce60c26..9bd2f37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-docs" -version = "0.1.8" +version = "0.1.9" description = "GovOPlaN documentation module for configured-system, available, and evidence documentation." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_docs/__init__.py b/src/govoplan_docs/__init__.py index ff29d2a..1c3ca24 100644 --- a/src/govoplan_docs/__init__.py +++ b/src/govoplan_docs/__init__.py @@ -2,4 +2,4 @@ __all__ = ["__version__"] -__version__ = "0.1.8" +__version__ = "0.1.9" diff --git a/src/govoplan_docs/backend/api/v1/routes.py b/src/govoplan_docs/backend/api/v1/routes.py index 84831c2..aaccfd7 100644 --- a/src/govoplan_docs/backend/api/v1/routes.py +++ b/src/govoplan_docs/backend/api/v1/routes.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Any, Mapping +from urllib.parse import urlsplit from fastapi import APIRouter, Depends, HTTPException, Query, Request, status @@ -19,7 +20,7 @@ from govoplan_core.core.modules import ( from govoplan_core.core.registry import PlatformRegistry from govoplan_core.db.session import get_database -from govoplan_docs.backend.manifest import DOCS_READ_SCOPES +from govoplan_docs.backend.manifest import DOCS_ADMIN_READ_SCOPES, DOCS_READ_SCOPES router = APIRouter(prefix="/docs", tags=["docs"]) @@ -29,30 +30,57 @@ TOPIC_KINDS = ("workflow", "reference", "pattern", "system") @router.get("/context") def docs_context( request: Request, - documentation_type: DocumentationType = Query(default="admin", alias="type", pattern="^(admin|user)$"), + documentation_type: DocumentationType = Query(default="user", alias="type", pattern="^(admin|user)$"), locale: str | None = Query(default=None, min_length=2, max_length=20), principal: ApiPrincipal = Depends(require_any_scope(*DOCS_READ_SCOPES)), ) -> dict[str, Any]: + can_read_admin_documentation = _has_any_scope(principal, DOCS_ADMIN_READ_SCOPES) + if documentation_type == "admin" and not can_read_admin_documentation: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Administrative documentation requires documentation-administrator authority", + ) registry = _registry(request) resolved_locale = _preferred_locale(request, locale) - modules = [_module_payload(manifest) for manifest in registry.manifests()] - permissions = [_permission_payload(permission, principal) for permission in registry.permissions()] route_items = _route_items(registry.manifests(), principal) visible_routes = [item for item in route_items if item["visible"]] - available_routes = [item for item in route_items if not item["visible"]] - optional_modules = _optional_module_evidence(registry.manifests()) documentation_layers = _documentation_layers(request, registry, principal, documentation_type=documentation_type, locale=resolved_locale) + if documentation_type == "admin": + modules = [_module_payload(manifest, technical=True) for manifest in registry.manifests()] + permissions = [_permission_payload(permission, principal) for permission in registry.permissions()] + available_routes = [item for item in route_items if not item["visible"]] + optional_modules = _optional_module_evidence(registry.manifests()) + else: + visible_module_ids = { + *(str(item["module_id"]) for item in visible_routes), + *(str(topic["source_module_id"]) for topic in _all_documentation_topics(documentation_layers)), + } + modules = [ + _module_payload(manifest, technical=False) + for manifest in registry.manifests() + if manifest.id in visible_module_ids + ] + permissions = [] + visible_routes = [_user_route_payload(item) for item in visible_routes] + available_routes = [] + optional_modules = [] documentation_count = sum(len(layer) for layer in documentation_layers.values()) topic_groups = _documentation_topic_groups(documentation_layers) + actor_payload: dict[str, Any] = { + "documentation_type": documentation_type, + "locale": resolved_locale, + "available_documentation_types": ["user", *(["admin"] if can_read_admin_documentation else [])], + } + if documentation_type == "admin": + actor_payload.update( + tenant_id=principal.tenant_id, + user_id=principal.user.id, + scope_count=len(principal.scopes), + ) + return { - "actor": { - "tenant_id": principal.tenant_id, - "user_id": principal.user.id, - "scope_count": len(principal.scopes), - "documentation_type": documentation_type, - "locale": resolved_locale, - }, + "actor": actor_payload, "summary": { "module_count": len(modules), "visible_route_count": len(visible_routes), @@ -99,25 +127,42 @@ def _registry(request: Request) -> PlatformRegistry: return registry -def _module_payload(manifest: ModuleManifest) -> dict[str, Any]: +def _module_payload(manifest: ModuleManifest, *, technical: bool) -> dict[str, Any]: frontend = manifest.frontend migration = manifest.migration_spec return { "id": manifest.id, "name": manifest.name, - "version": manifest.version, - "dependencies": list(manifest.dependencies), - "optional_dependencies": list(manifest.optional_dependencies), - "permission_count": len(manifest.permissions), - "role_template_count": len(manifest.role_templates), - "nav_count": len(manifest.nav_items) + (len(frontend.nav_items) if frontend else 0), - "route_count": (1 if manifest.route_factory else 0) + (len(frontend.routes) if frontend else 0), - "frontend_package": frontend.package_name if frontend else None, - "backend_route_contributed": manifest.route_factory is not None, - "migration_module_id": migration.module_id if migration else None, - "capabilities": sorted(manifest.capability_factories), - "documentation_count": len(manifest.documentation), - "documentation_provider_count": len(manifest.documentation_providers), + "version": manifest.version if technical else "", + "dependencies": list(manifest.dependencies) if technical else [], + "optional_dependencies": list(manifest.optional_dependencies) if technical else [], + "permission_count": len(manifest.permissions) if technical else 0, + "role_template_count": len(manifest.role_templates) if technical else 0, + "nav_count": len(manifest.nav_items) + (len(frontend.nav_items) if frontend else 0) if technical else 0, + "route_count": (1 if manifest.route_factory else 0) + (len(frontend.routes) if frontend else 0) if technical else 0, + "frontend_package": frontend.package_name if frontend and technical else None, + "backend_route_contributed": manifest.route_factory is not None if technical else False, + "migration_module_id": migration.module_id if migration and technical else None, + "capabilities": sorted(manifest.capability_factories) if technical else [], + "documentation_count": len(manifest.documentation) if technical else 0, + "documentation_provider_count": len(manifest.documentation_providers) if technical else 0, + } + + +def _user_route_payload(item: Mapping[str, Any]) -> dict[str, Any]: + return { + "module_id": item["module_id"], + "path": item["path"], + "label": item["label"], + "icon": item["icon"], + "section": item["section"], + "source": "visible", + "component": None, + "required_all": [], + "required_any": [], + "order": item["order"], + "visible": True, + "reason": "visible", } @@ -265,6 +310,8 @@ def _classify_documentation( if not _topic_matches_documentation_type(topic, documentation_type): continue active, reason, blockers = _documentation_visibility(topic, installed, registry, principal) + if documentation_type == "user" and not active: + continue target_layer = _documentation_target_layer(topic, active, blockers) if target_layer not in layers: target_layer = "evidence" @@ -276,6 +323,7 @@ def _classify_documentation( target_layer=target_layer, blockers=blockers, locale=locale, + documentation_type=documentation_type, )) return layers @@ -488,11 +536,12 @@ def _documentation_topic_payload( target_layer: str, blockers: dict[str, list[str]], locale: str, + documentation_type: DocumentationType, ) -> dict[str, Any]: module_id = topic.source_module_id or source_module_id translation_locale, translation = _translation_for_locale(topic, locale) kind = _documentation_topic_kind(topic) - return { + payload = { "id": topic.id, "source_module_id": module_id, "kind": kind, @@ -522,6 +571,125 @@ def _documentation_topic_payload( "configuration_keys": sorted({*topic.configuration_keys, *(key for condition in topic.conditions for key in condition.configuration_keys)}), "metadata": dict(topic.metadata), } + if documentation_type == "admin": + return payload + return { + "id": payload["id"], + "source_module_id": payload["source_module_id"], + "kind": payload["kind"], + "anchor_id": payload["anchor_id"], + "title": payload["title"], + "summary": payload["summary"], + "body": payload["body"], + "layer": payload["layer"], + "target_layer": payload["target_layer"], + "documentation_types": ["user"], + "active": True, + "reason": "documented", + "blockers": {"modules": [], "capabilities": [], "scopes": []}, + "audience": [], + "order": payload["order"], + "i18n_key": "", + "locale": locale, + "translation_locale": payload["translation_locale"], + "conditions": [], + "links": [ + _documentation_link_payload(link) + for link in topic.links + if _user_link_allowed(link) + ], + "related_modules": [], + "unlocks": list(topic.unlocks), + "configuration_keys": [], + "metadata": _user_topic_metadata(kind, topic.metadata), + } + + +def _user_topic_metadata(kind: str, metadata: Mapping[str, Any]) -> dict[str, Any]: + projected: dict[str, Any] = {} + scalar_keys = ("kind", "screen", "section", "outcome", "result", "verification", "purpose", "when_used", "user_explanation") + list_keys = ("prerequisites", "steps", "current_configuration", "limitations", "related_topic_ids") + id_keys = ("tree_parent_id", "parent_topic_id", "parent_id") + for key in scalar_keys: + value = _bounded_string(metadata.get(key), maximum=2_000) + if value: + projected[key] = value + for key in list_keys: + values = _bounded_string_list(metadata.get(key), maximum_items=64, maximum_length=2_000) + if values: + projected[key] = values + for key in id_keys: + value = _bounded_string(metadata.get(key), maximum=255) + if value: + projected[key] = value + if kind == "reference" and isinstance(metadata.get("fields"), list): + fields = [_user_field_metadata(item) for item in metadata["fields"][:64] if isinstance(item, Mapping)] + if fields: + projected["fields"] = [field for field in fields if field] + constraints = _user_constraints(metadata.get("constraints")) + if constraints: + projected["constraints"] = constraints + return projected + + +def _user_field_metadata(field: Mapping[str, Any]) -> dict[str, Any]: + allowed = ("field_id", "label", "user_description", "validation") + return { + key: value + for key in allowed + if (value := _bounded_string(field.get(key), maximum=2_000)) + } + + +def _user_constraints(value: object) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + constraints: list[dict[str, Any]] = [] + for item in value[:32]: + if not isinstance(item, Mapping): + continue + constraint = { + key: text + for key in ("id", "label", "description") + if (text := _bounded_string(item.get(key), maximum=2_000 if key == "description" else 255)) + } + values = _bounded_string_list(item.get("values"), maximum_items=64, maximum_length=500) + if values: + constraint["values"] = values + if constraint.get("label") and constraint.get("description"): + constraints.append(constraint) + return constraints + + +def _bounded_string(value: object, *, maximum: int) -> str: + if not isinstance(value, str): + return "" + clean = value.strip() + return clean if len(clean) <= maximum else "" + + +def _bounded_string_list(value: object, *, maximum_items: int, maximum_length: int) -> list[str]: + if not isinstance(value, list): + return [] + return [ + clean + for item in value[:maximum_items] + if (clean := _bounded_string(item, maximum=maximum_length)) + ] + + +def _user_link_allowed(link: DocumentationLink) -> bool: + href = link.href.strip() + if link.kind == "runtime": + return href.startswith("/") and not href.startswith("//") + if link.kind != "public": + return False + parsed = urlsplit(href) + return parsed.scheme == "https" and bool(parsed.netloc) and not parsed.username and not parsed.password + + +def _has_any_scope(principal: ApiPrincipal, scopes: tuple[str, ...]) -> bool: + return any(has_scope(principal, scope) for scope in scopes) def _documentation_topic_kind(topic: DocumentationTopic) -> str: diff --git a/src/govoplan_docs/backend/manifest.py b/src/govoplan_docs/backend/manifest.py index d8edb6c..7e0f42e 100644 --- a/src/govoplan_docs/backend/manifest.py +++ b/src/govoplan_docs/backend/manifest.py @@ -14,7 +14,9 @@ from govoplan_core.core.modules import ( ) DOCS_READ_SCOPE = "docs:documentation:read" -DOCS_READ_SCOPES = (DOCS_READ_SCOPE, "system:settings:read", "admin:settings:read") +DOCS_ADMIN_READ_SCOPE = "docs:documentation:admin" +DOCS_ADMIN_READ_SCOPES = (DOCS_ADMIN_READ_SCOPE, "system:settings:read", "admin:settings:read") +DOCS_READ_SCOPES = (DOCS_READ_SCOPE, *DOCS_ADMIN_READ_SCOPES) def _permission(scope: str, label: str, description: str) -> PermissionDefinition: @@ -41,14 +43,19 @@ def _route_factory(context: ModuleContext): manifest = ModuleManifest( id="docs", name="Docs", - version="0.1.8", + version="0.1.9", required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), optional_dependencies=("policy", "audit", "ops", "workflow", "search"), permissions=( _permission( DOCS_READ_SCOPE, "View configured documentation", - "Read documentation generated from installed modules, visible routes, permissions, and evidence sources.", + "Read user documentation generated for the current actor from installed modules and effective configuration.", + ), + _permission( + DOCS_ADMIN_READ_SCOPE, + "View administrative documentation", + "Read technical module, route, permission, configuration, and evidence documentation.", ), ), role_templates=( @@ -58,6 +65,12 @@ manifest = ModuleManifest( description="Read the configured-system documentation browser.", permissions=(DOCS_READ_SCOPE,), ), + RoleTemplate( + slug="docs_admin", + name="Documentation administrator", + description="Read user guidance and the technical configured-system documentation projection.", + permissions=(DOCS_READ_SCOPE, DOCS_ADMIN_READ_SCOPE), + ), ), route_factory=_route_factory, nav_items=(NavItem(path="/docs", label="Docs", icon="reports", required_any=DOCS_READ_SCOPES, order=880),), diff --git a/tests/test_docs_context.py b/tests/test_docs_context.py index 12f1c2b..e889dae 100644 --- a/tests/test_docs_context.py +++ b/tests/test_docs_context.py @@ -1,10 +1,14 @@ from __future__ import annotations import unittest +from inspect import signature from types import SimpleNamespace +from unittest.mock import patch + +from fastapi import HTTPException from govoplan_access.backend.manifest import get_manifest as get_access_manifest -from govoplan_core.core.modules import DocumentationCondition +from govoplan_core.core.modules import DocumentationCondition, DocumentationLink, DocumentationTopic, ModuleManifest from govoplan_core.core.registry import PlatformRegistry from govoplan_tenancy.backend.manifest import get_manifest as get_tenancy_manifest from govoplan_docs.backend.api.v1.routes import ( @@ -12,6 +16,7 @@ from govoplan_docs.backend.api.v1.routes import ( _condition_visibility, _documentation_topic_anchor, _documentation_topic_groups, + docs_context, ) from govoplan_docs.backend.manifest import get_manifest as get_docs_manifest @@ -60,7 +65,7 @@ class DocsContextTests(unittest.TestCase): self.assertIn("access.workflow.grant-user-access", configured_ids) self.assertIn("docs.pattern.field-help", always_ids) - def test_unavailable_topic_still_appears_in_kind_group(self) -> None: + def test_user_projection_omits_topics_without_required_access(self) -> None: registry = PlatformRegistry() registry.register(get_tenancy_manifest()) registry.register(get_access_manifest()) @@ -76,11 +81,106 @@ class DocsContextTests(unittest.TestCase): locale="en", ) groups = _documentation_topic_groups(layers) - workflows = {topic["id"]: topic for topic in groups["workflow"]} + workflow_ids = {topic["id"] for topic in groups["workflow"]} - self.assertIn("access.workflow.grant-user-access", workflows) - self.assertFalse(workflows["access.workflow.grant-user-access"]["active"]) - self.assertIn("access.workflow.grant-user-access", {topic["id"] for topic in layers["available"]}) + self.assertNotIn("access.workflow.grant-user-access", workflow_ids) + self.assertNotIn("access.workflow.grant-user-access", {topic["id"] for topic in layers["available"]}) + + def test_user_projection_is_a_bounded_safe_whitelist(self) -> None: + registry = PlatformRegistry() + registry.register(ModuleManifest( + id="example", + name="Example", + version="9.9.9", + documentation=( + DocumentationTopic( + id="example.workflow.safe", + title="Safe task", + summary="A task the actor may perform.", + documentation_types=("user",), + conditions=(DocumentationCondition(required_scopes=("docs:documentation:read",)),), + links=( + DocumentationLink(label="Open task", href="/example", kind="runtime"), + DocumentationLink(label="Unsafe runtime", href="javascript:alert(1)", kind="runtime"), + DocumentationLink(label="Public help", href="https://example.invalid/help", kind="public"), + DocumentationLink(label="Repository", href="example/docs/SECRET.md", kind="repository"), + DocumentationLink(label="API", href="/api/v1/example", kind="api"), + ), + metadata={ + "kind": "workflow", + "screen": "Example", + "steps": ["Do the safe thing."], + "current_configuration": ["The bounded option is enabled."], + "constraints": [{ + "id": "domain", + "label": "Domain", + "description": "Use an approved domain.", + "values": ["*.example.invalid"], + "secret": "must-not-pass", + }], + "raw_policy": {"secret": "must-not-pass"}, + "api_path": "/api/v1/internal", + }, + ), + ), + )) + + layers = _classify_documentation( + registry, + FakePrincipal({"docs:documentation:read"}), + settings=None, + session=None, + documentation_type="user", + locale="en", + ) + topic = layers["configured"][0] + + self.assertEqual([link["href"] for link in topic["links"]], ["/example", "https://example.invalid/help"]) + self.assertEqual(topic["conditions"], []) + self.assertEqual(topic["configuration_keys"], []) + self.assertEqual(topic["blockers"], {"modules": [], "capabilities": [], "scopes": []}) + self.assertNotIn("raw_policy", topic["metadata"]) + self.assertNotIn("api_path", topic["metadata"]) + self.assertEqual(topic["metadata"]["constraints"][0], { + "id": "domain", + "label": "Domain", + "description": "Use an approved domain.", + "values": ["*.example.invalid"], + }) + + def test_docs_default_to_user_and_admin_projection_requires_admin_authority(self) -> None: + query_default = signature(docs_context).parameters["documentation_type"].default + self.assertEqual(query_default.default, "user") + + with self.assertRaises(HTTPException) as raised: + docs_context( + SimpleNamespace(), + documentation_type="admin", + locale="en", + principal=FakePrincipal({"docs:documentation:read"}), + ) + self.assertEqual(raised.exception.status_code, 403) + + def test_user_actor_projection_does_not_disclose_identity_or_scope_count(self) -> None: + registry = PlatformRegistry() + registry.register(get_docs_manifest()) + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(govoplan_registry=registry))) + empty_layers = {"always": [], "configured": [], "available": [], "evidence": []} + + with patch("govoplan_docs.backend.api.v1.routes._documentation_layers", return_value=empty_layers): + payload = docs_context( + request, + documentation_type="user", + locale="en", + principal=FakePrincipal({"docs:documentation:read"}), + ) + + self.assertEqual(payload["actor"]["available_documentation_types"], ["user"]) + self.assertNotIn("tenant_id", payload["actor"]) + self.assertNotIn("user_id", payload["actor"]) + self.assertNotIn("scope_count", payload["actor"]) + self.assertEqual(payload["layers"]["configured"]["permissions"], []) + self.assertEqual(payload["layers"]["available"]["routes"], []) def test_topic_anchor_is_stable(self) -> None: self.assertEqual( diff --git a/webui/package.json b/webui/package.json index 45ee5be..4212da8 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/docs-webui", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "main": "src/index.ts", diff --git a/webui/src/api/docs.ts b/webui/src/api/docs.ts index 693e84a..7bd7ba1 100644 --- a/webui/src/api/docs.ts +++ b/webui/src/api/docs.ts @@ -110,11 +110,12 @@ export type DocsDocumentationTopic = { export type DocsContext = { actor: { - tenant_id: string; - user_id: string; - scope_count: number; + tenant_id?: string; + user_id?: string; + scope_count?: number; documentation_type: "admin" | "user"; locale: string; + available_documentation_types: Array<"admin" | "user">; }; summary: { module_count: number; diff --git a/webui/src/features/docs/DocsPage.tsx b/webui/src/features/docs/DocsPage.tsx index 9bc548d..1094dc7 100644 --- a/webui/src/features/docs/DocsPage.tsx +++ b/webui/src/features/docs/DocsPage.tsx @@ -123,7 +123,11 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {