3 Commits

5 changed files with 213 additions and 60 deletions

View File

@@ -26,7 +26,7 @@ It should not start from a full product manual. The current tenant, installed
modules, enabled routes, permissions, and configuration decide what is shown as
the default path.
Tracking issue: `add-ideas/govoplan-docs#15`.
Tracking issue: `GovOPlaN/govoplan-docs#15`.
## Editorial Pillars
@@ -255,6 +255,9 @@ Workflow topics:
- keep each step actionable
- mention blockers where the user would encounter them
- link to field/reference topics instead of repeating field tables
- for every user-facing workflow, declare one or more conditions and put
`required_scopes` or `any_scopes` on every condition alternative; the release
gate rejects an unscoped alternative
Reference topics:

View File

@@ -34,6 +34,14 @@ opens the user projection: each contributed workflow still needs its own
module, capability, permission, and runtime-policy conditions so baseline Help
Center access does not imply authority to perform every documented task.
This permission binding is a strict source contract. Every user workflow topic
must declare at least one `DocumentationCondition`, and every alternative in
its `conditions` tuple must include `required_scopes` or `any_scopes`. Module,
capability, or configuration conditions alone are not sufficient because topic
conditions are alternatives: one unscoped alternative would bypass all scoped
ones. Manifest validation blocks a release containing such a topic, and Docs
omits a non-compliant runtime-provider topic from the user projection.
## Conditions
Documentation topics can declare:

View File

@@ -16,6 +16,7 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
user_workflow_scope_condition_issues,
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import get_database
@@ -46,78 +47,168 @@ def docs_context(
visible_route_items = [item for item in route_items if item["visible"]]
documentation_layers = _documentation_layers(request, registry, principal, documentation_type=documentation_type, locale=resolved_locale)
if documentation_type == "admin":
visible_routes = visible_route_items
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())
catalog = _admin_documentation_catalog(
registry,
principal,
route_items=route_items,
visible_route_items=visible_route_items,
)
else:
catalog = _user_documentation_catalog(
registry,
visible_route_items=visible_route_items,
documentation_layers=documentation_layers,
)
topic_groups = _documentation_topic_groups(documentation_layers)
return {
"actor": _documentation_actor(
principal,
documentation_type=documentation_type,
locale=resolved_locale,
can_read_admin=can_read_admin_documentation,
),
"summary": _documentation_summary(
catalog,
documentation_layers=documentation_layers,
topic_groups=topic_groups,
),
"topic_groups": topic_groups,
"layers": _documentation_layer_payload(
catalog,
documentation_layers=documentation_layers,
include_evidence_sources=documentation_type == "admin",
),
}
def _admin_documentation_catalog(
registry: PlatformRegistry,
principal: ApiPrincipal,
*,
route_items: list[dict[str, Any]],
visible_route_items: list[dict[str, Any]],
) -> dict[str, list[dict[str, Any]]]:
return {
"modules": [
_module_payload(manifest, technical=True)
for manifest in registry.manifests()
],
"permissions": [
_permission_payload(permission, principal)
for permission in registry.permissions()
],
"visible_routes": visible_route_items,
"available_routes": [item for item in route_items if not item["visible"]],
"optional_modules": _optional_module_evidence(registry.manifests()),
}
def _user_documentation_catalog(
registry: PlatformRegistry,
*,
visible_route_items: list[dict[str, Any]],
documentation_layers: Mapping[str, list[dict[str, Any]]],
) -> dict[str, list[dict[str, Any]]]:
visible_module_ids = {
*(str(item["module_id"]) for item in visible_route_items),
*(str(topic["source_module_id"]) for topic in _all_documentation_topics(documentation_layers)),
*(
str(topic["source_module_id"])
for topic in _all_documentation_topics(documentation_layers)
),
}
modules = [
return {
"modules": [
_module_payload(manifest, technical=False)
for manifest in registry.manifests()
if manifest.id in visible_module_ids
]
permissions = []
visible_routes = _user_route_payloads(visible_route_items)
available_routes = []
optional_modules = []
documentation_count = sum(len(layer) for layer in documentation_layers.values())
topic_groups = _documentation_topic_groups(documentation_layers)
],
"permissions": [],
"visible_routes": _user_route_payloads(visible_route_items),
"available_routes": [],
"optional_modules": [],
}
actor_payload: dict[str, Any] = {
def _documentation_actor(
principal: ApiPrincipal,
*,
documentation_type: DocumentationType,
locale: str,
can_read_admin: bool,
) -> dict[str, Any]:
actor: dict[str, Any] = {
"documentation_type": documentation_type,
"locale": resolved_locale,
"available_documentation_types": ["user", *(["admin"] if can_read_admin_documentation else [])],
"locale": locale,
"available_documentation_types": [
"user",
*(["admin"] if can_read_admin else []),
],
}
if documentation_type == "admin":
actor_payload.update(
actor.update(
tenant_id=principal.tenant_id,
user_id=principal.user.id,
scope_count=len(principal.scopes),
)
return actor
def _documentation_summary(
catalog: Mapping[str, list[dict[str, Any]]],
*,
documentation_layers: Mapping[str, list[dict[str, Any]]],
topic_groups: Mapping[str, list[dict[str, Any]]],
) -> dict[str, int]:
permissions = catalog["permissions"]
return {
"actor": actor_payload,
"summary": {
"module_count": len(modules),
"visible_route_count": len(visible_routes),
"available_route_count": len(available_routes),
"module_count": len(catalog["modules"]),
"visible_route_count": len(catalog["visible_routes"]),
"available_route_count": len(catalog["available_routes"]),
"permission_count": len(permissions),
"granted_permission_count": sum(1 for item in permissions if item["granted"]),
"optional_module_count": len(optional_modules),
"documentation_topic_count": documentation_count,
"configured_documentation_topic_count": len(documentation_layers["configured"]),
"granted_permission_count": sum(
1 for item in permissions if item["granted"]
),
"optional_module_count": len(catalog["optional_modules"]),
"documentation_topic_count": sum(
len(layer) for layer in documentation_layers.values()
),
"configured_documentation_topic_count": len(
documentation_layers["configured"]
),
"workflow_topic_count": len(topic_groups["workflow"]),
"reference_topic_count": len(topic_groups["reference"]),
"pattern_topic_count": len(topic_groups["pattern"]),
"system_topic_count": len(topic_groups["system"]),
},
"topic_groups": topic_groups,
"layers": {
}
def _documentation_layer_payload(
catalog: Mapping[str, list[dict[str, Any]]],
*,
documentation_layers: Mapping[str, list[dict[str, Any]]],
include_evidence_sources: bool,
) -> dict[str, dict[str, object]]:
permissions = catalog["permissions"]
return {
"always": {
"documentation": documentation_layers["always"],
},
"configured": {
"modules": modules,
"routes": visible_routes,
"modules": catalog["modules"],
"routes": catalog["visible_routes"],
"permissions": permissions,
"documentation": documentation_layers["configured"],
},
"available": {
"routes": available_routes,
"routes": catalog["available_routes"],
"permissions": [item for item in permissions if not item["granted"]],
"documentation": documentation_layers["available"],
},
"evidence": {
"optional_modules": optional_modules,
"sources": _evidence_sources() if documentation_type == "admin" else [],
"optional_modules": catalog["optional_modules"],
"sources": _evidence_sources() if include_evidence_sources else [],
"documentation": documentation_layers["evidence"],
},
},
}
@@ -362,6 +453,7 @@ def _collect_documentation_topics(
topics: list[tuple[str, DocumentationTopic]] = []
for manifest in registry.manifests():
for topic in manifest.documentation:
if not user_workflow_scope_condition_issues(topic):
topics.append((manifest.id, topic))
if not manifest.documentation_providers:
continue
@@ -393,6 +485,7 @@ def _collect_documentation_topics(
),
)
for topic in provided_topics:
if not user_workflow_scope_condition_issues(topic):
topics.append((manifest.id, topic))
return topics

View File

@@ -98,6 +98,55 @@ class DocsContextTests(unittest.TestCase):
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_fails_closed_for_unscoped_workflow_topics(self) -> None:
unscoped_static_topic = DocumentationTopic(
id="example.workflow.unscoped-static",
title="Unscoped static task",
summary="This topic must not be rendered.",
documentation_types=("user",),
metadata={"kind": "workflow"},
)
unscoped_runtime_topic = DocumentationTopic(
id="example.workflow.unscoped-runtime",
title="Unscoped runtime task",
summary="This provider topic must not be rendered.",
documentation_types=("user",),
conditions=(DocumentationCondition(required_modules=("example",)),),
metadata={"kind": "workflow"},
)
scoped_runtime_topic = DocumentationTopic(
id="example.workflow.scoped-runtime",
title="Scoped runtime task",
summary="This provider topic remains visible.",
documentation_types=("user",),
conditions=(DocumentationCondition(required_scopes=("docs:documentation:read",)),),
metadata={"kind": "workflow"},
)
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="example",
name="Example",
version="9.9.9",
documentation=(unscoped_static_topic,),
documentation_providers=(
lambda context: (unscoped_runtime_topic, scoped_runtime_topic),
),
))
layers = _classify_documentation(
registry,
FakePrincipal({"docs:documentation:read"}),
settings=None,
session=None,
documentation_type="user",
locale="en",
)
self.assertEqual(
[topic["id"] for topic in _documentation_topic_groups(layers)["workflow"]],
["example.workflow.scoped-runtime"],
)
def test_user_projection_is_a_bounded_safe_whitelist(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(

View File

@@ -17,7 +17,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.6"