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
@@ -12,7 +12,7 @@ sensitivity = "high"
[[operation]]
id = "orm-write"
kind = "protected_write"
calls = ["update", "updateOne", "updateMany", "deleteOne", "deleteMany", "delete", "create", "insert", "upsert", "save", "remove", "findByIdAndUpdate", "findByIdAndDelete"]
calls = ["updateOne", "updateMany", "deleteOne", "deleteMany", "findByIdAndUpdate", "findByIdAndDelete", "upsert", "insertOne", "insertMany", "prisma.update", "prisma.delete", "prisma.create"]
requires = ["identity_constraint", "ownership_check"]
sensitivity = "high"
@@ -33,7 +33,7 @@ sensitivity = "high"
[[operation]]
id = "security-setting"
kind = "security_setting"
calls = ["cors", "cookie", "session", "helmet", "listen", "use", "setHeader", "createClient", "from"]
calls = ["cors", "cookie", "session", "helmet", "app.listen", "setHeader", "createClient"]
resource_arg = 0
requires = ["non_permissive_value"]
sensitivity = "medium"
@@ -12,7 +12,7 @@ sensitivity = "high"
[[operation]]
id = "orm-write"
kind = "protected_write"
calls = ["add", "delete", "update", "save", "commit", "insert_one", "update_one", "delete_one", "bulk_update"]
calls = ["session.add", "session.delete", "session.merge", "query.update", "query.delete", "objects.update", "objects.create", "objects.filter", "insert_one", "update_one", "delete_one", "bulk_update"]
requires = ["identity_constraint", "ownership_check"]
sensitivity = "high"
@@ -33,7 +33,7 @@ sensitivity = "high"
[[operation]]
id = "security-setting"
kind = "security_setting"
calls = ["CORS", "cross_origin", "set_cookie", "Fernet", "run", "config.update", "TrustedHostMiddleware", "add_middleware", "set_debug"]
calls = ["CORS", "cross_origin", "set_cookie", "app.run", "config.update", "TrustedHostMiddleware", "add_middleware", "set_debug"]
resource_arg = 0
requires = ["non_permissive_value"]
sensitivity = "medium"
@@ -42,7 +42,7 @@ sensitivity = "medium"
id = "route-guard"
kind = "path_guard"
decorators = ["login_required", "jwt_required", "auth_required", "roles_required", "permission_required", "admin_required", "requires_auth", "authenticated", "token_required"]
calls = ["token_validator", "verify_jwt_in_request", "get_jwt_identity", "check_auth", "require_login", "authenticate", "abort", "login_required", "current_user_required"]
calls = ["token_validator", "verify_jwt_in_request", "get_jwt_identity", "check_auth", "require_login", "authenticate", "login_required", "current_user_required"]
[[discharger]]
id = "identity-from-context"
@@ -59,7 +59,7 @@ calls = ["check_owner", "is_owner", "assert_owner", "ensure_owner", "owns", "can
[[discharger]]
id = "safe-setting"
kind = "non_permissive_value"
calls = ["CORS", "cross_origin", "set_cookie", "run", "config.update", "add_middleware"]
calls = ["CORS", "cross_origin", "set_cookie", "app.run", "config.update", "add_middleware"]
permissive_values = ["'*'", "\"*\"", "True", "0.0.0.0", "None", "False", "allow_all", "disable"]
[[discharger]]
@@ -12,7 +12,7 @@ sensitivity = "high"
[[operation]]
id = "orm-write"
kind = "protected_write"
calls = ["update", "updateOne", "updateMany", "deleteOne", "deleteMany", "delete", "create", "insert", "upsert", "save", "remove", "findByIdAndUpdate", "findByIdAndDelete"]
calls = ["updateOne", "updateMany", "deleteOne", "deleteMany", "findByIdAndUpdate", "findByIdAndDelete", "upsert", "insertOne", "insertMany", "prisma.update", "prisma.delete", "prisma.create"]
requires = ["identity_constraint", "ownership_check"]
sensitivity = "high"
@@ -33,7 +33,7 @@ sensitivity = "high"
[[operation]]
id = "security-setting"
kind = "security_setting"
calls = ["cors", "cookie", "session", "helmet", "listen", "use", "setHeader", "createClient", "from"]
calls = ["cors", "cookie", "session", "helmet", "app.listen", "setHeader", "createClient"]
resource_arg = 0
requires = ["non_permissive_value"]
sensitivity = "medium"
@@ -6,6 +6,7 @@ ownership check, a non-permissive value, validated input). Facts are closed data
findings never claim a proof rung.
"""
from .check import ObligationFinding, ObligationResult, check_obligations, findings_to_static
from .facts import (
DISCHARGER_KINDS,
OPERATION_KINDS,
@@ -25,10 +26,14 @@ __all__ = [
"DischargerFact",
"ObligationFacts",
"ObligationFactsError",
"ObligationFinding",
"ObligationResult",
"ObligationShape",
"OperationFact",
"check_obligations",
"derive_obligation",
"explain_skip",
"findings_to_static",
"load_obligation_facts",
"obligation_mechanisms",
]
@@ -103,6 +103,11 @@ def find_discharges(
return tuple(_dedupe(witnesses))
def valid_witness(witness: Discharge) -> bool:
"""An identity constraint witnesses a discharge only when its value comes from the authenticated context."""
return witness.kind != "identity_constraint" or witness.provenance == "authenticated_context"
def covers(witness: Discharge, operation: Operation) -> bool:
"""A witness discharges an operation when it sits in the same function, has a kind the operation requires, and, for an
identity constraint, binds its value from the authenticated context. Ordering along the path is the Dominance protocol's
@@ -111,7 +116,7 @@ def covers(witness: Discharge, operation: Operation) -> bool:
return False
if witness.kind not in operation.requires:
return False
if witness.kind == "identity_constraint" and witness.provenance != "authenticated_context":
if not valid_witness(witness):
return False
return not (witness.kind == "non_permissive_value" and witness.line != operation.line)
@@ -130,7 +135,7 @@ def _decorator_witnesses(path: str, function: FunctionIR, fact: DischargerFact,
out: list[Discharge] = []
for decorator in decorators:
name = _decorator_name(decorator)
if any(candidate == name or candidate in decorator for candidate in fact.decorators):
if any(candidate == name for candidate in fact.decorators): # the parsed decorator name only, never free text
out.append(
Discharge(
path=path,
@@ -245,4 +250,4 @@ def _dedupe(witnesses: Sequence[Discharge]) -> list[Discharge]:
return out
__all__ = ["SCOPES", "Discharge", "Operation", "covers", "find_discharges", "find_operations"]
__all__ = ["SCOPES", "Discharge", "Operation", "covers", "find_discharges", "find_operations", "valid_witness"]
@@ -0,0 +1,125 @@
"""Sibling handler sets and consistency anomalies (authorization-obligations, Req 3).
Handlers that operate on the same resource under the same router or module are siblings. When at least the configured
minimum number of siblings discharge an obligation and one does not (Req 3.2), that one is the anomaly; its evidence is
the siblings that discharge. A set with fewer members than the minimum is counted as under-populated and never produces
a finding (Req 3.3). Nothing here touches other findings.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path
from .facts import DISCHARGER_KINDS
from .operations import Discharge, Operation, valid_witness
@dataclass(frozen=True)
class SiblingSet:
key: tuple[str, str] # (module path without suffix, resource token)
handlers: tuple[str, ...] # "path::function"
discharging: dict[str, tuple[str, ...]] # discharger kind -> handlers that carry a valid witness of that kind
operation_kinds: tuple[str, ...]
access_levels: dict[str, str] = field(default_factory=dict) # handler -> entry-point access classification
@dataclass(frozen=True)
class Anomaly:
handler: str
missing: str # discharger kind the siblings practice and this handler lacks
operation_kind: str
siblings: tuple[str, ...] # handlers that discharge
key: tuple[str, str]
access_level: str = "unknown"
evidence: tuple[str, ...] = ()
def sibling_sets(entries: Sequence[object], operations: Sequence[Operation], discharges: Sequence[Discharge]) -> tuple[SiblingSet, ...]:
"""Group entry-point handlers by (module, resource) over the operations they perform; record who discharges what."""
by_handler_ops: dict[str, list[Operation]] = {}
for operation in operations:
by_handler_ops.setdefault(f"{operation.path}::{operation.function}", []).append(operation)
access: dict[str, str] = {}
for entry in entries:
name = getattr(entry, "function_name", None)
path = getattr(entry, "path", None)
if name and path:
access[f"{path}::{name}"] = str(getattr(entry, "access_level", "unknown"))
valid: dict[str, set[str]] = {}
for witness in discharges:
if valid_witness(witness):
valid.setdefault(f"{witness.path}::{witness.function}", set()).add(witness.kind)
groups: dict[tuple[str, str], dict[str, list[Operation]]] = {}
for handler, ops in by_handler_ops.items():
if handler not in access:
continue # only entry-point handlers are siblings; helpers are reached through them
module = _module_of(handler.split("::", 1)[0])
for operation in ops:
if operation.resource is None:
continue
groups.setdefault((module, operation.resource), {}).setdefault(handler, []).append(operation)
sets: list[SiblingSet] = []
for key, members in sorted(groups.items()):
handlers = tuple(sorted(members))
discharging = {
kind: tuple(sorted(handler for handler in handlers if kind in valid.get(handler, set()))) for kind in DISCHARGER_KINDS
}
kinds = tuple(sorted({operation.kind for ops in members.values() for operation in ops}))
sets.append(
SiblingSet(
key=key,
handlers=handlers,
discharging={kind: who for kind, who in discharging.items() if who},
operation_kinds=kinds,
access_levels={handler: access.get(handler, "unknown") for handler in handlers},
)
)
return tuple(sets)
def consistency_anomalies(sets: Sequence[SiblingSet], *, min_siblings: int) -> tuple[tuple[Anomaly, ...], tuple[tuple[str, str], ...]]:
"""Anomalies where at least ``min_siblings`` siblings discharge a kind another sibling lacks; sets with fewer members than
``min_siblings`` are returned as under-populated instead. One threshold, read as Req 3.2 states it."""
anomalies: list[Anomaly] = []
under: list[tuple[str, str]] = []
for group in sets:
if len(group.handlers) < min_siblings:
under.append(group.key)
continue
for kind, dischargers in sorted(group.discharging.items()):
if len(dischargers) < min_siblings:
continue # one sibling does not set a norm
for handler in group.handlers:
if handler in dischargers:
continue
access = group.access_levels.get(handler, "unknown")
evidence: list[str] = [f"{sibling} discharges {kind}" for sibling in dischargers]
if kind == "path_guard":
evidence.append(
f"route access: {access}; siblings: "
+ ", ".join(sorted({group.access_levels.get(s, "unknown") for s in dischargers}))
)
for operation_kind in group.operation_kinds:
anomalies.append(
Anomaly(
handler=handler,
missing=kind,
operation_kind=operation_kind,
siblings=dischargers,
key=group.key,
access_level=access,
evidence=tuple(evidence),
)
)
return tuple(anomalies), tuple(under)
def _module_of(path: str) -> str:
"""The module path without its suffix: `api/v1/books.py` -> `api/v1/books`. Two same-named files in different
directories are different modules, so their handlers are never siblings."""
return Path(path).with_suffix("").as_posix()
__all__ = ["Anomaly", "SiblingSet", "consistency_anomalies", "sibling_sets"]