4 Commits

11 changed files with 251 additions and 71 deletions

View File

@@ -22,6 +22,14 @@ This repository owns:
Core owns module discovery, configuration package loading, route registry, RBAC evaluation, capability registry, and shared WebUI shell behavior. Core owns module discovery, configuration package loading, route registry, RBAC evaluation, capability registry, and shared WebUI shell behavior.
While Docs is installed, its managed `docs_reader` tenant role is an automatic
authenticated-member baseline. Access derives its narrow read grant from the
active Docs manifest without per-user assignments or authorization-time writes,
so ordinary users can open their configured handbook without an administrator
assigning documentation access one account at a time.
Administrative documentation remains separately protected by
`docs:documentation:admin` or the applicable administration scope.
## Documentation model ## Documentation model
The docs module should render documentation in three layers: The docs module should render documentation in three layers:

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 modules, enabled routes, permissions, and configuration decide what is shown as
the default path. the default path.
Tracking issue: `add-ideas/govoplan-docs#15`. Tracking issue: `GovOPlaN/govoplan-docs#15`.
## Editorial Pillars ## Editorial Pillars
@@ -255,6 +255,9 @@ Workflow topics:
- keep each step actionable - keep each step actionable
- mention blockers where the user would encounter them - mention blockers where the user would encounter them
- link to field/reference topics instead of repeating field tables - 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: Reference topics:

View File

@@ -28,6 +28,20 @@ documentation may show all layers plus route, permission, module, and capability
diagnostics, but it requires the separate administrative documentation diagnostics, but it requires the separate administrative documentation
authority. authority.
The managed `docs_reader` role grants `docs:documentation:read` automatically
to every authenticated tenant membership while Docs is installed. This only
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 ## Conditions
Documentation topics can declare: Documentation topics can declare:

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/docs-webui", "name": "@govoplan/docs-webui",
"version": "0.1.9", "version": "0.1.10",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -17,7 +17,7 @@
"README.md" "README.md"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.10",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-docs" name = "govoplan-docs"
version = "0.1.9" version = "0.1.10"
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation." description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.8", "govoplan-core>=0.1.10",
"govoplan-access>=0.1.8", "govoplan-access>=0.1.10",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

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

View File

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

View File

@@ -44,7 +44,7 @@ def _route_factory(context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="docs", id="docs",
name="Docs", name="Docs",
version="0.1.9", version="0.1.10",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("policy", "audit", "ops", "workflow", "search"), optional_dependencies=("policy", "audit", "ops", "workflow", "search"),
permissions=( permissions=(
@@ -63,8 +63,9 @@ manifest = ModuleManifest(
RoleTemplate( RoleTemplate(
slug="docs_reader", slug="docs_reader",
name="Documentation reader", name="Documentation reader",
description="Read the configured-system documentation browser.", description="Read the configured-system documentation browser. This role is granted automatically to every authenticated tenant member while Docs is installed.",
permissions=(DOCS_READ_SCOPE,), permissions=(DOCS_READ_SCOPE,),
default_authenticated=True,
), ),
RoleTemplate( RoleTemplate(
slug="docs_admin", slug="docs_admin",

View File

@@ -33,6 +33,18 @@ class FakePrincipal:
class DocsContextTests(unittest.TestCase): class DocsContextTests(unittest.TestCase):
def test_docs_reader_is_the_managed_authenticated_tenant_default(self) -> None:
manifest = get_docs_manifest()
roles = {template.slug: template for template in manifest.role_templates}
self.assertTrue(roles["docs_reader"].default_authenticated)
self.assertTrue(roles["docs_reader"].managed)
self.assertEqual(roles["docs_reader"].level, "tenant")
self.assertEqual(
roles["docs_reader"].permissions,
("docs:documentation:read",),
)
def test_topic_groups_preserve_layer_classification(self) -> None: def test_topic_groups_preserve_layer_classification(self) -> None:
registry = PlatformRegistry() registry = PlatformRegistry()
registry.register(get_tenancy_manifest()) registry.register(get_tenancy_manifest())
@@ -86,6 +98,55 @@ class DocsContextTests(unittest.TestCase):
self.assertNotIn("access.workflow.grant-user-access", workflow_ids) self.assertNotIn("access.workflow.grant-user-access", workflow_ids)
self.assertNotIn("access.workflow.grant-user-access", {topic["id"] for topic in layers["available"]}) 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: def test_user_projection_is_a_bounded_safe_whitelist(self) -> None:
registry = PlatformRegistry() registry = PlatformRegistry()
registry.register(ModuleManifest( registry.register(ModuleManifest(

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/docs-webui", "name": "@govoplan/docs-webui",
"version": "0.1.9", "version": "0.1.10",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -13,11 +13,11 @@
} }
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.10",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^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", "@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "vite": "^6.0.6"

View File

@@ -14,7 +14,7 @@ const translations = {
export const docsModule: PlatformWebModule = { export const docsModule: PlatformWebModule = {
id: "docs", id: "docs",
label: "i18n:govoplan-docs.docs.68a41942", label: "i18n:govoplan-docs.docs.68a41942",
version: "0.1.9", version: "0.1.10",
dependencies: ["access"], dependencies: ["access"],
optionalDependencies: ["policy", "audit", "ops", "workflow", "search"], optionalDependencies: ["policy", "audit", "ops", "workflow", "search"],
translations, translations,