From 7eb491dc82d42a7fd37df7678555f81e61d64c45 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:11:30 +0100 Subject: [PATCH] feat(access-control-prover): 10.3 authority matching through promoted implications --- .kiro/specs/access-control-prover/tasks.md | 2 +- .../semantic/obligations/witness.py | 56 +++++++-- tests/test_witness_matching.py | 119 +++++++++++++++++- 3 files changed, 164 insertions(+), 13 deletions(-) diff --git a/.kiro/specs/access-control-prover/tasks.md b/.kiro/specs/access-control-prover/tasks.md index 507f407..d10acfd 100644 --- a/.kiro/specs/access-control-prover/tasks.md +++ b/.kiro/specs/access-control-prover/tasks.md @@ -179,7 +179,7 @@ its final resolution, dominance, fact-acquisition and measurement semantics with - _Requirements: 2.1, 2.2, 2.5, 2.6, 2.7, 11.1_ - _Boundary: Witness Matcher_ - _Depends: 7.2, 10.1_ -- [ ] 10.3 Match authority and promoted implications +- [x] 10.3 Match authority and promoted implications - Apply only promoted authority implications and preserve their evidence. - Return NO for insufficient authority and UNKNOWN when compatibility cannot be decided. - Done when stronger, weaker, unrelated and unknown authorities produce the expected relations. diff --git a/src/openultrasast/semantic/obligations/witness.py b/src/openultrasast/semantic/obligations/witness.py index 814122f..133c057 100644 --- a/src/openultrasast/semantic/obligations/witness.py +++ b/src/openultrasast/semantic/obligations/witness.py @@ -1,17 +1,22 @@ -"""Witness kind, scope and resource matching (task 10.2, Reqs 2.1, 2.2, 2.5-2.7, 11.1). +"""Witness kind, scope, resource and authority matching (tasks 10.2-10.3, Reqs 2.1, 2.2, 2.5-2.8, 11.1). A witness governs an obligation only when every required dimension is MUST. This module -matches the kind, scope and resource dimensions; authority matching (task 10.3) and -dominance (task 10.1) join through the same algebra. Definite mismatches are NO, unresolved -compatibility is UNKNOWN (never a broad match, never a definite rejection), per Req 2.7. +matches the kind, scope, resource and authority dimensions; dominance (task 10.1) joins +through the same algebra. Definite mismatches are NO, unresolved compatibility is UNKNOWN +(never a broad match, never a definite rejection), per Req 2.7. Authority implications come +only from promoted knowledge and their fact identifiers are preserved as evidence (Req 2.8). """ from .abstract_domain import ( + AuthorityImplication, + AuthorityRef, EvidenceRelation, ResourceRef, WitnessMatch, + authority_satisfies, conjoin_dimensions, parse_resource, + supporting_implications, ) from .facts import OperationFact from .operations import Discharge, Operation @@ -19,17 +24,25 @@ from .operations import Discharge, Operation _WHOLE_HANDLER_SCOPES = frozenset({"decorator", "router"}) -def match_witness(witness: Discharge, operation: Operation, fact: OperationFact | None = None) -> WitnessMatch: - """Match one witness against one operation on kind, scope and resource.""" +def match_witness( + witness: Discharge, + operation: Operation, + fact: OperationFact | None = None, + *, + required_authority: AuthorityRef | None = None, + implications: tuple[AuthorityImplication, ...] = (), +) -> WitnessMatch: + """Match one witness against one operation on kind, scope, resource and authority.""" if witness.kind not in operation.requires: - return _match(EvidenceRelation.NO, EvidenceRelation.NO, witness, operation) + return _match(EvidenceRelation.NO, EvidenceRelation.NO, EvidenceRelation.UNKNOWN, witness, operation, ()) if _out_of_scope(witness, operation): - return _match(EvidenceRelation.NO, EvidenceRelation.NO, witness, operation) + return _match(EvidenceRelation.NO, EvidenceRelation.NO, EvidenceRelation.UNKNOWN, witness, operation, ()) resource_relation = _resource_relation(witness, operation, fact) + authority_relation, implication_ids = _authority_relation(witness, required_authority, implications) relation = conjoin_dimensions( - (EvidenceRelation.MUST, resource_relation), + (EvidenceRelation.MUST, resource_relation, authority_relation), ) - return _match(relation, resource_relation, witness, operation) + return _match(relation, resource_relation, authority_relation, witness, operation, implication_ids) def _out_of_scope(witness: Discharge, operation: Operation) -> bool: @@ -75,20 +88,41 @@ def _alias_relation(witness_resource: ResourceRef, operation_resource: ResourceR return EvidenceRelation.UNKNOWN +def _authority_relation( + witness: Discharge, + required_authority: AuthorityRef | None, + implications: tuple[AuthorityImplication, ...], +) -> tuple[EvidenceRelation, tuple[str, ...]]: + """Decide the authority dimension (Req 2.5-2.8); preserve implication fact ids.""" + if required_authority is None: + return EvidenceRelation.MUST, () + held_authority = witness.authority + if held_authority is None or held_authority.is_unknown or required_authority.is_unknown: + return EvidenceRelation.UNKNOWN, () + relation = authority_satisfies(held_authority, required_authority, implications) + used_ids = tuple( + f"implication:{implication.id}" for implication in supporting_implications(held_authority, required_authority, implications) + ) + return relation, used_ids + + def _match( relation: EvidenceRelation, resource_relation: EvidenceRelation, + authority_relation: EvidenceRelation, witness: Discharge, operation: Operation, + implication_ids: tuple[str, ...], ) -> WitnessMatch: evidence = ( f"witness:{witness.fact_id}", f"operation:{operation.fact_id}", f"resource:{operation.resource or 'unknown'}", + *implication_ids, ) return WitnessMatch( relation=relation, resource_relation=resource_relation, - authority_relation=EvidenceRelation.UNKNOWN, # task 10.3 owns authority matching + authority_relation=authority_relation, evidence=evidence, ) diff --git a/tests/test_witness_matching.py b/tests/test_witness_matching.py index fdbeb78..7f0ff97 100644 --- a/tests/test_witness_matching.py +++ b/tests/test_witness_matching.py @@ -2,6 +2,7 @@ from openultrasast.semantic.obligations.abstract_domain import ( UNKNOWN_RESOURCE, + AuthorityRef, EvidenceRelation, ResourceRef, parse_resource, @@ -20,6 +21,7 @@ def _witness( scope: str = "statement", function: str = "handle", line: int | None = 3, + authority: AuthorityRef | None = None, ) -> Discharge: return Discharge( path=PATH, @@ -30,6 +32,7 @@ def _witness( provenance="call", scope=scope, resource=resource, + authority=authority, ) @@ -62,7 +65,7 @@ def test_same_resource_exact_match_is_must() -> None: match = match_witness(_witness(resource=parse_resource("book")), _operation(resource="book"), _fact()) assert match.resource_relation is EvidenceRelation.MUST assert match.relation is EvidenceRelation.MUST - assert match.authority_relation is EvidenceRelation.UNKNOWN + assert match.authority_relation is EvidenceRelation.MUST # no authority obligation: vacuous MUST (10.3) def test_definite_resource_mismatch_is_no() -> None: @@ -132,3 +135,117 @@ def test_matching_is_deterministic() -> None: witness = _witness(resource=parse_resource("book")) operation = _operation(resource="book") assert match_witness(witness, operation, _fact()) == match_witness(witness, operation, _fact()) + + +# --- Task 10.3: authority matching through promoted implications (Reqs 2.2, 2.5, 2.7, 2.8, 11.1) --- + + +def _authority_witness(authority: AuthorityRef | None) -> Discharge: + return _witness(resource=ResourceRef(kind="exact", values=("book",)), authority=authority) + + +def test_stronger_authority_via_promoted_implication_is_must_and_keeps_fact_id() -> None: + from openultrasast.semantic.obligations.abstract_domain import AuthorityImplication, AuthorityRef + + witness = _authority_witness(AuthorityRef(values=("admin",))) + required = AuthorityRef(values=("supervisor",)) + implications = (AuthorityImplication(id="php:admin-implies-supervisor", authority="admin", implies="supervisor"),) + match = match_witness( + witness, + _operation(), + required_authority=required, + implications=implications, + ) + assert match.authority_relation is EvidenceRelation.MUST + assert "implication:php:admin-implies-supervisor" in match.evidence + + +def test_identity_authority_is_must() -> None: + from openultrasast.semantic.obligations.abstract_domain import AuthorityRef + + match = match_witness( + _authority_witness(AuthorityRef(values=("admin",))), + _operation(), + required_authority=AuthorityRef(values=("admin",)), + ) + assert match.authority_relation is EvidenceRelation.MUST + + +def test_weaker_authority_is_no() -> None: + from openultrasast.semantic.obligations.abstract_domain import AuthorityRef + + match = match_witness( + _authority_witness(AuthorityRef(values=("user",))), + _operation(), + required_authority=AuthorityRef(values=("admin",)), + ) + assert match.authority_relation is EvidenceRelation.NO + + +def test_unrelated_authority_is_no() -> None: + from openultrasast.semantic.obligations.abstract_domain import AuthorityRef + + match = match_witness( + _authority_witness(AuthorityRef(values=("auditor",))), + _operation(), + required_authority=AuthorityRef(values=("admin",)), + ) + assert match.authority_relation is EvidenceRelation.NO + + +def test_unknown_witness_authority_is_unknown() -> None: + from openultrasast.semantic.obligations.abstract_domain import AuthorityRef + + match = match_witness( + _authority_witness(None), + _operation(), + required_authority=AuthorityRef(values=("admin",)), + ) + assert match.authority_relation is EvidenceRelation.UNKNOWN + + +def test_unknown_required_authority_is_unknown() -> None: + from openultrasast.semantic.obligations.abstract_domain import AuthorityRef + + match = match_witness( + _authority_witness(AuthorityRef(values=("admin",))), + _operation(), + required_authority=AuthorityRef(values=(), is_unknown=True), + ) + assert match.authority_relation is EvidenceRelation.UNKNOWN + + +def test_multi_value_requirement_is_may_never_must() -> None: + from openultrasast.semantic.obligations.abstract_domain import AuthorityRef + + match = match_witness( + _authority_witness(AuthorityRef(values=("admin",))), + _operation(), + required_authority=AuthorityRef(values=("admin", "guest")), + ) + assert match.authority_relation is EvidenceRelation.MAY + + +def test_no_authority_requirement_is_vacuous_must() -> None: + from openultrasast.semantic.obligations.abstract_domain import AuthorityRef + + match = match_witness( + _authority_witness(AuthorityRef(values=("admin",))), + _operation(), + required_authority=None, + ) + assert match.authority_relation is EvidenceRelation.MUST + + +def test_overall_relation_conjoins_authority() -> None: + from openultrasast.semantic.obligations.abstract_domain import AuthorityRef + + witness = _authority_witness(AuthorityRef(values=("user",))) + match = match_witness( + witness, + _operation(), + required_authority=AuthorityRef(values=("admin",)), + ) + assert match.resource_relation is EvidenceRelation.MUST + assert match.authority_relation is EvidenceRelation.NO + assert match.relation is EvidenceRelation.NO