fix(docs): secure adaptive handbook projections

This commit is contained in:
2026-07-21 18:39:06 +02:00
parent 0c9997593c
commit 0f41e02428
10 changed files with 376 additions and 54 deletions

View File

@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.8"
__version__ = "0.1.9"

View File

@@ -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:

View File

@@ -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),),