feat(authorization-obligations): group sibling handlers and find consistency anomalies

Task 2.2. Handlers on the same resource under the same module are siblings;
when at least min_siblings of them discharge an obligation and one does not,
that one is the anomaly and the discharging siblings are its evidence (Req 3.2).
A set with fewer members than the minimum is under-populated and never reported
(Req 3.3). A public route among authenticated siblings is a missing path guard
with the mapper's access classification as evidence (Req 3.4).

The sibling key is the module path without its suffix, so two same-named files
in different directories are never merged. Witness validity (an identity
constraint counts only when it binds the authenticated context) is one helper
shared by operations, siblings and the checker.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
Marius Ciepluch
2026-09-06 07:24:13 +02:00
co-authored by Claude Opus 5
parent fe5f233c00
commit 8e26dfd1e2
10 changed files with 269 additions and 19 deletions
-3
View File
@@ -60,9 +60,6 @@ def test_loader_rejects_kinds_and_fields_outside_the_closed_sets(tmp_path: Path)
(tmp_path / "python.toml").write_text(good + '\n[[discharger]]\nid = "g"\nkind = "maybe_guard"\ndecorators = ["login_required"]\n')
with pytest.raises(ObligationFactsError, match="maybe_guard"):
load_obligation_facts(tmp_path)
(tmp_path / "python.toml").write_text(good + '\n[[discharger]]\nid = "g"\nkind = "maybe_guard"\ndecorators = ["login_required"]\n')
with pytest.raises(ObligationFactsError, match="maybe_guard"):
load_obligation_facts(tmp_path)
def test_flow_facts_loader_ignores_the_obligation_facts_directory() -> None:
+11 -5
View File
@@ -10,9 +10,12 @@ APP = (
"from flask import request\n\n\n"
"@app.route('/books/<title>')\n@login_required\ndef guarded(title):\n"
" return Book.query.filter_by(book_title=title).first()\n\n\n"
"@app.route('/books/mine/<title>')\ndef constrained(title):\n"
"@app.route('/books/mine/<title>')\n@login_required\ndef constrained(title):\n"
" user_id = request.user.id\n"
" return Book.query.filter_by(user_id=user_id, book_title=title).first()\n\n\n"
"@app.route('/books/shared/<title>')\ndef constrained_too(title):\n"
" owner = current_user.id\n"
" return Book.query.filter_by(owner=owner, book_title=title).first()\n\n\n"
"@app.route('/books/any/<title>')\ndef leaky(title):\n"
" owner = request.args.get('owner')\n"
" return Book.query.filter_by(user_id=owner, book_title=title).first()\n\n\n"
@@ -39,9 +42,10 @@ def _entry(function: str, line: int, end: int, access: str, evidence: list[str])
ENTRIES = [
_entry("guarded", 6, 7, "authenticated", ["@login_required"]),
_entry("constrained", 11, 13, "public", []),
_entry("leaky", 17, 19, "public", []),
_entry("health", 23, 24, "public", []),
_entry("constrained", 12, 14, "authenticated", ["@login_required"]),
_entry("constrained_too", 18, 20, "public", []),
_entry("leaky", 24, 26, "public", []),
_entry("health", 30, 31, "public", []),
]
@@ -54,6 +58,7 @@ def test_find_operations_names_kind_fact_function_and_resource() -> None:
assert [(op.function, op.kind, op.resource) for op in ops] == [
("guarded", "protected_read", "book"),
("constrained", "protected_read", "book"),
("constrained_too", "protected_read", "book"),
("leaky", "protected_read", "book"),
]
assert all(op.path == "app.py" and op.fact_id == "orm-read" and op.line > 0 for op in ops)
@@ -73,6 +78,7 @@ def test_find_discharges_reports_witness_kind_provenance_and_scope() -> None:
by_function.setdefault(w.function, []).append((w.kind, w.provenance, w.scope))
assert ("path_guard", "authenticated_context", "decorator") in by_function["guarded"]
assert ("identity_constraint", "authenticated_context", "statement") in by_function["constrained"]
assert ("identity_constraint", "authenticated_context", "statement") in by_function["constrained_too"]
assert ("identity_constraint", "request_input", "statement") in by_function["leaky"] # present, but discharges nothing
assert "health" not in by_function
assert all(w.path == "app.py" and w.fact_id for w in witnesses)
@@ -87,6 +93,6 @@ def test_discharge_witness_covers_operation_only_when_kind_and_provenance_fit()
ops = {op.function: op for op in find_operations(ir, facts, text=APP)}
witnesses = find_discharges(ir, facts, load_facts().for_language("python"), text=APP, entries=ENTRIES)
covering = {op.function: [w.kind for w in witnesses if covers(w, op)] for op in ops.values()}
assert covering["constrained"] == ["identity_constraint"]
assert covering["constrained"] == ["identity_constraint"] and covering["constrained_too"] == ["identity_constraint"]
assert covering["leaky"] == [] # request-bound identity does not cover
assert covering["guarded"] == [] # a path guard does not cover a protected read (only privileged actions)
+1
View File
@@ -42,6 +42,7 @@ def test_unknown_fields_and_values_are_rejected_by_name(tmp_path: Path) -> None:
(GOOD.replace('sensitivity = "high"', 'sensitivity = "critical"'), "critical"),
(GOOD + "\nallow_everything = true\n", "allow_everything"),
(GOOD.replace('identity_source = "request.user"', 'identity_source = "whatever.works"'), "identity_source"),
(GOOD + '\n[[route]]\npath = "/x"\naccess = "public"\ncolor = "blue"\n', "color"),
):
path.write_text(bad)
with pytest.raises(PolicyError, match=needle):
+111
View File
@@ -0,0 +1,111 @@
"""authorization-obligations task 2.2: sibling handler sets and consistency anomalies (offline)."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from test_obligation_operations import APP, ENTRIES # noqa: E402
from openultrasast.semantic.facts import load_facts # noqa: E402
from openultrasast.semantic.ir import parse_file # noqa: E402
def _inputs():
from openultrasast.semantic.obligations import load_obligation_facts
from openultrasast.semantic.obligations.operations import find_discharges, find_operations
ir = parse_file("app.py", APP, "python")
facts = load_obligation_facts().for_language("python")
ops = find_operations(ir, facts, text=APP)
wit = find_discharges(ir, facts, load_facts().for_language("python"), text=APP, entries=ENTRIES)
return ops, wit
def test_sibling_sets_group_by_router_and_resource_and_record_who_discharges() -> None:
from openultrasast.semantic.obligations.siblings import sibling_sets
ops, wit = _inputs()
sets = sibling_sets(ENTRIES, ops, wit)
assert len(sets) == 1
(books,) = sets
assert books.key == ("app", "book")
assert set(books.handlers) == {"app.py::guarded", "app.py::constrained", "app.py::constrained_too", "app.py::leaky"}
assert books.discharging["identity_constraint"] == (
"app.py::constrained",
"app.py::constrained_too",
) # leaky's request-bound one does not count
assert books.discharging["path_guard"] == ("app.py::constrained", "app.py::guarded")
assert books.operation_kinds == ("protected_read",)
def test_consistency_anomalies_respect_the_minimum_and_name_the_siblings() -> None:
from openultrasast.semantic.obligations.siblings import consistency_anomalies, sibling_sets
ops, wit = _inputs()
sets = sibling_sets(ENTRIES, ops, wit)
anomalies, under = consistency_anomalies(sets, min_siblings=2)
by_handler = {(a.handler, a.missing): a for a in anomalies}
# tasks.md observable: two siblings constrain by owner -> one anomaly per unconstrained handler naming both siblings
assert by_handler[("app.py::leaky", "identity_constraint")].siblings == ("app.py::constrained", "app.py::constrained_too")
assert by_handler[("app.py::guarded", "identity_constraint")].siblings == ("app.py::constrained", "app.py::constrained_too")
assert by_handler[("app.py::leaky", "identity_constraint")].operation_kind == "protected_read"
assert ("app.py::constrained", "identity_constraint") not in by_handler
assert under == ()
# Req 3.2: the configured minimum applies to how many siblings discharge, not to one sibling setting the norm
anomalies_three, _ = consistency_anomalies(sets, min_siblings=3)
assert not [a for a in anomalies_three if a.missing == "identity_constraint"] # only two discharge
anomalies_strict, under_strict = consistency_anomalies(sets, min_siblings=5)
assert anomalies_strict == () and under_strict == (("app", "book"),) # Req 3.3: under-populated, never reported
def test_public_route_among_authenticated_siblings_is_a_missing_path_guard_with_access_evidence() -> None:
from openultrasast.semantic.obligations.siblings import consistency_anomalies, sibling_sets
ops, wit = _inputs()
sets = sibling_sets(ENTRIES, ops, wit)
anomalies, _ = consistency_anomalies(sets, min_siblings=2)
guard_anomalies = [a for a in anomalies if a.missing == "path_guard"]
assert {a.handler for a in guard_anomalies} == {"app.py::constrained_too", "app.py::leaky"}
assert all(a.access_level == "public" and "authenticated" in " ".join(a.evidence) for a in guard_anomalies)
assert all(a.siblings == ("app.py::constrained", "app.py::guarded") for a in guard_anomalies)
def test_same_named_files_in_different_modules_are_not_siblings() -> None:
"""Round-2 finding: the key must be the module path, not the file's basename."""
from openultrasast.mapping import EntryPointRecord
from openultrasast.semantic.obligations.operations import Operation
from openultrasast.semantic.obligations.siblings import sibling_sets
def entry(path: str, function: str) -> EntryPointRecord:
return EntryPointRecord(
path=path,
line=1,
end_line=2,
function_name=function,
name=function,
kind="route",
access_level="public",
trust_boundary="http_request",
access_evidence=[],
conditions=[],
provenance="t",
rationale="",
) # type: ignore[arg-type]
def op(path: str, function: str) -> Operation:
return Operation(
path=path,
line=2,
function=function,
kind="protected_read",
fact_id="orm-read",
resource="book",
requires=("identity_constraint",),
)
entries = [entry("api/v1/books.py", "list_a"), entry("admin/legacy/books.py", "list_b")]
sets = sibling_sets(entries, [op("api/v1/books.py", "list_a"), op("admin/legacy/books.py", "list_b")], [])
assert {group.key for group in sets} == {("api/v1/books", "book"), ("admin/legacy/books", "book")}
assert all(len(group.handlers) == 1 for group in sets)