fix(docs): secure adaptive handbook projections
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from inspect import signature
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_access.backend.manifest import get_manifest as get_access_manifest
|
||||
from govoplan_core.core.modules import DocumentationCondition
|
||||
from govoplan_core.core.modules import DocumentationCondition, DocumentationLink, DocumentationTopic, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_tenancy.backend.manifest import get_manifest as get_tenancy_manifest
|
||||
from govoplan_docs.backend.api.v1.routes import (
|
||||
@@ -12,6 +16,7 @@ from govoplan_docs.backend.api.v1.routes import (
|
||||
_condition_visibility,
|
||||
_documentation_topic_anchor,
|
||||
_documentation_topic_groups,
|
||||
docs_context,
|
||||
)
|
||||
from govoplan_docs.backend.manifest import get_manifest as get_docs_manifest
|
||||
|
||||
@@ -60,7 +65,7 @@ class DocsContextTests(unittest.TestCase):
|
||||
self.assertIn("access.workflow.grant-user-access", configured_ids)
|
||||
self.assertIn("docs.pattern.field-help", always_ids)
|
||||
|
||||
def test_unavailable_topic_still_appears_in_kind_group(self) -> None:
|
||||
def test_user_projection_omits_topics_without_required_access(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(get_tenancy_manifest())
|
||||
registry.register(get_access_manifest())
|
||||
@@ -76,11 +81,106 @@ class DocsContextTests(unittest.TestCase):
|
||||
locale="en",
|
||||
)
|
||||
groups = _documentation_topic_groups(layers)
|
||||
workflows = {topic["id"]: topic for topic in groups["workflow"]}
|
||||
workflow_ids = {topic["id"] for topic in groups["workflow"]}
|
||||
|
||||
self.assertIn("access.workflow.grant-user-access", workflows)
|
||||
self.assertFalse(workflows["access.workflow.grant-user-access"]["active"])
|
||||
self.assertIn("access.workflow.grant-user-access", {topic["id"] for topic in layers["available"]})
|
||||
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_is_a_bounded_safe_whitelist(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="9.9.9",
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="example.workflow.safe",
|
||||
title="Safe task",
|
||||
summary="A task the actor may perform.",
|
||||
documentation_types=("user",),
|
||||
conditions=(DocumentationCondition(required_scopes=("docs:documentation:read",)),),
|
||||
links=(
|
||||
DocumentationLink(label="Open task", href="/example", kind="runtime"),
|
||||
DocumentationLink(label="Unsafe runtime", href="javascript:alert(1)", kind="runtime"),
|
||||
DocumentationLink(label="Public help", href="https://example.invalid/help", kind="public"),
|
||||
DocumentationLink(label="Repository", href="example/docs/SECRET.md", kind="repository"),
|
||||
DocumentationLink(label="API", href="/api/v1/example", kind="api"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"screen": "Example",
|
||||
"steps": ["Do the safe thing."],
|
||||
"current_configuration": ["The bounded option is enabled."],
|
||||
"constraints": [{
|
||||
"id": "domain",
|
||||
"label": "Domain",
|
||||
"description": "Use an approved domain.",
|
||||
"values": ["*.example.invalid"],
|
||||
"secret": "must-not-pass",
|
||||
}],
|
||||
"raw_policy": {"secret": "must-not-pass"},
|
||||
"api_path": "/api/v1/internal",
|
||||
},
|
||||
),
|
||||
),
|
||||
))
|
||||
|
||||
layers = _classify_documentation(
|
||||
registry,
|
||||
FakePrincipal({"docs:documentation:read"}),
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="user",
|
||||
locale="en",
|
||||
)
|
||||
topic = layers["configured"][0]
|
||||
|
||||
self.assertEqual([link["href"] for link in topic["links"]], ["/example", "https://example.invalid/help"])
|
||||
self.assertEqual(topic["conditions"], [])
|
||||
self.assertEqual(topic["configuration_keys"], [])
|
||||
self.assertEqual(topic["blockers"], {"modules": [], "capabilities": [], "scopes": []})
|
||||
self.assertNotIn("raw_policy", topic["metadata"])
|
||||
self.assertNotIn("api_path", topic["metadata"])
|
||||
self.assertEqual(topic["metadata"]["constraints"][0], {
|
||||
"id": "domain",
|
||||
"label": "Domain",
|
||||
"description": "Use an approved domain.",
|
||||
"values": ["*.example.invalid"],
|
||||
})
|
||||
|
||||
def test_docs_default_to_user_and_admin_projection_requires_admin_authority(self) -> None:
|
||||
query_default = signature(docs_context).parameters["documentation_type"].default
|
||||
self.assertEqual(query_default.default, "user")
|
||||
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
docs_context(
|
||||
SimpleNamespace(),
|
||||
documentation_type="admin",
|
||||
locale="en",
|
||||
principal=FakePrincipal({"docs:documentation:read"}),
|
||||
)
|
||||
self.assertEqual(raised.exception.status_code, 403)
|
||||
|
||||
def test_user_actor_projection_does_not_disclose_identity_or_scope_count(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(get_docs_manifest())
|
||||
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(govoplan_registry=registry)))
|
||||
empty_layers = {"always": [], "configured": [], "available": [], "evidence": []}
|
||||
|
||||
with patch("govoplan_docs.backend.api.v1.routes._documentation_layers", return_value=empty_layers):
|
||||
payload = docs_context(
|
||||
request,
|
||||
documentation_type="user",
|
||||
locale="en",
|
||||
principal=FakePrincipal({"docs:documentation:read"}),
|
||||
)
|
||||
|
||||
self.assertEqual(payload["actor"]["available_documentation_types"], ["user"])
|
||||
self.assertNotIn("tenant_id", payload["actor"])
|
||||
self.assertNotIn("user_id", payload["actor"])
|
||||
self.assertNotIn("scope_count", payload["actor"])
|
||||
self.assertEqual(payload["layers"]["configured"]["permissions"], [])
|
||||
self.assertEqual(payload["layers"]["available"]["routes"], [])
|
||||
|
||||
def test_topic_anchor_is_stable(self) -> None:
|
||||
self.assertEqual(
|
||||
|
||||
Reference in New Issue
Block a user