Compare commits
13 Commits
0c9997593c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| da15645dba | |||
| 511930ea56 | |||
| 4d36ab2747 | |||
| 94c000f351 | |||
| be52b716ca | |||
| 901baf8352 | |||
| 7cdd1e1c78 | |||
| e9f3106657 | |||
| 9dab1c842a | |||
| 2f912ff619 | |||
| a08bc7c204 | |||
| bbe3c7d163 | |||
| 0f41e02428 |
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
The docs module should render documentation in three layers:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -100,17 +100,17 @@ database session when available. The module can then describe the effective
|
||||
state without the docs module importing feature internals.
|
||||
|
||||
For example, `govoplan-mail` contributes static documentation for reusable mail
|
||||
profiles and a runtime tenant policy topic. If the effective tenant mail policy
|
||||
limits sending to approved profile ids and disables user, group, and
|
||||
campaign-local profiles, the runtime docs state that users can choose approved
|
||||
profiles but cannot bring arbitrary SMTP or IMAP servers at lower scopes. If the
|
||||
policy allows lower scopes, the same topic explains which scopes may define
|
||||
profiles and whether credential inheritance is locked.
|
||||
profiles and runtime effective-policy topics. The provider evaluates the
|
||||
current actor and scope. A custom-profile task appears only when that actor has
|
||||
the relevant authority and the effective user policy permits it; the task then
|
||||
states the host/domain bounds and separate credential authority needed to
|
||||
complete it.
|
||||
|
||||
Runtime providers must avoid leaking secrets. They should summarize posture,
|
||||
counts, source provenance, and enabling conditions rather than exposing
|
||||
credentials, hostnames, profile ids, or raw policy payloads unless the route is
|
||||
explicitly intended for that level of detail.
|
||||
counts, source provenance, and enabling conditions. A provider may disclose a
|
||||
host or domain pattern only when the actor is authorized and that exact value is
|
||||
necessary to complete the documented task. Credentials, usernames, profile
|
||||
ids, source ids, unrelated topology, and raw policy payloads remain excluded.
|
||||
|
||||
### Conditions And Unlocks
|
||||
|
||||
@@ -123,9 +123,10 @@ Documentation topics can declare conditions:
|
||||
- required scopes or one-of scope alternatives
|
||||
- configuration keys that influence the topic
|
||||
|
||||
The docs API classifies satisfied topics into their requested layer. Unsatisfied
|
||||
topics remain visible as available or evidence documentation when safe, with a
|
||||
reason such as a missing module, capability, or scope.
|
||||
The docs API classifies satisfied topics into their requested layer.
|
||||
Unsatisfied topics can remain visible in the administrator projection when
|
||||
safe. The user projection omits them so a protected body or metadata payload is
|
||||
not disclosed merely because a condition failed.
|
||||
|
||||
Topics can also declare related modules and unlock notes. This lets a module
|
||||
state that additional behavior becomes available when another module is
|
||||
@@ -136,7 +137,7 @@ then to public module documentation for broader examples.
|
||||
|
||||
GovOPlaN has two documentation presentations over the same source model.
|
||||
|
||||
Admin documentation is allowed to be technical. It can expose installed module
|
||||
Admin documentation is a separately authorized technical projection. It can expose installed module
|
||||
ids, route contributions, API paths, permissions, capability names, server
|
||||
configuration keys, policy source chains, migration notes, and operator
|
||||
evidence. This is the right place to explain exactly why a setting is available,
|
||||
@@ -157,8 +158,10 @@ whether the user may choose an approved mail profile or add a local mail server.
|
||||
|
||||
The first presentation rule is:
|
||||
|
||||
- admin docs show technical context and evidence tables
|
||||
- user docs show plain-language topics, examples, limits, and escalation paths
|
||||
- admin docs require `docs:documentation:admin` or the compatible settings
|
||||
administration authority and show technical context and evidence tables
|
||||
- user docs are the default and show only active plain-language topics,
|
||||
examples, limits, and safe escalation paths
|
||||
- both presentations are filtered by installed modules, active configuration,
|
||||
permissions, and safe disclosure rules
|
||||
|
||||
|
||||
@@ -23,9 +23,24 @@ The documentation context is built from:
|
||||
| `available` | Installed features hidden by missing permissions or unmet conditions. |
|
||||
| `evidence` | Hints for unavailable optional modules, missing capabilities, or external evidence sources. |
|
||||
|
||||
Normal user documentation should emphasize `always` and `configured` content.
|
||||
Admin documentation may show all layers plus route, permission, module, and
|
||||
capability diagnostics.
|
||||
Normal user documentation returns only active, safely projected topics. Admin
|
||||
documentation may show all layers plus route, permission, module, and capability
|
||||
diagnostics, but it requires the separate administrative documentation
|
||||
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
|
||||
|
||||
@@ -37,7 +52,11 @@ Documentation topics can declare:
|
||||
- required capabilities
|
||||
- required scopes
|
||||
- any-of scope sets
|
||||
- related configuration keys
|
||||
- related configuration keys as technical provenance
|
||||
|
||||
Configuration keys are descriptive metadata; they are not evaluated as
|
||||
conditions. Configuration-dependent guidance belongs in an owning module's
|
||||
runtime provider.
|
||||
|
||||
The API returns both a human-readable `reason` and structured `blockers`:
|
||||
|
||||
@@ -52,10 +71,27 @@ The API returns both a human-readable `reason` and structured `blockers`:
|
||||
}
|
||||
```
|
||||
|
||||
The WebUI must display the reason and, when present, the blocker lists. This
|
||||
prevents dead-end instructions: users see whether a feature is unavailable
|
||||
because a module is missing, a capability is not registered, or their current
|
||||
permissions do not expose it.
|
||||
Raw blockers and scope names are part of the administrator projection. The user
|
||||
projection omits inactive topics instead of returning their protected body,
|
||||
metadata, and blocker identifiers. A module can contribute a separate, safe
|
||||
plain-language escalation topic when users need to know that an administrator
|
||||
must enable something.
|
||||
|
||||
## Task metadata
|
||||
|
||||
Workflow topics can supply `outcome`, `prerequisites`, `steps`, `result`, and
|
||||
`verification`. Runtime providers can additionally supply:
|
||||
|
||||
- `current_configuration`: bounded plain-language facts for this actor and
|
||||
installation;
|
||||
- `limitations`: bounded, actionable caveats; and
|
||||
- `constraints`: records with `id`, `label`, `description`, and optional
|
||||
user-safe `values` needed to complete the task.
|
||||
|
||||
The Docs API validates and whitelists those fields for the user projection.
|
||||
Modules remain responsible for authorization and for preserving policy
|
||||
semantics. Constraints must never contain secrets, internal policy-source ids,
|
||||
unrelated topology, or raw policy payloads.
|
||||
|
||||
## Module Guidance
|
||||
|
||||
@@ -90,9 +126,9 @@ Runtime docs may link to issues when an unavailable feature is planned or a
|
||||
known limitation is relevant, but the UI must label that link as active work.
|
||||
It must not treat open issues as shipped behavior.
|
||||
|
||||
Safe disclosure is evaluated before a topic is returned. Missing permission,
|
||||
missing module, and missing capability explanations should be useful but
|
||||
minimal. User docs explain the blocker and who can resolve it. Admin docs can
|
||||
Safe disclosure is evaluated before a topic is returned. User docs contain
|
||||
only active topics, runtime and HTTPS public links, and a bounded metadata
|
||||
whitelist. Admin docs can
|
||||
include route ids, scopes, capability names, configuration keys, and policy
|
||||
provenance only when the actor has permission to inspect those details. Secrets,
|
||||
tokens, private keys, credentials, raw policy payloads, and unrelated personal
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -17,7 +17,7 @@
|
||||
"README.md"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.10",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-docs"
|
||||
version = "0.1.8"
|
||||
version = "0.1.10"
|
||||
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.8",
|
||||
"govoplan-access>=0.1.8",
|
||||
"govoplan-core>=0.1.10",
|
||||
"govoplan-access>=0.1.10",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.8"
|
||||
__version__ = "0.1.10"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationSourceDefinition,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
@@ -14,7 +16,9 @@ from govoplan_core.core.modules import (
|
||||
)
|
||||
|
||||
DOCS_READ_SCOPE = "docs:documentation:read"
|
||||
DOCS_READ_SCOPES = (DOCS_READ_SCOPE, "system:settings:read", "admin:settings:read")
|
||||
DOCS_ADMIN_READ_SCOPE = "docs:documentation:admin"
|
||||
DOCS_ADMIN_READ_SCOPES = (DOCS_ADMIN_READ_SCOPE, "system:settings:read", "admin:settings:read")
|
||||
DOCS_READ_SCOPES = (DOCS_READ_SCOPE, *DOCS_ADMIN_READ_SCOPES)
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
@@ -41,22 +45,34 @@ def _route_factory(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="docs",
|
||||
name="Docs",
|
||||
version="0.1.8",
|
||||
version="0.1.10",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("policy", "audit", "ops", "workflow", "search"),
|
||||
permissions=(
|
||||
_permission(
|
||||
DOCS_READ_SCOPE,
|
||||
"View configured documentation",
|
||||
"Read documentation generated from installed modules, visible routes, permissions, and evidence sources.",
|
||||
"Read user documentation generated for the current actor from installed modules and effective configuration.",
|
||||
),
|
||||
_permission(
|
||||
DOCS_ADMIN_READ_SCOPE,
|
||||
"View administrative documentation",
|
||||
"Read technical module, route, permission, configuration, and evidence documentation.",
|
||||
),
|
||||
),
|
||||
role_templates=(
|
||||
RoleTemplate(
|
||||
slug="docs_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,),
|
||||
default_authenticated=True,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="docs_admin",
|
||||
name="Documentation administrator",
|
||||
description="Read user guidance and the technical configured-system documentation projection.",
|
||||
permissions=(DOCS_READ_SCOPE, DOCS_ADMIN_READ_SCOPE),
|
||||
),
|
||||
),
|
||||
route_factory=_route_factory,
|
||||
@@ -155,6 +171,11 @@ manifest = ModuleManifest(
|
||||
audience=("tenant_admin", "access_admin", "operator", "user"),
|
||||
related_modules=("organizations", "identity", "idm", "access"),
|
||||
order=21,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("organizations", "identity", "idm", "access"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Organizations", href="/organizations", kind="runtime"),
|
||||
DocumentationLink(label="IDM assignments", href="/idm", kind="runtime"),
|
||||
@@ -173,6 +194,39 @@ manifest = ModuleManifest(
|
||||
},
|
||||
),
|
||||
),
|
||||
documentation_sources=(
|
||||
DocumentationSourceDefinition(
|
||||
id="docs.configuration.packages",
|
||||
kind="configuration_package",
|
||||
label="Configuration package catalog",
|
||||
condition=DocumentationCondition(
|
||||
any_scopes=("system:settings:read", "admin:settings:read"),
|
||||
),
|
||||
provenance={
|
||||
"source": "core_configuration_package_contract",
|
||||
"version": "1",
|
||||
},
|
||||
inspection={
|
||||
"package_id": "govoplan.configuration-packages",
|
||||
"schema_version": "1",
|
||||
"description": "Signed, preflighted platform configuration packages.",
|
||||
},
|
||||
),
|
||||
DocumentationSourceDefinition(
|
||||
id="docs.project.wiki",
|
||||
kind="wiki",
|
||||
label="GovOPlaN Docs wiki",
|
||||
provenance={"source": "gitea_wiki"},
|
||||
link=DocumentationLink(
|
||||
label="GovOPlaN Docs wiki",
|
||||
href="https://git.add-ideas.de/GovOPlaN/govoplan-docs/wiki",
|
||||
kind="wiki",
|
||||
),
|
||||
inspection={
|
||||
"href": "https://git.add-ideas.de/GovOPlaN/govoplan-docs/wiki",
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
391
src/govoplan_docs/backend/sources.py
Normal file
391
src/govoplan_docs/backend/sources.py
Normal file
@@ -0,0 +1,391 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any, Literal, Mapping
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationSourceDefinition,
|
||||
DocumentationType,
|
||||
ModuleManifest,
|
||||
)
|
||||
from govoplan_core.security.redaction import redact_secret_values
|
||||
|
||||
|
||||
class _SourceModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class DocumentationSourceProvenance(_SourceModel):
|
||||
source: str
|
||||
version: str | None = None
|
||||
revision: str | None = None
|
||||
published_at: str | None = None
|
||||
checksum: str | None = None
|
||||
|
||||
|
||||
class DocumentationSourceVisibility(_SourceModel):
|
||||
documentation_types: list[DocumentationType]
|
||||
required_modules: list[str] = Field(default_factory=list)
|
||||
any_modules: list[str] = Field(default_factory=list)
|
||||
missing_modules: list[str] = Field(default_factory=list)
|
||||
required_capabilities: list[str] = Field(default_factory=list)
|
||||
required_scopes: list[str] = Field(default_factory=list)
|
||||
any_scopes: list[str] = Field(default_factory=list)
|
||||
configuration_keys: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ManifestInspection(_SourceModel):
|
||||
kind: Literal["manifest"] = "manifest"
|
||||
module_id: str
|
||||
name: str
|
||||
version: str
|
||||
dependencies: list[str]
|
||||
optional_dependencies: list[str]
|
||||
|
||||
|
||||
class RouteInspection(_SourceModel):
|
||||
kind: Literal["route"] = "route"
|
||||
path: str
|
||||
component: str | None = None
|
||||
order: int
|
||||
|
||||
|
||||
class CapabilityInspection(_SourceModel):
|
||||
kind: Literal["capability"] = "capability"
|
||||
capability: str
|
||||
|
||||
|
||||
class PolicyInspection(_SourceModel):
|
||||
kind: Literal["policy"] = "policy"
|
||||
capability: str
|
||||
|
||||
|
||||
class ConfigurationPackageInspection(_SourceModel):
|
||||
kind: Literal["configuration_package"] = "configuration_package"
|
||||
package_id: str
|
||||
schema_version: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class WikiInspection(_SourceModel):
|
||||
kind: Literal["wiki"] = "wiki"
|
||||
href: str
|
||||
published_at: str | None = None
|
||||
revision: str | None = None
|
||||
|
||||
|
||||
class RepositoryInspection(_SourceModel):
|
||||
kind: Literal["repository"] = "repository"
|
||||
href: str
|
||||
revision: str | None = None
|
||||
|
||||
|
||||
DocumentationSourceInspection = Annotated[
|
||||
ManifestInspection
|
||||
| RouteInspection
|
||||
| CapabilityInspection
|
||||
| PolicyInspection
|
||||
| ConfigurationPackageInspection
|
||||
| WikiInspection
|
||||
| RepositoryInspection,
|
||||
Field(discriminator="kind"),
|
||||
]
|
||||
|
||||
|
||||
class DocumentationSourceItem(_SourceModel):
|
||||
id: str
|
||||
kind: str
|
||||
owner_module_id: str
|
||||
label: str
|
||||
state: Literal["configured", "disabled", "unavailable"]
|
||||
state_reason: str | None = None
|
||||
inspection_url: str
|
||||
provenance: DocumentationSourceProvenance
|
||||
visibility: DocumentationSourceVisibility
|
||||
inspection: DocumentationSourceInspection
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
return self.model_dump(mode="json", exclude={"inspection"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegisteredDocumentationSource:
|
||||
item: DocumentationSourceItem
|
||||
condition: DocumentationCondition
|
||||
documentation_types: tuple[DocumentationType, ...]
|
||||
configuration_key: str | None = None
|
||||
|
||||
|
||||
class DocumentationSourceRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._sources: dict[str, RegisteredDocumentationSource] = {}
|
||||
|
||||
def register(self, source: RegisteredDocumentationSource) -> None:
|
||||
if source.item.id in self._sources:
|
||||
raise ValueError(f"Duplicate documentation source id: {source.item.id}")
|
||||
self._sources[source.item.id] = source
|
||||
|
||||
def sources(self) -> tuple[RegisteredDocumentationSource, ...]:
|
||||
return tuple(
|
||||
self._sources[source_id]
|
||||
for source_id in sorted(self._sources)
|
||||
)
|
||||
|
||||
def get(self, source_id: str) -> RegisteredDocumentationSource | None:
|
||||
return self._sources.get(source_id)
|
||||
|
||||
|
||||
def build_documentation_source_registry(
|
||||
manifests: tuple[ModuleManifest, ...],
|
||||
) -> DocumentationSourceRegistry:
|
||||
registry = DocumentationSourceRegistry()
|
||||
for manifest in manifests:
|
||||
registry.register(_manifest_source(manifest))
|
||||
for source in _route_sources(manifest):
|
||||
registry.register(source)
|
||||
for source in _capability_sources(manifest):
|
||||
registry.register(source)
|
||||
for definition in manifest.documentation_sources:
|
||||
registry.register(_defined_source(manifest, definition))
|
||||
for source in _linked_sources(manifest):
|
||||
if registry.get(source.item.id) is None:
|
||||
registry.register(source)
|
||||
return registry
|
||||
|
||||
|
||||
def _manifest_source(manifest: ModuleManifest) -> RegisteredDocumentationSource:
|
||||
return _registered_source(
|
||||
source_id=f"{manifest.id}.manifest",
|
||||
kind="manifest",
|
||||
owner_module_id=manifest.id,
|
||||
label=f"{manifest.name} module manifest",
|
||||
provenance={"source": "module_manifest", "version": manifest.version},
|
||||
inspection=ManifestInspection(
|
||||
module_id=manifest.id,
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
dependencies=list(manifest.dependencies),
|
||||
optional_dependencies=list(manifest.optional_dependencies),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _route_sources(manifest: ModuleManifest) -> tuple[RegisteredDocumentationSource, ...]:
|
||||
if manifest.frontend is None:
|
||||
return ()
|
||||
return tuple(
|
||||
_registered_source(
|
||||
source_id=_derived_source_id(manifest.id, "route", route.path),
|
||||
kind="route",
|
||||
owner_module_id=manifest.id,
|
||||
label=f"{manifest.name} route {route.path}",
|
||||
provenance={"source": "frontend_manifest", "version": manifest.version},
|
||||
inspection=RouteInspection(
|
||||
path=route.path,
|
||||
component=route.component,
|
||||
order=route.order,
|
||||
),
|
||||
condition=DocumentationCondition(
|
||||
required_modules=(manifest.id,),
|
||||
required_scopes=route.required_all,
|
||||
any_scopes=route.required_any,
|
||||
),
|
||||
)
|
||||
for route in manifest.frontend.routes
|
||||
)
|
||||
|
||||
|
||||
def _capability_sources(manifest: ModuleManifest) -> tuple[RegisteredDocumentationSource, ...]:
|
||||
sources: list[RegisteredDocumentationSource] = []
|
||||
for capability in sorted(manifest.capability_factories):
|
||||
is_policy = capability.startswith("policy.")
|
||||
kind = "policy" if is_policy else "capability"
|
||||
inspection: DocumentationSourceInspection = (
|
||||
PolicyInspection(capability=capability)
|
||||
if is_policy
|
||||
else CapabilityInspection(capability=capability)
|
||||
)
|
||||
sources.append(_registered_source(
|
||||
source_id=_derived_source_id(manifest.id, kind, capability),
|
||||
kind=kind,
|
||||
owner_module_id=manifest.id,
|
||||
label=f"{manifest.name} {kind} {capability}",
|
||||
provenance={"source": "module_manifest", "version": manifest.version},
|
||||
inspection=inspection,
|
||||
condition=DocumentationCondition(required_modules=(manifest.id,)),
|
||||
))
|
||||
return tuple(sources)
|
||||
|
||||
|
||||
def _defined_source(
|
||||
manifest: ModuleManifest,
|
||||
definition: DocumentationSourceDefinition,
|
||||
) -> RegisteredDocumentationSource:
|
||||
inspection = _defined_inspection(definition)
|
||||
return _registered_source(
|
||||
source_id=definition.id,
|
||||
kind=definition.kind,
|
||||
owner_module_id=manifest.id,
|
||||
label=definition.label,
|
||||
provenance={
|
||||
"source": str(definition.provenance.get("source") or "module_manifest"),
|
||||
**_safe_source_fields(definition.provenance),
|
||||
},
|
||||
inspection=inspection,
|
||||
condition=definition.condition,
|
||||
documentation_types=definition.documentation_types,
|
||||
state=definition.state,
|
||||
configuration_key=definition.configuration_key,
|
||||
)
|
||||
|
||||
|
||||
def _defined_inspection(definition: DocumentationSourceDefinition) -> DocumentationSourceInspection:
|
||||
safe = _safe_source_fields(definition.inspection)
|
||||
if definition.kind == "configuration_package":
|
||||
return ConfigurationPackageInspection(
|
||||
package_id=str(safe.get("package_id") or definition.id),
|
||||
schema_version=_optional_text(safe.get("schema_version")),
|
||||
description=_optional_text(safe.get("description")),
|
||||
)
|
||||
href = definition.link.href if definition.link else str(safe.get("href") or "")
|
||||
if definition.kind == "wiki":
|
||||
return WikiInspection(
|
||||
href=href,
|
||||
published_at=_optional_text(safe.get("published_at")),
|
||||
revision=_optional_text(safe.get("revision")),
|
||||
)
|
||||
if definition.kind == "repository":
|
||||
return RepositoryInspection(
|
||||
href=href,
|
||||
revision=_optional_text(safe.get("revision")),
|
||||
)
|
||||
if definition.kind == "route":
|
||||
return RouteInspection(
|
||||
path=str(safe.get("path") or ""),
|
||||
component=_optional_text(safe.get("component")),
|
||||
order=int(safe.get("order") or 0),
|
||||
)
|
||||
if definition.kind == "policy":
|
||||
return PolicyInspection(capability=str(safe.get("capability") or definition.id))
|
||||
if definition.kind == "capability":
|
||||
return CapabilityInspection(capability=str(safe.get("capability") or definition.id))
|
||||
return ManifestInspection(
|
||||
module_id=str(safe.get("module_id") or definition.id.split(".", 1)[0]),
|
||||
name=str(safe.get("name") or definition.label),
|
||||
version=str(safe.get("version") or ""),
|
||||
dependencies=_string_list(safe.get("dependencies")),
|
||||
optional_dependencies=_string_list(safe.get("optional_dependencies")),
|
||||
)
|
||||
|
||||
|
||||
def _linked_sources(manifest: ModuleManifest) -> tuple[RegisteredDocumentationSource, ...]:
|
||||
sources: list[RegisteredDocumentationSource] = []
|
||||
for topic in manifest.documentation:
|
||||
for link in topic.links:
|
||||
if link.kind not in {"wiki", "repository"}:
|
||||
continue
|
||||
source_id = _derived_source_id(manifest.id, link.kind, link.href)
|
||||
inspection: DocumentationSourceInspection = (
|
||||
WikiInspection(href=link.href)
|
||||
if link.kind == "wiki"
|
||||
else RepositoryInspection(href=link.href)
|
||||
)
|
||||
sources.append(_registered_source(
|
||||
source_id=source_id,
|
||||
kind=link.kind,
|
||||
owner_module_id=manifest.id,
|
||||
label=link.label,
|
||||
provenance={"source": "documentation_link", "version": manifest.version},
|
||||
inspection=inspection,
|
||||
condition=topic.conditions[0] if len(topic.conditions) == 1 else DocumentationCondition(),
|
||||
documentation_types=topic.documentation_types,
|
||||
))
|
||||
return tuple(sources)
|
||||
|
||||
|
||||
def _registered_source(
|
||||
*,
|
||||
source_id: str,
|
||||
kind: str,
|
||||
owner_module_id: str,
|
||||
label: str,
|
||||
provenance: Mapping[str, Any],
|
||||
inspection: DocumentationSourceInspection,
|
||||
condition: DocumentationCondition | None = None,
|
||||
documentation_types: tuple[DocumentationType, ...] = ("admin",),
|
||||
state: Literal["configured", "disabled", "unavailable"] = "configured",
|
||||
configuration_key: str | None = None,
|
||||
) -> RegisteredDocumentationSource:
|
||||
clean_provenance = _safe_source_fields(provenance)
|
||||
return RegisteredDocumentationSource(
|
||||
item=DocumentationSourceItem(
|
||||
id=source_id,
|
||||
kind=kind,
|
||||
owner_module_id=owner_module_id,
|
||||
label=label,
|
||||
state=state,
|
||||
inspection_url=f"/api/v1/docs/sources/{source_id}",
|
||||
provenance=DocumentationSourceProvenance(
|
||||
source=str(clean_provenance.get("source") or "unknown"),
|
||||
version=_optional_text(clean_provenance.get("version")),
|
||||
revision=_optional_text(clean_provenance.get("revision")),
|
||||
published_at=_optional_text(clean_provenance.get("published_at")),
|
||||
checksum=_optional_text(clean_provenance.get("checksum")),
|
||||
),
|
||||
visibility=_visibility_payload(condition or DocumentationCondition(), documentation_types),
|
||||
inspection=inspection,
|
||||
),
|
||||
condition=condition or DocumentationCondition(),
|
||||
documentation_types=documentation_types,
|
||||
configuration_key=configuration_key,
|
||||
)
|
||||
|
||||
|
||||
def _visibility_payload(
|
||||
condition: DocumentationCondition,
|
||||
documentation_types: tuple[DocumentationType, ...],
|
||||
) -> DocumentationSourceVisibility:
|
||||
return DocumentationSourceVisibility(
|
||||
documentation_types=list(documentation_types),
|
||||
required_modules=list(condition.required_modules),
|
||||
any_modules=list(condition.any_modules),
|
||||
missing_modules=list(condition.missing_modules),
|
||||
required_capabilities=list(condition.required_capabilities),
|
||||
required_scopes=list(condition.required_scopes),
|
||||
any_scopes=list(condition.any_scopes),
|
||||
configuration_keys=list(condition.configuration_keys),
|
||||
)
|
||||
|
||||
|
||||
def _derived_source_id(module_id: str, kind: str, value: str) -> str:
|
||||
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
|
||||
return f"{module_id}.{kind}.{digest}"
|
||||
|
||||
|
||||
def _safe_source_fields(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
redacted = redact_secret_values(dict(value))
|
||||
if not isinstance(redacted, Mapping):
|
||||
return {}
|
||||
return {
|
||||
str(key): item
|
||||
for key, item in redacted.items()
|
||||
if isinstance(item, (str, int, float, bool, list, tuple)) or item is None
|
||||
}
|
||||
|
||||
|
||||
def _optional_text(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text[:2_000] if text else None
|
||||
|
||||
|
||||
def _string_list(value: object) -> list[str]:
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return []
|
||||
return [str(item)[:500] for item in value[:100]]
|
||||
@@ -1,10 +1,21 @@
|
||||
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, NavItem
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationConfigurationDecision,
|
||||
DocumentationConfigurationProviderRegistration,
|
||||
DocumentationSourceDefinition,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
)
|
||||
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,8 +23,11 @@ from govoplan_docs.backend.api.v1.routes import (
|
||||
_condition_visibility,
|
||||
_documentation_topic_anchor,
|
||||
_documentation_topic_groups,
|
||||
_visible_documentation_sources,
|
||||
docs_context,
|
||||
)
|
||||
from govoplan_docs.backend.manifest import get_manifest as get_docs_manifest
|
||||
from govoplan_docs.backend.sources import build_documentation_source_registry
|
||||
|
||||
|
||||
class FakePrincipal:
|
||||
@@ -28,6 +42,18 @@ class FakePrincipal:
|
||||
|
||||
|
||||
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:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(get_tenancy_manifest())
|
||||
@@ -60,7 +86,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 +102,212 @@ 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_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(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="9.9.9",
|
||||
nav_items=(NavItem(path="/example", label="Example", required_any=("docs:documentation:read",)),),
|
||||
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="Backslash runtime", href="/\\evil.invalid", kind="runtime"),
|
||||
DocumentationLink(label="Invisible runtime", href="/hidden", kind="runtime"),
|
||||
DocumentationLink(label="Shell settings", href="/settings?section=example", kind="runtime"),
|
||||
DocumentationLink(label="Public help", href="https://example.invalid/help", kind="public"),
|
||||
DocumentationLink(label="Malformed public", href="https://[", 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."],
|
||||
"help_contexts": ["example.list"],
|
||||
"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", "/settings?section=example", "https://example.invalid/help"],
|
||||
)
|
||||
self.assertEqual(topic["conditions"], [])
|
||||
self.assertEqual(topic["configuration_keys"], [])
|
||||
self.assertEqual(topic["blockers"], {
|
||||
"modules": [],
|
||||
"capabilities": [],
|
||||
"scopes": [],
|
||||
"configuration": [],
|
||||
})
|
||||
self.assertNotIn("raw_policy", topic["metadata"])
|
||||
self.assertNotIn("api_path", topic["metadata"])
|
||||
self.assertEqual(topic["metadata"]["help_contexts"], ["example.list"])
|
||||
self.assertEqual(topic["metadata"]["constraints"][0], {
|
||||
"id": "domain",
|
||||
"label": "Domain",
|
||||
"description": "Use an approved domain.",
|
||||
"values": ["*.example.invalid"],
|
||||
})
|
||||
|
||||
def test_runtime_provider_failure_is_diagnostic_only(self) -> None:
|
||||
def failing_provider(_context):
|
||||
raise RuntimeError("sensitive provider detail")
|
||||
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="private-module",
|
||||
name="Private module",
|
||||
version="1.0.0",
|
||||
documentation_providers=(failing_provider,),
|
||||
))
|
||||
principal = FakePrincipal({"docs:documentation:read"})
|
||||
|
||||
user_layers = _classify_documentation(
|
||||
registry,
|
||||
principal,
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="user",
|
||||
locale="en",
|
||||
)
|
||||
admin_layers = _classify_documentation(
|
||||
registry,
|
||||
principal,
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="admin",
|
||||
locale="en",
|
||||
)
|
||||
|
||||
self.assertFalse(any(user_layers.values()))
|
||||
self.assertEqual(
|
||||
[topic["id"] for topic in admin_layers["evidence"]],
|
||||
["private-module.runtime-documentation-unavailable"],
|
||||
)
|
||||
|
||||
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"], [])
|
||||
self.assertEqual(payload["layers"]["evidence"]["sources"], [])
|
||||
self.assertEqual(
|
||||
[(item["module_id"], item["path"]) for item in payload["layers"]["configured"]["routes"]],
|
||||
[("docs", "/docs")],
|
||||
)
|
||||
self.assertEqual(payload["summary"]["visible_route_count"], 1)
|
||||
|
||||
def test_topic_anchor_is_stable(self) -> None:
|
||||
self.assertEqual(
|
||||
@@ -114,6 +341,7 @@ class DocsContextTests(unittest.TestCase):
|
||||
self.assertEqual(blockers["modules"], ["mail", "files", "campaigns", "legacy"])
|
||||
self.assertEqual(blockers["capabilities"], ["mail.delivery"])
|
||||
self.assertEqual(blockers["scopes"], ["mail:profile:read", "admin:policies:read"])
|
||||
self.assertEqual(blockers["configuration"], [])
|
||||
|
||||
def test_condition_visibility_accepts_any_module_scope_and_capability(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
@@ -129,7 +357,213 @@ class DocsContextTests(unittest.TestCase):
|
||||
|
||||
self.assertTrue(active)
|
||||
self.assertEqual(reason, "conditions satisfied")
|
||||
self.assertEqual(blockers, {"modules": [], "capabilities": [], "scopes": []})
|
||||
self.assertEqual(blockers, {
|
||||
"modules": [],
|
||||
"capabilities": [],
|
||||
"scopes": [],
|
||||
"configuration": [],
|
||||
})
|
||||
|
||||
def test_configuration_states_gate_conditions_without_disclosing_values(self) -> None:
|
||||
states = {
|
||||
"feature.enabled": "enabled",
|
||||
"feature.inherited": "inherited",
|
||||
"feature.disabled": "disabled",
|
||||
"feature.unavailable": "unavailable",
|
||||
}
|
||||
|
||||
def resolve(_context, keys):
|
||||
return {
|
||||
key: DocumentationConfigurationDecision(
|
||||
key=key,
|
||||
state=states[key], # type: ignore[arg-type]
|
||||
source="tenant" if states[key] == "enabled" else "system",
|
||||
reason="State metadata only.",
|
||||
)
|
||||
for key in keys
|
||||
}
|
||||
|
||||
topics = tuple(
|
||||
DocumentationTopic(
|
||||
id=f"example.{key}",
|
||||
title=key,
|
||||
summary="Configuration-aware topic.",
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_scopes=("docs:documentation:read",),
|
||||
configuration_keys=(key,),
|
||||
),
|
||||
),
|
||||
configuration_keys=(key,),
|
||||
)
|
||||
for key in states
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
documentation=topics,
|
||||
documentation_configuration_providers=(
|
||||
DocumentationConfigurationProviderRegistration(
|
||||
keys=tuple(states),
|
||||
resolve=resolve,
|
||||
),
|
||||
),
|
||||
))
|
||||
|
||||
layers = _classify_documentation(
|
||||
registry,
|
||||
FakePrincipal({"docs:documentation:read"}),
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="admin",
|
||||
locale="en",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{topic["id"] for topic in layers["configured"]},
|
||||
{"example.feature.enabled", "example.feature.inherited"},
|
||||
)
|
||||
self.assertEqual(
|
||||
[topic["id"] for topic in layers["available"]],
|
||||
["example.feature.disabled"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[topic["id"] for topic in layers["evidence"]],
|
||||
["example.feature.unavailable"],
|
||||
)
|
||||
rendered = repr(layers)
|
||||
self.assertNotIn("raw value", rendered.casefold())
|
||||
self.assertNotIn("password", rendered.casefold())
|
||||
|
||||
def test_source_registry_covers_every_kind_and_redacts_explicit_payloads(self) -> None:
|
||||
manifest = ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.2.3",
|
||||
capability_factories={
|
||||
"example.lookup": lambda _context: object(),
|
||||
"policy.example": lambda _context: object(),
|
||||
},
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/example",
|
||||
component="ExamplePage",
|
||||
required_all=("example:item:read",),
|
||||
),
|
||||
),
|
||||
),
|
||||
documentation_sources=(
|
||||
DocumentationSourceDefinition(
|
||||
id="example.configuration.package",
|
||||
kind="configuration_package",
|
||||
label="Example package",
|
||||
inspection={
|
||||
"package_id": "example.package",
|
||||
"schema_version": "2",
|
||||
"password": "must-not-leak",
|
||||
},
|
||||
),
|
||||
DocumentationSourceDefinition(
|
||||
id="example.project.wiki",
|
||||
kind="wiki",
|
||||
label="Example wiki",
|
||||
link=DocumentationLink(
|
||||
label="Wiki",
|
||||
href="https://example.invalid/wiki",
|
||||
kind="wiki",
|
||||
),
|
||||
),
|
||||
DocumentationSourceDefinition(
|
||||
id="example.repository.handbook",
|
||||
kind="repository",
|
||||
label="Example handbook",
|
||||
link=DocumentationLink(
|
||||
label="Handbook",
|
||||
href="example/docs/HANDBOOK.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
sources = build_documentation_source_registry((manifest,)).sources()
|
||||
|
||||
self.assertEqual(
|
||||
{source.item.kind for source in sources},
|
||||
{
|
||||
"manifest",
|
||||
"route",
|
||||
"capability",
|
||||
"policy",
|
||||
"configuration_package",
|
||||
"wiki",
|
||||
"repository",
|
||||
},
|
||||
)
|
||||
self.assertNotIn("must-not-leak", repr(sources))
|
||||
for source in sources:
|
||||
self.assertTrue(source.item.id.startswith("example."))
|
||||
self.assertEqual("example", source.item.owner_module_id)
|
||||
self.assertEqual(
|
||||
f"/api/v1/docs/sources/{source.item.id}",
|
||||
source.item.inspection_url,
|
||||
)
|
||||
|
||||
def test_source_visibility_hides_unauthorized_ids(self) -> None:
|
||||
manifest = ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
documentation_sources=(
|
||||
DocumentationSourceDefinition(
|
||||
id="example.protected.handbook",
|
||||
kind="repository",
|
||||
label="Protected handbook",
|
||||
condition=DocumentationCondition(
|
||||
required_scopes=("example:handbook:read",),
|
||||
),
|
||||
link=DocumentationLink(
|
||||
label="Protected",
|
||||
href="example/docs/PROTECTED.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(manifest)
|
||||
sources = build_documentation_source_registry((manifest,)).sources()
|
||||
|
||||
denied = _visible_documentation_sources(
|
||||
sources,
|
||||
registry,
|
||||
FakePrincipal({"docs:documentation:admin"}),
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="admin",
|
||||
locale="en",
|
||||
)
|
||||
allowed = _visible_documentation_sources(
|
||||
sources,
|
||||
registry,
|
||||
FakePrincipal({
|
||||
"docs:documentation:admin",
|
||||
"example:handbook:read",
|
||||
}),
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="admin",
|
||||
locale="en",
|
||||
)
|
||||
|
||||
denied_ids = {source.item.id for source in denied}
|
||||
allowed_ids = {source.item.id for source in allowed}
|
||||
self.assertNotIn("example.protected.handbook", denied_ids)
|
||||
self.assertIn("example.protected.handbook", allowed_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -13,11 +13,11 @@
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.10",
|
||||
"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"
|
||||
|
||||
@@ -54,9 +54,20 @@ export type DocsOptionalModuleEvidence = {
|
||||
};
|
||||
|
||||
export type DocsSource = {
|
||||
id: string;
|
||||
kind: string;
|
||||
owner_module_id: string;
|
||||
label: string;
|
||||
source: string;
|
||||
layer: string;
|
||||
state: "configured" | "disabled" | "unavailable";
|
||||
state_reason?: string | null;
|
||||
inspection_url: string;
|
||||
provenance: {
|
||||
source: string;
|
||||
version?: string | null;
|
||||
revision?: string | null;
|
||||
published_at?: string | null;
|
||||
checksum?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type DocsDocumentationCondition = {
|
||||
@@ -94,6 +105,7 @@ export type DocsDocumentationTopic = {
|
||||
modules: string[];
|
||||
capabilities: string[];
|
||||
scopes: string[];
|
||||
configuration: string[];
|
||||
};
|
||||
audience: string[];
|
||||
order: number;
|
||||
@@ -105,16 +117,23 @@ export type DocsDocumentationTopic = {
|
||||
related_modules: string[];
|
||||
unlocks: string[];
|
||||
configuration_keys: string[];
|
||||
configuration_states: Array<{
|
||||
key: string;
|
||||
state: "enabled" | "disabled" | "inherited" | "unavailable";
|
||||
source?: string | null;
|
||||
reason?: string | null;
|
||||
}>;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DocsContext = {
|
||||
actor: {
|
||||
tenant_id: string;
|
||||
user_id: string;
|
||||
scope_count: number;
|
||||
tenant_id?: string;
|
||||
user_id?: string;
|
||||
scope_count?: number;
|
||||
documentation_type: "admin" | "user";
|
||||
locale: string;
|
||||
available_documentation_types: Array<"admin" | "user">;
|
||||
};
|
||||
summary: {
|
||||
module_count: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
@@ -67,20 +67,29 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
const [context, setContext] = useState<DocsContext | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const loadSequence = useRef(0);
|
||||
const [documentationType, setDocumentationType] = useState<DocumentationType>(() => documentationTypeFromSearch(location.search));
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set());
|
||||
const locale = localeFromSearch(location.search) ?? language;
|
||||
const adminDocs = documentationType === "admin";
|
||||
|
||||
async function load() {
|
||||
const sequence = ++loadSequence.current;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setContext(null);
|
||||
try {
|
||||
setContext(await fetchDocsContext(settings, { documentationType, locale }));
|
||||
const nextContext = await fetchDocsContext(settings, { documentationType, locale });
|
||||
if (sequence !== loadSequence.current) return;
|
||||
if (nextContext.actor.documentation_type !== documentationType) {
|
||||
throw new Error("Documentation response type did not match the requested projection.");
|
||||
}
|
||||
setContext(nextContext);
|
||||
} catch (err) {
|
||||
if (sequence !== loadSequence.current) return;
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (sequence === loadSequence.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +132,11 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
<aside className="section-sidebar docs-outline" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
|
||||
<div className="docs-sidebar-header">
|
||||
<div className="section-title">i18n:govoplan-docs.help_center.f3f3a34b</div>
|
||||
<AudienceToggle selected={documentationType} onSelect={selectDocumentationType} />
|
||||
<AudienceToggle
|
||||
selected={documentationType}
|
||||
onSelect={selectDocumentationType}
|
||||
canViewAdmin={context?.actor.available_documentation_types.includes("admin") ?? documentationType === "admin"}
|
||||
/>
|
||||
</div>
|
||||
<nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
|
||||
<ExplorerTree
|
||||
@@ -211,7 +224,13 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
}
|
||||
}
|
||||
|
||||
function AudienceToggle({ selected, onSelect }: { selected: DocumentationType; onSelect: (type: DocumentationType) => void }) {
|
||||
function AudienceToggle({ selected, onSelect, canViewAdmin }: { selected: DocumentationType; onSelect: (type: DocumentationType) => void; canViewAdmin: boolean }) {
|
||||
const options: Array<{ id: DocumentationType; label: string }> = [
|
||||
{ id: "user", label: "i18n:govoplan-docs.user_docs.1e38e8d3" }
|
||||
];
|
||||
if (canViewAdmin) {
|
||||
options.unshift({ id: "admin", label: "i18n:govoplan-docs.admin_docs.bf504a56" });
|
||||
}
|
||||
return (
|
||||
<SegmentedControl
|
||||
className="docs-audience-toggle"
|
||||
@@ -221,10 +240,7 @@ function AudienceToggle({ selected, onSelect }: { selected: DocumentationType; o
|
||||
ariaLabel="i18n:govoplan-docs.documentation_type.5a66690c"
|
||||
value={selected}
|
||||
onChange={onSelect}
|
||||
options={[
|
||||
{ id: "admin", label: "i18n:govoplan-docs.admin_docs.bf504a56" },
|
||||
{ id: "user", label: "i18n:govoplan-docs.user_docs.1e38e8d3" }
|
||||
]}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -353,10 +369,26 @@ function DocumentationTopicList({ topics, emptyText, showTechnical = false, docu
|
||||
}
|
||||
|
||||
function TopicMetadata({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
|
||||
if (topic.kind === "workflow") return <WorkflowDetails topic={topic} documentationType={documentationType} topicById={topicById} />;
|
||||
if (topic.kind === "reference") return <ReferenceDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} />;
|
||||
if (topic.kind === "pattern") return <PatternDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} />;
|
||||
return <RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} />;
|
||||
const configuredDetails = <ConfiguredDetails topic={topic} />;
|
||||
if (topic.kind === "workflow") return <>{configuredDetails}<WorkflowDetails topic={topic} documentationType={documentationType} topicById={topicById} /></>;
|
||||
if (topic.kind === "reference") return <>{configuredDetails}<ReferenceDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} /></>;
|
||||
if (topic.kind === "pattern") return <>{configuredDetails}<PatternDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} /></>;
|
||||
return <>{configuredDetails}<RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} /></>;
|
||||
}
|
||||
|
||||
function ConfiguredDetails({ topic }: { topic: DocsDocumentationTopic }) {
|
||||
const currentConfiguration = metadataList(topic.metadata, "current_configuration");
|
||||
const limitations = metadataList(topic.metadata, "limitations");
|
||||
const constraints = metadataRecords(topic.metadata, "constraints");
|
||||
const prefix = topicAnchorId(topic);
|
||||
if (!currentConfiguration.length && !limitations.length && !constraints.length) return null;
|
||||
return (
|
||||
<div className="docs-topic-details">
|
||||
{!!currentConfiguration.length && <DetailList id={`${prefix}-current-configuration`} title="i18n:govoplan-docs.this_system.b13a51ad" items={currentConfiguration} />}
|
||||
{!!constraints.length && <ConstraintDetails id={`${prefix}-constraints`} constraints={constraints} />}
|
||||
{!!limitations.length && <DetailList id={`${prefix}-limitations`} title="i18n:govoplan-docs.details.a6b3c45f" items={limitations} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowDetails({ topic, documentationType, topicById }: { topic: DocsDocumentationTopic; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
|
||||
@@ -385,6 +417,30 @@ function WorkflowDetails({ topic, documentationType, topicById }: { topic: DocsD
|
||||
);
|
||||
}
|
||||
|
||||
function ConstraintDetails({ id, constraints }: { id: string; constraints: Record<string, unknown>[] }) {
|
||||
return (
|
||||
<div className="docs-detail-block" id={id}>
|
||||
<h4>i18n:govoplan-docs.requirements.09a428f9</h4>
|
||||
<dl className="detail-list compact">
|
||||
{constraints.map((constraint, index) => {
|
||||
const label = metadataString(constraint, "label");
|
||||
const description = metadataString(constraint, "description");
|
||||
const values = metadataList(constraint, "values");
|
||||
return (
|
||||
<div key={metadataString(constraint, "id") || `${label}-${index}`}>
|
||||
<dt>{label}</dt>
|
||||
<dd>
|
||||
{description}
|
||||
{!!values.length && <span className="muted block">{values.join(", ")}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferenceDetails({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
|
||||
const route = metadataString(topic.metadata, "route");
|
||||
const screen = metadataString(topic.metadata, "screen");
|
||||
@@ -503,7 +559,8 @@ function UnavailableDocumentationReason({ topic }: { topic: DocsDocumentationTop
|
||||
const rows = [
|
||||
["i18n:govoplan-docs.modules.04e9462c", topic.blockers.modules],
|
||||
["i18n:govoplan-docs.capabilities.ca09c54b", topic.blockers.capabilities],
|
||||
["i18n:govoplan-docs.permissions.d06d5557", topic.blockers.scopes]
|
||||
["i18n:govoplan-docs.permissions.d06d5557", topic.blockers.scopes],
|
||||
["Configuration", topic.blockers.configuration]
|
||||
].filter(([, values]) => Array.isArray(values) && values.length);
|
||||
return (
|
||||
<div className="docs-unavailable-reason">
|
||||
@@ -757,8 +814,25 @@ function uniqueTopics(topics: DocsDocumentationTopic[]): DocsDocumentationTopic[
|
||||
|
||||
function selectedPageFromSearch(search: string, pages: DocsPageNode[]): DocsPageNode | null {
|
||||
if (!pages.length) return null;
|
||||
const requested = new URLSearchParams(search).get("topic") || "";
|
||||
return pages.find((page) => page.id === requested) ?? pages[0];
|
||||
const params = new URLSearchParams(search);
|
||||
const requested = params.get("topic") || "";
|
||||
if (requested) return pages.find((page) => page.id === requested) ?? pages[0];
|
||||
const helpContext = params.get("context") || "";
|
||||
if (helpContext) {
|
||||
const exact = pages.find((page) => page.kind === "topic" && metadataList(page.topic.metadata, "help_contexts").includes(helpContext));
|
||||
if (exact) return exact;
|
||||
const moduleId = helpContextModuleId(helpContext);
|
||||
const moduleTopic = pages.find((page) => page.kind === "topic" && page.topic.source_module_id === moduleId);
|
||||
if (moduleTopic) return moduleTopic;
|
||||
}
|
||||
return pages[0];
|
||||
}
|
||||
|
||||
function helpContextModuleId(value: string): string {
|
||||
const prefix = value.split(".", 1)[0];
|
||||
if (prefix === "campaign") return "campaigns";
|
||||
if (prefix === "address-book") return "addresses";
|
||||
return prefix;
|
||||
}
|
||||
|
||||
function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): OutlineItem[] {
|
||||
@@ -781,6 +855,9 @@ function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): Outl
|
||||
const prefix = topicAnchorId(topic);
|
||||
const items: OutlineItem[] = [{ id: prefix, label: topic.title }];
|
||||
if (topic.summary) items.push({ id: `${prefix}-summary`, label: "i18n:govoplan-docs.summary.d6b9936d" });
|
||||
if (metadataList(topic.metadata, "current_configuration").length) items.push({ id: `${prefix}-current-configuration`, label: "i18n:govoplan-docs.this_system.b13a51ad" });
|
||||
if (metadataRecords(topic.metadata, "constraints").length) items.push({ id: `${prefix}-constraints`, label: "i18n:govoplan-docs.requirements.09a428f9" });
|
||||
if (metadataList(topic.metadata, "limitations").length) items.push({ id: `${prefix}-limitations`, label: "i18n:govoplan-docs.details.a6b3c45f" });
|
||||
if (topic.kind === "workflow") {
|
||||
if (metadataString(topic.metadata, "outcome")) items.push({ id: `${prefix}-outcome`, label: "i18n:govoplan-docs.outcome.10172bd3" });
|
||||
if (metadataList(topic.metadata, "prerequisites").length) items.push({ id: `${prefix}-prerequisites`, label: "i18n:govoplan-docs.prerequisites.fdf2407f" });
|
||||
@@ -800,7 +877,7 @@ function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): Outl
|
||||
}
|
||||
|
||||
function documentationTypeFromSearch(search: string): DocumentationType {
|
||||
return new URLSearchParams(search).get("type") === "user" ? "user" : "admin";
|
||||
return new URLSearchParams(search).get("type") === "admin" ? "admin" : "user";
|
||||
}
|
||||
|
||||
function localeFromSearch(search: string): string | null {
|
||||
@@ -899,9 +976,9 @@ function EvidenceList({ modules, sources }: { modules: DocsOptionalModuleEvidenc
|
||||
</div>
|
||||
)}
|
||||
{sources.map((item) =>
|
||||
<div key={item.source}>
|
||||
<dt>{item.layer}</dt>
|
||||
<dd><strong>{item.label}</strong><span className="muted"> · {item.source}</span></dd>
|
||||
<div key={item.id}>
|
||||
<dt><StatusBadge status={item.state === "configured" ? "success" : item.state === "disabled" ? "inactive" : "warning"} label={item.state} /></dt>
|
||||
<dd><strong>{item.label}</strong><span className="muted"> · {item.owner_module_id} · {item.kind} · {item.provenance.source}</span></dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
|
||||
const DocsPage = lazy(() => import("./features/docs/DocsPage"));
|
||||
|
||||
const docsReadScopes = ["docs:documentation:read", "system:settings:read", "admin:settings:read"];
|
||||
const docsReadScopes = ["docs:documentation:read", "docs:documentation:admin", "system:settings:read", "admin:settings:read"];
|
||||
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
@@ -14,7 +14,7 @@ const translations = {
|
||||
export const docsModule: PlatformWebModule = {
|
||||
id: "docs",
|
||||
label: "i18n:govoplan-docs.docs.68a41942",
|
||||
version: "1.0.0",
|
||||
version: "0.1.10",
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["policy", "audit", "ops", "workflow", "search"],
|
||||
translations,
|
||||
|
||||
Reference in New Issue
Block a user