diff --git a/docs/DOCUMENTATION_EXPERIENCE_CONCEPT.md b/docs/DOCUMENTATION_EXPERIENCE_CONCEPT.md index a6f5b34..e1172df 100644 --- a/docs/DOCUMENTATION_EXPERIENCE_CONCEPT.md +++ b/docs/DOCUMENTATION_EXPERIENCE_CONCEPT.md @@ -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: diff --git a/docs/INSTANCE_AWARE_DOCUMENTATION.md b/docs/INSTANCE_AWARE_DOCUMENTATION.md index 542b594..e252e65 100644 --- a/docs/INSTANCE_AWARE_DOCUMENTATION.md +++ b/docs/INSTANCE_AWARE_DOCUMENTATION.md @@ -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: diff --git a/src/govoplan_docs/backend/api/v1/routes.py b/src/govoplan_docs/backend/api/v1/routes.py index c9228b9..4d788d5 100644 --- a/src/govoplan_docs/backend/api/v1/routes.py +++ b/src/govoplan_docs/backend/api/v1/routes.py @@ -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 @@ -362,7 +363,8 @@ def _collect_documentation_topics( topics: list[tuple[str, DocumentationTopic]] = [] for manifest in registry.manifests(): for topic in manifest.documentation: - topics.append((manifest.id, topic)) + if not user_workflow_scope_condition_issues(topic): + topics.append((manifest.id, topic)) if not manifest.documentation_providers: continue context = DocumentationContext( @@ -393,7 +395,8 @@ def _collect_documentation_topics( ), ) for topic in provided_topics: - topics.append((manifest.id, topic)) + if not user_workflow_scope_condition_issues(topic): + topics.append((manifest.id, topic)) return topics diff --git a/tests/test_docs_context.py b/tests/test_docs_context.py index fd011ba..a4d83b0 100644 --- a/tests/test_docs_context.py +++ b/tests/test_docs_context.py @@ -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(