Access controlled knowledge gateway functionality

This commit is contained in:
2026-05-04 15:00:16 +02:00
parent e87406ac9e
commit d923661852
20 changed files with 1486 additions and 14 deletions

View File

@@ -52,6 +52,7 @@ from markitect_tool.generation import (
from markitect_tool.literate import tangle_markdown, weave_markdown, write_tangle_files
from markitect_tool.ops import IncludeError, compose_files, resolve_includes, transform_markdown
from markitect_tool.processor import ProcessorContext, run_fenced_processors
from markitect_tool.policy import LocalLabelPolicyGateway
from markitect_tool.query import (
InvalidQueryError,
extract_document,
@@ -727,6 +728,69 @@ def backend_refresh_plan(
raise click.exceptions.Exit(1 if plan.dirty else 0)
@main.group()
def policy() -> None:
"""Check local access policy decisions."""
@policy.command("check")
@click.argument("subject")
@click.argument("action")
@click.argument("object_id")
@click.option(
"--policy",
"policy_file",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="Local label policy file.",
)
@click.option("--label", "labels", multiple=True, help="Object policy label. May be repeated.")
@click.option("--path", "object_path", help="Object path for path ACL and path-label rules.")
@click.option("--trust-zone", help="Object trust zone.")
@click.option(
"--policy-mode",
type=click.Choice(["off", "audit", "enforce"], case_sensitive=False),
help="Override policy mode for this check.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
default="text",
show_default=True,
)
def policy_check(
subject: str,
action: str,
object_id: str,
policy_file: Path | None,
labels: tuple[str, ...],
object_path: str | None,
trust_zone: str | None,
policy_mode: str | None,
output_format: str,
) -> None:
"""Authorize one subject/action/object tuple with local label policy."""
try:
gateway = _load_policy_gateway(policy_file, policy_mode) or LocalLabelPolicyGateway()
decision = gateway.authorize(
subject,
action,
object_id,
context={
"object": {
"labels": list(labels),
"path": object_path,
"trust_zone": trust_zone,
}
},
)
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
_emit_policy_result({"decision": decision}, output_format)
raise click.exceptions.Exit(0 if decision.get("allowed") else 1)
@main.group("class")
def class_group() -> None:
"""Resolve deterministic content classes."""
@@ -1028,6 +1092,18 @@ def cache_index(
multiple=True,
help="Restrict query to one or more indexed relative paths.",
)
@click.option(
"--policy",
"policy_file",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="Local label policy file used to filter results.",
)
@click.option("--subject", default="anonymous", help="Policy subject id.")
@click.option(
"--policy-mode",
type=click.Choice(["off", "audit", "enforce"], case_sensitive=False),
help="Override policy mode for this query.",
)
@click.option(
"--engine",
type=click.Choice(["selector", "jsonpath"], case_sensitive=False),
@@ -1047,17 +1123,22 @@ def cache_query(
root: Path,
index_path: Path | None,
paths: tuple[str, ...],
policy_file: Path | None,
subject: str,
policy_mode: str | None,
engine: str,
output_format: str,
) -> None:
"""Run a selector or JSONPath query over indexed document snapshots."""
store = LocalSnapshotStore(local_index_path_for(root, index_path))
policy_gateway = _load_policy_gateway(policy_file, policy_mode)
indexed_paths = sorted(paths or [state.path for state in store.load_state()])
all_matches = []
try:
for indexed_path in indexed_paths:
document = Document.from_dict(store.get_document(indexed_path))
policy_metadata = store.policy_metadata(indexed_path) if policy_gateway else {}
matches = (
query_document_jsonpath(document, selector)
if engine == "jsonpath"
@@ -1066,11 +1147,17 @@ def cache_query(
for match in matches:
item = match.to_dict()
item["source_path"] = indexed_path
if policy_metadata:
item["policy"] = policy_metadata
all_matches.append(item)
except KeyError as exc:
raise click.ClickException(str(exc)) from exc
except InvalidQueryError as exc:
raise click.ClickException(str(exc)) from exc
policy_result = None
if policy_gateway:
policy_result = policy_gateway.filter_results(subject, "query", all_matches)
all_matches = policy_result["results"]
data = {
"selector": selector,
"engine": engine,
@@ -1078,6 +1165,10 @@ def cache_query(
"count": len(all_matches),
"matches": all_matches,
}
if policy_result:
data["policy"] = policy_result.get("policy")
data["policy_decisions"] = policy_result.get("decisions")
data["diagnostics"] = policy_result.get("diagnostics")
_emit_query(data, output_format)
@@ -1096,6 +1187,18 @@ def cache_query(
help="SQLite index path. Defaults to .markitect/cache/index.sqlite3 under root.",
)
@click.option("--limit", type=int, default=20, show_default=True)
@click.option(
"--policy",
"policy_file",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="Local label policy file used to filter results.",
)
@click.option("--subject", default="anonymous", help="Policy subject id.")
@click.option(
"--policy-mode",
type=click.Choice(["off", "audit", "enforce"], case_sensitive=False),
help="Override policy mode for this search.",
)
@click.option(
"--format",
"output_format",
@@ -1108,21 +1211,39 @@ def search(
root: Path,
index_path: Path | None,
limit: int,
policy_file: Path | None,
subject: str,
policy_mode: str | None,
output_format: str,
) -> None:
"""Search the local SQLite index with FTS5."""
try:
store = LocalSnapshotStore(local_index_path_for(root, index_path))
results = store.search(text, limit=limit)
policy_gateway = _load_policy_gateway(policy_file, policy_mode)
if policy_gateway:
policy_result = store.search_with_policy(
text,
subject=subject,
gateway=policy_gateway,
limit=limit,
)
matches = policy_result["results"]
else:
policy_result = None
matches = [result.to_dict() for result in store.search(text, limit=limit)]
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
data = {
"query": text,
"index_path": str(local_index_path_for(root, index_path)),
"count": len(results),
"matches": [result.to_dict() for result in results],
"count": len(matches),
"matches": matches,
}
if policy_result:
data["policy"] = policy_result.get("policy")
data["policy_decisions"] = policy_result.get("decisions")
data["diagnostics"] = policy_result.get("diagnostics")
_emit_search_results(data, output_format)
@@ -1529,6 +1650,20 @@ def contract_form_state(
raise click.exceptions.Exit(0 if form_state.valid else 1)
def _load_policy_gateway(
policy_file: Path | None,
policy_mode: str | None,
) -> LocalLabelPolicyGateway | None:
if policy_file is None and policy_mode is None:
return None
try:
if policy_file:
return LocalLabelPolicyGateway.from_file(policy_file, mode=policy_mode)
return LocalLabelPolicyGateway(mode=policy_mode)
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
def _emit_result(data: dict, output_format: str) -> None:
if output_format == "json":
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
@@ -1588,6 +1723,19 @@ def _emit_form_state(data: dict, output_format: str) -> None:
)
def _emit_policy_result(data: dict, output_format: str) -> None:
if output_format == "json":
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
elif output_format == "yaml":
click.echo(yaml.safe_dump(data, sort_keys=False))
else:
decision = data["decision"]
click.echo("allowed" if decision.get("allowed") else "denied")
click.echo(f"effect: {decision.get('effect')}")
click.echo(f"decision_id: {decision.get('decision_id')}")
click.echo(f"reason: {decision.get('reason')}")
def _emit_metrics(data: dict, output_format: str) -> None:
if output_format == "json":
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
@@ -1615,11 +1763,15 @@ def _emit_query(data: dict, output_format: str) -> None:
click.echo(yaml.safe_dump(data, sort_keys=False))
else:
click.echo(f"{data['count']} match(es)")
if data.get("policy"):
_emit_policy_summary(data["policy"])
for match in data["matches"]:
location = f":{match['line']}" if match.get("line") else ""
click.echo(f"- {match['kind']} {match['path']}{location}")
if match.get("text"):
click.echo(f" {match['text'].splitlines()[0]}")
for diagnostic in data.get("diagnostics", []):
click.echo(f"! [{diagnostic['severity']}] {diagnostic['code']}: {diagnostic['message']}")
def _emit_extract(data: dict, output_format: str) -> None:
@@ -1709,6 +1861,8 @@ def _emit_search_results(data: dict, output_format: str) -> None:
click.echo(yaml.safe_dump(data, sort_keys=False))
else:
click.echo(f"{data['count']} match(es)")
if data.get("policy"):
_emit_policy_summary(data["policy"])
for match in data["matches"]:
span = ""
if match.get("line_start"):
@@ -1720,6 +1874,19 @@ def _emit_search_results(data: dict, output_format: str) -> None:
preview = " ".join(str(match.get("text", "")).split())
if preview:
click.echo(f" {preview[:160]}")
for diagnostic in data.get("diagnostics", []):
click.echo(f"! [{diagnostic['severity']}] {diagnostic['code']}: {diagnostic['message']}")
def _emit_policy_summary(policy_data: dict) -> None:
click.echo(
"policy: "
f"mode={policy_data.get('mode')} "
f"subject={policy_data.get('subject')} "
f"allowed={policy_data.get('allowed', 0)} "
f"denied={policy_data.get('denied', 0)} "
f"redacted={policy_data.get('redacted', 0)}"
)
def _emit_workflow_result(data: dict, output_format: str) -> None: