fix(docs): fail closed on unscoped workflows

This commit is contained in:
2026-07-22 01:55:54 +02:00
parent be52b716ca
commit 94c000f351
4 changed files with 65 additions and 2 deletions

View File

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

@@ -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 module, capability, permission, and runtime-policy conditions so baseline Help
Center access does not imply authority to perform every documented task. 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

@@ -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
@@ -362,6 +363,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 +395,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

@@ -98,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(