mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-11-13 17:58:18 +00:00
feat(access-control-prover): 7.3 authority abstract values and promoted implication facts
This commit is contained in:
@@ -94,7 +94,7 @@ its final resolution, dominance, fact-acquisition and measurement semantics with
|
|||||||
- _Requirements: 2.1, 2.5, 2.6, 2.7, 11.1_
|
- _Requirements: 2.1, 2.5, 2.6, 2.7, 11.1_
|
||||||
- _Boundary: Resource Domain_
|
- _Boundary: Resource Domain_
|
||||||
- _Depends: 7.1_
|
- _Depends: 7.1_
|
||||||
- [ ] 7.3 Add authority abstract values and promoted implication facts
|
- [x] 7.3 Add authority abstract values and promoted implication facts
|
||||||
- Represent exact, finite and unknown authorities separately from resources.
|
- Represent exact, finite and unknown authorities separately from resources.
|
||||||
- Load authority implications from promoted facts and retain the supporting fact identifier.
|
- Load authority implications from promoted facts and retain the supporting fact identifier.
|
||||||
- Done when sufficient, insufficient and unknown authority fixtures produce the specified relations.
|
- Done when sufficient, insufficient and unknown authority fixtures produce the specified relations.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Evidence relations, the completeness value and resource abstract values (access-control-prover).
|
"""Evidence relations, the completeness value, resource and authority abstract values (access-control-prover).
|
||||||
|
|
||||||
Covers Req 2.1, 2.5, 2.6, 2.7, 11.1, 11.6, 11.7.
|
Covers Req 2.1, 2.2, 2.5, 2.6, 2.7, 2.8, 11.1, 11.6, 11.7.
|
||||||
|
|
||||||
``EvidenceRelation`` is the abstract evidence domain: definite presence (``MUST``), definite absence
|
``EvidenceRelation`` is the abstract evidence domain: definite presence (``MUST``), definite absence
|
||||||
(``NO``), possibility (``MAY``) and unknown (``UNKNOWN``). Three separate operations keep one table
|
(``NO``), possibility (``MAY``) and unknown (``UNKNOWN``). Three separate operations keep one table
|
||||||
@@ -9,16 +9,24 @@ targets or control paths, ``conjoin_dimensions`` combines one witness's kind, sc
|
|||||||
and dominance dimensions, and ``aggregate_witnesses`` asks whether any witness definitely governs one
|
and dominance dimensions, and ``aggregate_witnesses`` asks whether any witness definitely governs one
|
||||||
obligation. ``ResourceRef`` abstracts one resource binding into an exact token, a finite alias set,
|
obligation. ``ResourceRef`` abstracts one resource binding into an exact token, a finite alias set,
|
||||||
request-global scope or an unknown value; ``join_resources`` unions alias sets finitely, lets
|
request-global scope or an unknown value; ``join_resources`` unions alias sets finitely, lets
|
||||||
request-global scope absorb concrete bindings and never lets an unresolvable value resolve. Every
|
request-global scope absorb concrete bindings and never lets an unresolvable value resolve.
|
||||||
|
``AuthorityRef`` abstracts one authority holding into an exact name, a finite alternative set or an
|
||||||
|
unknown value; ``authority_satisfies`` asks whether a held authority satisfies a required one by
|
||||||
|
folding ``join_alternatives`` over every held-by-required name pair, where a pair is ``MUST`` when a
|
||||||
|
promoted ``AuthorityImplication`` chain connects them and ``NO`` otherwise, so sufficient authority is
|
||||||
|
``MUST``, insufficient is ``NO`` and undecidable is ``UNKNOWN`` (Req 2.5, 2.7, 2.8). ``supporting_implications``
|
||||||
|
returns the implications used with their supporting fact ids, retained for the trace (Req 2.8). Every
|
||||||
operation is deterministic, pure and independent of target names; join never converts
|
operation is deterministic, pure and independent of target names; join never converts
|
||||||
``MAY`` or ``UNKNOWN`` into ``MUST`` or ``NO``, so losing precision cannot manufacture a verdict.
|
``MAY`` or ``UNKNOWN`` into ``MUST`` or ``NO``, so losing precision cannot manufacture a verdict.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
from collections.abc import Iterable, Mapping, Sequence
|
from collections.abc import Iterable, Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from functools import reduce
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
@@ -108,6 +116,163 @@ def join_resources(left: ResourceRef, right: ResourceRef) -> ResourceRef:
|
|||||||
return ResourceRef(kind="aliases", values=left.values + right.values)
|
return ResourceRef(kind="aliases", values=left.values + right.values)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuthorityRef:
|
||||||
|
"""The abstract value of one authority holding (design, "Abstract evidence domain"; Req 2.1, 2.2, 2.5, 2.7, 11.1).
|
||||||
|
|
||||||
|
One value is an ``exact`` authority, several values are a ``finite`` alternative set (the authority is
|
||||||
|
one of them, not all of them) and ``is_unknown`` marks an unresolvable holding that can neither match
|
||||||
|
nor fail. Values are deduplicated and sorted, so equal holdings are equal however they were
|
||||||
|
constructed; a misshapen value fails loud rather than degrading silently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
values: tuple[str, ...]
|
||||||
|
is_unknown: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not all(isinstance(value, str) and value for value in self.values):
|
||||||
|
raise ValueError(f"misshapen authority values: {self.values!r}")
|
||||||
|
if self.is_unknown and self.values:
|
||||||
|
raise ValueError(f"unknown authority binds no values: {self.values!r}")
|
||||||
|
if not self.is_unknown and not self.values:
|
||||||
|
raise ValueError("authority needs at least one value")
|
||||||
|
object.__setattr__(self, "values", tuple(sorted(set(self.values))))
|
||||||
|
|
||||||
|
|
||||||
|
UNKNOWN_AUTHORITY = AuthorityRef(values=(), is_unknown=True)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_authority(binding: str | Sequence[str] | None) -> AuthorityRef:
|
||||||
|
"""Parse one authority holding from fact or IR evidence into its abstract value.
|
||||||
|
|
||||||
|
A single name is ``exact``, a finite name collection is a finite alternative set, and ``None``
|
||||||
|
(no holding could be resolved) is ``unknown``; anything misshapen raises instead of quietly degrading.
|
||||||
|
"""
|
||||||
|
if binding is None:
|
||||||
|
return UNKNOWN_AUTHORITY
|
||||||
|
names = (binding,) if isinstance(binding, str) else tuple(binding)
|
||||||
|
if not names or not all(isinstance(name, str) and name for name in names):
|
||||||
|
raise ValueError(f"misshapen authority binding: {binding!r}")
|
||||||
|
return AuthorityRef(values=names)
|
||||||
|
|
||||||
|
|
||||||
|
def join_authorities(left: AuthorityRef, right: AuthorityRef) -> AuthorityRef:
|
||||||
|
"""Join authority holdings from alternative paths or call targets.
|
||||||
|
|
||||||
|
Identical exact holdings stay exact and finite sets join by finite union, mirroring ``join_resources``;
|
||||||
|
an unresolvable holding joins to ``unknown`` so losing precision cannot manufacture a match
|
||||||
|
(Req 2.7, 11.1).
|
||||||
|
"""
|
||||||
|
if left.is_unknown or right.is_unknown:
|
||||||
|
return UNKNOWN_AUTHORITY
|
||||||
|
if left.values == right.values:
|
||||||
|
return left
|
||||||
|
return AuthorityRef(values=left.values + right.values)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuthorityImplication:
|
||||||
|
"""One promoted implication fact: ``authority`` also covers ``implies`` (Req 2.8).
|
||||||
|
|
||||||
|
``id`` is the supporting promoted fact row's identifier, retained so the trace can name the
|
||||||
|
implication used. The engine bakes in no implication of its own; only these facts connect authorities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
authority: str
|
||||||
|
implies: str
|
||||||
|
|
||||||
|
|
||||||
|
def _implication_index(
|
||||||
|
implications: Iterable[AuthorityImplication],
|
||||||
|
) -> dict[str, tuple[AuthorityImplication, ...]]:
|
||||||
|
"""Group implications by the authority they extend, preserving first-occurrence order."""
|
||||||
|
grouped: dict[str, list[AuthorityImplication]] = {}
|
||||||
|
for implication in implications:
|
||||||
|
grouped.setdefault(implication.authority, []).append(implication)
|
||||||
|
return {authority: tuple(edges) for authority, edges in grouped.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _implication_path(
|
||||||
|
source: str,
|
||||||
|
target: str,
|
||||||
|
index: Mapping[str, tuple[AuthorityImplication, ...]],
|
||||||
|
) -> tuple[AuthorityImplication, ...] | None:
|
||||||
|
"""The implications leading from ``source`` to ``target``, or ``None`` when no promoted chain reaches it.
|
||||||
|
|
||||||
|
Breadth-first over the finite edges in fact order, so the returned chain is deterministic and shortest
|
||||||
|
by edge count; ``()`` means the names are identical and no implication is needed.
|
||||||
|
"""
|
||||||
|
if source == target:
|
||||||
|
return ()
|
||||||
|
parents: dict[str, tuple[str, AuthorityImplication] | None] = {source: None}
|
||||||
|
frontier: deque[str] = deque([source])
|
||||||
|
while frontier:
|
||||||
|
current = frontier.popleft()
|
||||||
|
for implication in index.get(current, ()):
|
||||||
|
reached = implication.implies
|
||||||
|
if reached in parents:
|
||||||
|
continue
|
||||||
|
parents[reached] = (current, implication)
|
||||||
|
if reached == target:
|
||||||
|
chain: list[AuthorityImplication] = []
|
||||||
|
node: str = target
|
||||||
|
while True:
|
||||||
|
step = parents[node]
|
||||||
|
if step is None:
|
||||||
|
return tuple(reversed(chain))
|
||||||
|
previous, edge = step
|
||||||
|
chain.append(edge)
|
||||||
|
node = previous
|
||||||
|
frontier.append(reached)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def authority_satisfies(
|
||||||
|
held: AuthorityRef,
|
||||||
|
required: AuthorityRef,
|
||||||
|
implications: Iterable[AuthorityImplication] = (),
|
||||||
|
) -> EvidenceRelation:
|
||||||
|
"""Ask whether one held authority value satisfies one required authority value (Req 2.5, 2.7, 2.8, 11.1).
|
||||||
|
|
||||||
|
Each held-by-required name pair is definite: ``MUST`` when the names match or a promoted implication
|
||||||
|
chain connects them, ``NO`` otherwise. The aggregate folds ``join_alternatives`` over every pair, so a
|
||||||
|
finite holding with one satisfying alternative is ``MAY``; an unresolvable holding or requirement is
|
||||||
|
``UNKNOWN`` and can never match broadly or fail definitely.
|
||||||
|
"""
|
||||||
|
if held.is_unknown or required.is_unknown:
|
||||||
|
return EvidenceRelation.UNKNOWN
|
||||||
|
index = _implication_index(implications)
|
||||||
|
pairs = [
|
||||||
|
EvidenceRelation.MUST if _implication_path(held_name, required_name, index) is not None else EvidenceRelation.NO
|
||||||
|
for held_name in held.values
|
||||||
|
for required_name in required.values
|
||||||
|
]
|
||||||
|
return reduce(join_alternatives, pairs)
|
||||||
|
|
||||||
|
|
||||||
|
def supporting_implications(
|
||||||
|
held: AuthorityRef,
|
||||||
|
required: AuthorityRef,
|
||||||
|
implications: Iterable[AuthorityImplication] = (),
|
||||||
|
) -> tuple[AuthorityImplication, ...]:
|
||||||
|
"""The implications used on satisfying paths, with supporting fact ids retained for the trace (Req 2.8).
|
||||||
|
|
||||||
|
First-occurrence order over the normalized names, deduplicated; empty when nothing matched or the
|
||||||
|
relation could not be decided.
|
||||||
|
"""
|
||||||
|
if held.is_unknown or required.is_unknown:
|
||||||
|
return ()
|
||||||
|
index = _implication_index(implications)
|
||||||
|
used: list[AuthorityImplication] = []
|
||||||
|
for held_name in held.values:
|
||||||
|
for required_name in required.values:
|
||||||
|
chain = _implication_path(held_name, required_name, index)
|
||||||
|
if chain is not None:
|
||||||
|
used.extend(chain)
|
||||||
|
return tuple(dict.fromkeys(used))
|
||||||
|
|
||||||
|
|
||||||
_ALTERNATIVE_JOIN: Mapping[tuple[EvidenceRelation, EvidenceRelation], EvidenceRelation] = {
|
_ALTERNATIVE_JOIN: Mapping[tuple[EvidenceRelation, EvidenceRelation], EvidenceRelation] = {
|
||||||
(EvidenceRelation.MUST, EvidenceRelation.MUST): EvidenceRelation.MUST,
|
(EvidenceRelation.MUST, EvidenceRelation.MUST): EvidenceRelation.MUST,
|
||||||
(EvidenceRelation.MUST, EvidenceRelation.NO): EvidenceRelation.MAY,
|
(EvidenceRelation.MUST, EvidenceRelation.NO): EvidenceRelation.MAY,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from ...source import canonical_language
|
from ...source import canonical_language
|
||||||
from ..facts import _ruleset_state
|
from ..facts import _ruleset_state
|
||||||
|
from .abstract_domain import AuthorityImplication
|
||||||
|
|
||||||
OPERATION_KINDS = ("protected_read", "protected_write", "privileged_action", "security_setting")
|
OPERATION_KINDS = ("protected_read", "protected_write", "privileged_action", "security_setting")
|
||||||
DISCHARGER_KINDS = ("path_guard", "identity_constraint", "ownership_check", "non_permissive_value", "validated_input")
|
DISCHARGER_KINDS = ("path_guard", "identity_constraint", "ownership_check", "non_permissive_value", "validated_input")
|
||||||
@@ -38,6 +39,7 @@ _OPERATION_FIELDS = frozenset(
|
|||||||
_DISCHARGER_FIELDS = frozenset(
|
_DISCHARGER_FIELDS = frozenset(
|
||||||
{"id", "kind", "calls", "decorators", "identity_sources", "constraint_params", "request_sources", "permissive_values"}
|
{"id", "kind", "calls", "decorators", "identity_sources", "constraint_params", "request_sources", "permissive_values"}
|
||||||
)
|
)
|
||||||
|
_IMPLICATION_FIELDS = frozenset({"id", "authority", "implies"})
|
||||||
|
|
||||||
|
|
||||||
class ObligationFactsError(ValueError):
|
class ObligationFactsError(ValueError):
|
||||||
@@ -79,11 +81,25 @@ class DischargerFact:
|
|||||||
permissive_values: tuple[str, ...] = () # literal values that leave a security setting open (non_permissive_value facts)
|
permissive_values: tuple[str, ...] = () # literal values that leave a security setting open (non_permissive_value facts)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ImplicationFact(AuthorityImplication):
|
||||||
|
"""One promoted ``[[implication]]`` row: ``authority`` also covers ``implies`` (Req 2.8).
|
||||||
|
|
||||||
|
Extends the abstract domain's ``AuthorityImplication`` with the loader's language scope, so loaded
|
||||||
|
facts feed ``authority_satisfies`` directly while filtering per language like every other fact row.
|
||||||
|
"""
|
||||||
|
|
||||||
|
language: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ObligationFacts:
|
class ObligationFacts:
|
||||||
version: str
|
version: str
|
||||||
operations: tuple[OperationFact, ...]
|
operations: tuple[OperationFact, ...]
|
||||||
dischargers: tuple[DischargerFact, ...]
|
dischargers: tuple[DischargerFact, ...]
|
||||||
|
# Requirement 2.8 (access-control-prover): promoted authority implications, each retaining the
|
||||||
|
# supporting fact row id so the trace can name the implication used. Default-empty; older data loads unchanged.
|
||||||
|
implications: tuple[ImplicationFact, ...] = ()
|
||||||
|
|
||||||
def for_language(self, language: str) -> ObligationFacts:
|
def for_language(self, language: str) -> ObligationFacts:
|
||||||
key = canonical_language(language)
|
key = canonical_language(language)
|
||||||
@@ -93,6 +109,7 @@ class ObligationFacts:
|
|||||||
version=self.version,
|
version=self.version,
|
||||||
operations=tuple(item for item in self.operations if item.language == key),
|
operations=tuple(item for item in self.operations if item.language == key),
|
||||||
dischargers=tuple(item for item in self.dischargers if item.language == key),
|
dischargers=tuple(item for item in self.dischargers if item.language == key),
|
||||||
|
implications=tuple(item for item in self.implications if item.language == key),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +119,7 @@ def _load_obligation_facts_uncached(directory: Path | None = None) -> Obligation
|
|||||||
raise ObligationFactsError(f"obligation facts directory missing: {root}")
|
raise ObligationFactsError(f"obligation facts directory missing: {root}")
|
||||||
operations: list[OperationFact] = []
|
operations: list[OperationFact] = []
|
||||||
dischargers: list[DischargerFact] = []
|
dischargers: list[DischargerFact] = []
|
||||||
|
implications: list[ImplicationFact] = []
|
||||||
versions: list[str] = []
|
versions: list[str] = []
|
||||||
for path in sorted(root.glob("*.toml")):
|
for path in sorted(root.glob("*.toml")):
|
||||||
try:
|
try:
|
||||||
@@ -112,6 +130,8 @@ def _load_obligation_facts_uncached(directory: Path | None = None) -> Obligation
|
|||||||
versions.append(str(payload.get("version", "1")))
|
versions.append(str(payload.get("version", "1")))
|
||||||
for row in _rows(payload.get("operation"), path):
|
for row in _rows(payload.get("operation"), path):
|
||||||
operations.append(_operation_fact(row, path, language))
|
operations.append(_operation_fact(row, path, language))
|
||||||
|
for row in _rows(payload.get("implication"), path):
|
||||||
|
implications.append(_implication_fact(row, path, language))
|
||||||
for row in _rows(payload.get("discharger"), path):
|
for row in _rows(payload.get("discharger"), path):
|
||||||
_only_fields(row, _DISCHARGER_FIELDS, path)
|
_only_fields(row, _DISCHARGER_FIELDS, path)
|
||||||
kind = str(row.get("kind", ""))
|
kind = str(row.get("kind", ""))
|
||||||
@@ -130,7 +150,12 @@ def _load_obligation_facts_uncached(directory: Path | None = None) -> Obligation
|
|||||||
permissive_values=_strings(row.get("permissive_values"), "permissive_values", path),
|
permissive_values=_strings(row.get("permissive_values"), "permissive_values", path),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return ObligationFacts(version=versions[0] if versions else "1", operations=tuple(operations), dischargers=tuple(dischargers))
|
return ObligationFacts(
|
||||||
|
version=versions[0] if versions else "1",
|
||||||
|
operations=tuple(operations),
|
||||||
|
dischargers=tuple(dischargers),
|
||||||
|
implications=tuple(implications),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _operation_fact(row: dict[str, object], path: Path, language: str) -> OperationFact:
|
def _operation_fact(row: dict[str, object], path: Path, language: str) -> OperationFact:
|
||||||
@@ -166,6 +191,24 @@ def _operation_fact(row: dict[str, object], path: Path, language: str) -> Operat
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _implication_fact(row: dict[str, object], path: Path, language: str) -> ImplicationFact:
|
||||||
|
"""Validate one implication row against the closed fields; both names must be non-empty strings."""
|
||||||
|
_only_fields(row, _IMPLICATION_FIELDS, path)
|
||||||
|
return ImplicationFact(
|
||||||
|
id=str(row["id"]),
|
||||||
|
authority=_implication_name(row, "authority", path),
|
||||||
|
implies=_implication_name(row, "implies", path),
|
||||||
|
language=language,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _implication_name(row: dict[str, object], field: str, path: Path) -> str:
|
||||||
|
value = row.get(field)
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise ObligationFactsError(f"{path}: implication {row.get('id')!r} needs a non-empty {field}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _optional_int(row: dict[str, object], field: str, path: Path, *, positive: bool) -> int | None:
|
def _optional_int(row: dict[str, object], field: str, path: Path, *, positive: bool) -> int | None:
|
||||||
value = row.get(field)
|
value = row.get(field)
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
"""access-control-prover task 7.3: authority abstract values and promoted implication facts (offline).
|
||||||
|
|
||||||
|
Exact, finite and unknown authorities per the design's "Abstract evidence domain" section (Req 2.1, 2.2,
|
||||||
|
2.5, 2.7, 11.1), the promoted implication facts that carry one authority implying another with the
|
||||||
|
supporting fact id retained (Req 2.8), and the satisfy query stated in the EvidenceRelation algebra
|
||||||
|
from task 7.1: sufficient authority is MUST, insufficient is NO, undecidable is UNKNOWN, and a finite set
|
||||||
|
with one satisfying alternative is MAY.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import AuthorityImplication, AuthorityRef
|
||||||
|
|
||||||
|
|
||||||
|
def _authority_fixtures() -> tuple[AuthorityRef, ...]:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import UNKNOWN_AUTHORITY, parse_authority
|
||||||
|
|
||||||
|
return (
|
||||||
|
parse_authority("admin"),
|
||||||
|
parse_authority("staff"),
|
||||||
|
parse_authority("guest"),
|
||||||
|
parse_authority(["admin", "guest"]),
|
||||||
|
parse_authority(["staff", "owner"]),
|
||||||
|
UNKNOWN_AUTHORITY,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _implication_fixtures() -> tuple[AuthorityImplication, ...]:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import AuthorityImplication
|
||||||
|
|
||||||
|
# owner -> staff -> authenticated, plus admin -> owner: a chain promotion must be able to walk.
|
||||||
|
return (
|
||||||
|
AuthorityImplication(id="admin-covers-owner", authority="admin", implies="owner"),
|
||||||
|
AuthorityImplication(id="owner-covers-staff", authority="owner", implies="staff"),
|
||||||
|
AuthorityImplication(id="staff-covers-authenticated", authority="staff", implies="authenticated"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_IMPLICATION_TOML = """version = "1"
|
||||||
|
language = "python"
|
||||||
|
|
||||||
|
[[operation]]
|
||||||
|
id = "orm-read"
|
||||||
|
kind = "protected_read"
|
||||||
|
calls = ["filter_by"]
|
||||||
|
requires = ["identity_constraint"]
|
||||||
|
sensitivity = "high"
|
||||||
|
|
||||||
|
[[implication]]
|
||||||
|
id = "admin-covers-owner"
|
||||||
|
authority = "admin"
|
||||||
|
implies = "owner"
|
||||||
|
|
||||||
|
[[implication]]
|
||||||
|
id = "owner-covers-staff"
|
||||||
|
authority = "owner"
|
||||||
|
implies = "staff"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_authority_single_name_is_exact() -> None:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import AuthorityRef, parse_authority
|
||||||
|
|
||||||
|
authority = parse_authority("admin")
|
||||||
|
assert authority == AuthorityRef(values=("admin",))
|
||||||
|
assert authority.values == ("admin",)
|
||||||
|
assert authority.is_unknown is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_authority_name_collection_is_a_finite_set() -> None:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import AuthorityRef, parse_authority
|
||||||
|
|
||||||
|
authority = parse_authority(["owner", "admin", "owner"])
|
||||||
|
assert authority == AuthorityRef(values=("admin", "owner"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_authority_without_a_binding_is_unknown() -> None:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import UNKNOWN_AUTHORITY, parse_authority
|
||||||
|
|
||||||
|
authority = parse_authority(None)
|
||||||
|
assert authority == UNKNOWN_AUTHORITY
|
||||||
|
assert authority.is_unknown is True
|
||||||
|
assert authority.values == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_authority_grades_distinguish_exact_finite_and_unknown() -> None:
|
||||||
|
# Req 11.1: authority compatibility distinguishes definite, possible, absent and unknown evidence.
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import UNKNOWN_AUTHORITY, parse_authority
|
||||||
|
|
||||||
|
exact = parse_authority("admin")
|
||||||
|
finite = parse_authority(["admin", "owner"])
|
||||||
|
grades = {exact, finite, UNKNOWN_AUTHORITY}
|
||||||
|
assert len(grades) == 3
|
||||||
|
assert (len(exact.values), exact.is_unknown) == (1, False)
|
||||||
|
assert (len(finite.values), finite.is_unknown) == (2, False)
|
||||||
|
assert UNKNOWN_AUTHORITY.values == () and UNKNOWN_AUTHORITY.is_unknown
|
||||||
|
|
||||||
|
|
||||||
|
def test_authority_values_are_normalized_deterministically() -> None:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import AuthorityRef
|
||||||
|
|
||||||
|
forward = AuthorityRef(values=("owner", "admin", "owner"))
|
||||||
|
backward = AuthorityRef(values=("admin", "owner"))
|
||||||
|
assert forward == backward
|
||||||
|
assert hash(forward) == hash(backward)
|
||||||
|
assert len({forward, backward}) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_authority_ref_fails_loud_on_misshapen_values() -> None:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import AuthorityRef
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="at least one"):
|
||||||
|
AuthorityRef(values=())
|
||||||
|
with pytest.raises(ValueError, match="unknown authority"):
|
||||||
|
AuthorityRef(values=("admin",), is_unknown=True)
|
||||||
|
with pytest.raises(ValueError, match="misshapen"):
|
||||||
|
AuthorityRef(values=("admin", ""))
|
||||||
|
with pytest.raises(ValueError, match="misshapen"):
|
||||||
|
AuthorityRef(values=("",))
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_authority_rejects_misshapen_bindings() -> None:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import parse_authority
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
parse_authority("")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
parse_authority(())
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
parse_authority(["admin", ""])
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_authorities_keeps_identical_exact_authorities_exact() -> None:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import join_authorities, parse_authority
|
||||||
|
|
||||||
|
authority = parse_authority("admin")
|
||||||
|
assert join_authorities(authority, parse_authority("admin")) == authority
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_authorities_merges_different_authorities_into_a_finite_set() -> None:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import join_authorities, parse_authority
|
||||||
|
|
||||||
|
joined = join_authorities(parse_authority("admin"), parse_authority("owner"))
|
||||||
|
assert joined == parse_authority(["admin", "owner"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_authorities_unions_finite_sets_to_their_superset() -> None:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import join_authorities, parse_authority
|
||||||
|
|
||||||
|
subset = parse_authority(["admin"])
|
||||||
|
superset = parse_authority(["admin", "owner"])
|
||||||
|
assert join_authorities(subset, superset) == superset
|
||||||
|
assert join_authorities(superset, subset) == superset
|
||||||
|
assert join_authorities(parse_authority(["admin"]), parse_authority(["staff"])) == parse_authority(["admin", "staff"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_authorities_unknown_absorbs_every_fixture() -> None:
|
||||||
|
# Req 11.1: an unresolvable authority stays unresolvable; joining cannot manufacture precision.
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import UNKNOWN_AUTHORITY, join_authorities
|
||||||
|
|
||||||
|
for other in _authority_fixtures():
|
||||||
|
assert join_authorities(other, UNKNOWN_AUTHORITY) == UNKNOWN_AUTHORITY, other
|
||||||
|
assert join_authorities(UNKNOWN_AUTHORITY, other) == UNKNOWN_AUTHORITY, other
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_authorities_is_commutative_and_idempotent_over_fixtures() -> None:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import join_authorities
|
||||||
|
|
||||||
|
fixtures = _authority_fixtures()
|
||||||
|
for left, right in itertools.product(fixtures, repeat=2):
|
||||||
|
assert join_authorities(left, right) == join_authorities(right, left), (left, right)
|
||||||
|
for authority in fixtures:
|
||||||
|
assert join_authorities(authority, authority) == authority, authority
|
||||||
|
|
||||||
|
|
||||||
|
def test_sufficient_exact_authority_satisfies_the_required_authority() -> None:
|
||||||
|
# Req 2.5, 11.1: a witness holding exactly the required authority is a definite match.
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import EvidenceRelation, authority_satisfies, parse_authority
|
||||||
|
|
||||||
|
assert authority_satisfies(parse_authority("admin"), parse_authority("admin")) is EvidenceRelation.MUST
|
||||||
|
|
||||||
|
|
||||||
|
def test_sufficient_authority_via_promoted_implication_satisfies() -> None:
|
||||||
|
# Req 2.8: one authority implying another discharges the requirement, from promoted facts only.
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import EvidenceRelation, authority_satisfies, parse_authority
|
||||||
|
|
||||||
|
implications = _implication_fixtures()
|
||||||
|
assert authority_satisfies(parse_authority("staff"), parse_authority("authenticated"), implications) is EvidenceRelation.MUST
|
||||||
|
assert authority_satisfies(parse_authority("admin"), parse_authority("staff"), implications) is EvidenceRelation.MUST
|
||||||
|
|
||||||
|
|
||||||
|
def test_transitive_implication_chain_satisfies_across_every_hop() -> None:
|
||||||
|
# Req 2.8: admin -> owner -> staff -> authenticated must hold end to end.
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import EvidenceRelation, authority_satisfies, parse_authority
|
||||||
|
|
||||||
|
assert authority_satisfies(parse_authority("admin"), parse_authority("authenticated"), _implication_fixtures()) is EvidenceRelation.MUST
|
||||||
|
|
||||||
|
|
||||||
|
def test_cyclic_implications_terminate() -> None:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import (
|
||||||
|
AuthorityImplication,
|
||||||
|
EvidenceRelation,
|
||||||
|
authority_satisfies,
|
||||||
|
parse_authority,
|
||||||
|
)
|
||||||
|
|
||||||
|
ring = (
|
||||||
|
AuthorityImplication(id="billing-covers-audit", authority="billing", implies="audit"),
|
||||||
|
AuthorityImplication(id="audit-covers-billing", authority="audit", implies="billing"),
|
||||||
|
)
|
||||||
|
assert authority_satisfies(parse_authority("billing"), parse_authority("audit"), ring) is EvidenceRelation.MUST
|
||||||
|
assert authority_satisfies(parse_authority("billing"), parse_authority("admin"), ring) is EvidenceRelation.NO
|
||||||
|
|
||||||
|
|
||||||
|
def test_implication_applies_only_when_promoted() -> None:
|
||||||
|
# Req 2.8: the engine bakes in no implication vocabulary; without the promoted fact it is NO.
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import EvidenceRelation, authority_satisfies, parse_authority
|
||||||
|
|
||||||
|
assert authority_satisfies(parse_authority("staff"), parse_authority("authenticated"), ()) is EvidenceRelation.NO
|
||||||
|
|
||||||
|
|
||||||
|
def test_insufficient_authority_is_definitely_insufficient() -> None:
|
||||||
|
# Req 2.5: an insufficient authority must not discharge the operation; the relation is NO.
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import EvidenceRelation, authority_satisfies, parse_authority
|
||||||
|
|
||||||
|
assert authority_satisfies(parse_authority("guest"), parse_authority("admin"), _implication_fixtures()) is EvidenceRelation.NO
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_held_authority_cannot_be_decided() -> None:
|
||||||
|
# Req 2.7, 11.1: undecidable authority compatibility is UNKNOWN, never a broad match or a rejection.
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import (
|
||||||
|
UNKNOWN_AUTHORITY,
|
||||||
|
EvidenceRelation,
|
||||||
|
authority_satisfies,
|
||||||
|
parse_authority,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert authority_satisfies(UNKNOWN_AUTHORITY, parse_authority("admin"), _implication_fixtures()) is EvidenceRelation.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_required_authority_cannot_be_decided() -> None:
|
||||||
|
# Req 2.7: an unresolvable requirement is UNKNOWN, so the pair cannot become a final verdict.
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import (
|
||||||
|
UNKNOWN_AUTHORITY,
|
||||||
|
EvidenceRelation,
|
||||||
|
authority_satisfies,
|
||||||
|
parse_authority,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert authority_satisfies(parse_authority("admin"), UNKNOWN_AUTHORITY, _implication_fixtures()) is EvidenceRelation.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def test_finite_authority_with_one_satisfying_alternative_is_possible() -> None:
|
||||||
|
# Req 11.1: one satisfying and one failing alternative join to MAY, never to MUST or NO.
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import EvidenceRelation, authority_satisfies, parse_authority
|
||||||
|
|
||||||
|
relation = authority_satisfies(parse_authority(["admin", "guest"]), parse_authority("admin"), _implication_fixtures())
|
||||||
|
assert relation is EvidenceRelation.MAY
|
||||||
|
|
||||||
|
|
||||||
|
def test_finite_authority_with_all_alternatives_satisfying_is_definite() -> None:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import EvidenceRelation, authority_satisfies, parse_authority
|
||||||
|
|
||||||
|
relation = authority_satisfies(parse_authority(["admin", "owner"]), parse_authority("staff"), _implication_fixtures())
|
||||||
|
assert relation is EvidenceRelation.MUST
|
||||||
|
|
||||||
|
|
||||||
|
def test_widened_required_authority_stays_possible_not_definite() -> None:
|
||||||
|
# Req 2.7, 11.1: a requirement only imprecisely resolved to {authenticated, guest} is MAY, not MUST.
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import EvidenceRelation, authority_satisfies, parse_authority
|
||||||
|
|
||||||
|
relation = authority_satisfies(parse_authority("admin"), parse_authority(["authenticated", "guest"]), _implication_fixtures())
|
||||||
|
assert relation is EvidenceRelation.MAY
|
||||||
|
|
||||||
|
|
||||||
|
def test_authority_satisfies_folds_single_pairs_with_join_alternatives() -> None:
|
||||||
|
# The 7.1 law: the aggregate is exactly join_alternatives over every held x required singleton pair.
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import (
|
||||||
|
EvidenceRelation,
|
||||||
|
authority_satisfies,
|
||||||
|
join_alternatives,
|
||||||
|
parse_authority,
|
||||||
|
)
|
||||||
|
|
||||||
|
implications = _implication_fixtures()
|
||||||
|
for held, required in itertools.product(_authority_fixtures(), repeat=2):
|
||||||
|
aggregated = authority_satisfies(held, required, implications)
|
||||||
|
if held.is_unknown or required.is_unknown:
|
||||||
|
assert aggregated is EvidenceRelation.UNKNOWN, (held, required)
|
||||||
|
continue
|
||||||
|
folded: EvidenceRelation | None = None
|
||||||
|
for held_name in held.values:
|
||||||
|
for required_name in required.values:
|
||||||
|
single = authority_satisfies(parse_authority(held_name), parse_authority(required_name), implications)
|
||||||
|
folded = single if folded is None else join_alternatives(folded, single)
|
||||||
|
assert folded == aggregated, (held, required)
|
||||||
|
|
||||||
|
|
||||||
|
def test_widening_either_authority_never_gains_definiteness() -> None:
|
||||||
|
# Req 11.1, 11.6: coarsening a value moves the relation up the precision order or leaves it.
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import at_least_as_precise, authority_satisfies, join_authorities
|
||||||
|
|
||||||
|
implications = _implication_fixtures()
|
||||||
|
fixtures = _authority_fixtures()
|
||||||
|
for held, required, extra in itertools.product(fixtures, repeat=3):
|
||||||
|
base = authority_satisfies(held, required, implications)
|
||||||
|
wider_held = authority_satisfies(join_authorities(held, extra), required, implications)
|
||||||
|
assert at_least_as_precise(base, wider_held), (held, extra, required)
|
||||||
|
wider_required = authority_satisfies(held, join_authorities(required, extra), implications)
|
||||||
|
assert at_least_as_precise(base, wider_required), (held, required, extra)
|
||||||
|
|
||||||
|
|
||||||
|
def test_supporting_implications_retain_the_fact_ids_used() -> None:
|
||||||
|
# Req 2.8: the implications used on the satisfying path are retained with their supporting fact ids.
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import authority_satisfies, parse_authority, supporting_implications
|
||||||
|
|
||||||
|
implications = _implication_fixtures()
|
||||||
|
used = supporting_implications(parse_authority("admin"), parse_authority("authenticated"), implications)
|
||||||
|
assert [implication.id for implication in used] == [
|
||||||
|
"admin-covers-owner",
|
||||||
|
"owner-covers-staff",
|
||||||
|
"staff-covers-authenticated",
|
||||||
|
]
|
||||||
|
assert authority_satisfies(parse_authority("admin"), parse_authority("authenticated"), implications).value == "must"
|
||||||
|
|
||||||
|
|
||||||
|
def test_supporting_implications_are_empty_for_identity_and_for_failure() -> None:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import (
|
||||||
|
UNKNOWN_AUTHORITY,
|
||||||
|
parse_authority,
|
||||||
|
supporting_implications,
|
||||||
|
)
|
||||||
|
|
||||||
|
implications = _implication_fixtures()
|
||||||
|
assert supporting_implications(parse_authority("admin"), parse_authority("admin"), implications) == ()
|
||||||
|
assert supporting_implications(parse_authority("guest"), parse_authority("admin"), implications) == ()
|
||||||
|
assert supporting_implications(UNKNOWN_AUTHORITY, parse_authority("admin"), implications) == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_supporting_implications_deduplicate_stably() -> None:
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import parse_authority, supporting_implications
|
||||||
|
|
||||||
|
implications = _implication_fixtures()
|
||||||
|
assert supporting_implications(parse_authority("owner"), parse_authority("staff"), implications + implications) == (implications[1],)
|
||||||
|
|
||||||
|
|
||||||
|
def test_implication_facts_load_and_retain_supporting_ids(tmp_path: Path) -> None:
|
||||||
|
# Req 2.8: implications come from promoted fact rows, keep the row id for the trace, and stay
|
||||||
|
# AuthorityImplication values so the checker can feed them straight into the satisfy algebra.
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import AuthorityImplication
|
||||||
|
from openultrasast.semantic.obligations.facts import ImplicationFact, load_obligation_facts
|
||||||
|
|
||||||
|
(tmp_path / "python.toml").write_text(_IMPLICATION_TOML)
|
||||||
|
facts = load_obligation_facts(tmp_path)
|
||||||
|
assert facts.implications == (
|
||||||
|
ImplicationFact(id="admin-covers-owner", authority="admin", implies="owner", language="python"),
|
||||||
|
ImplicationFact(id="owner-covers-staff", authority="owner", implies="staff", language="python"),
|
||||||
|
)
|
||||||
|
assert all(isinstance(implication, AuthorityImplication) for implication in facts.implications)
|
||||||
|
|
||||||
|
|
||||||
|
def test_implication_facts_default_to_empty(tmp_path: Path) -> None:
|
||||||
|
from openultrasast.semantic.obligations.facts import load_obligation_facts
|
||||||
|
|
||||||
|
(tmp_path / "python.toml").write_text(
|
||||||
|
'version = "1"\nlanguage = "python"\n\n[[operation]]\nid = "orm-read"\nkind = "protected_read"\n'
|
||||||
|
'calls = ["filter_by"]\nrequires = ["identity_constraint"]\nsensitivity = "high"\n'
|
||||||
|
)
|
||||||
|
assert load_obligation_facts(tmp_path).implications == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_implication_facts_filter_by_language(tmp_path: Path) -> None:
|
||||||
|
from openultrasast.semantic.obligations.facts import load_obligation_facts
|
||||||
|
|
||||||
|
(tmp_path / "python.toml").write_text(_IMPLICATION_TOML)
|
||||||
|
(tmp_path / "ruby.toml").write_text(
|
||||||
|
'version = "1"\nlanguage = "ruby"\n\n[[implication]]\nid = "ruby-maintainer-covers-reporter"\n'
|
||||||
|
'authority = "maintainer"\nimplies = "reporter"\n'
|
||||||
|
)
|
||||||
|
all_languages = load_obligation_facts(tmp_path)
|
||||||
|
assert {implication.id for implication in all_languages.implications} == {
|
||||||
|
"admin-covers-owner",
|
||||||
|
"owner-covers-staff",
|
||||||
|
"ruby-maintainer-covers-reporter",
|
||||||
|
}
|
||||||
|
python = all_languages.for_language("python")
|
||||||
|
assert [implication.id for implication in python.implications] == ["admin-covers-owner", "owner-covers-staff"]
|
||||||
|
assert all_languages.for_language("ruby").implications != python.implications
|
||||||
|
|
||||||
|
|
||||||
|
def test_implication_fact_rejects_unknown_fields(tmp_path: Path) -> None:
|
||||||
|
from openultrasast.semantic.obligations.facts import ObligationFactsError, load_obligation_facts
|
||||||
|
|
||||||
|
(tmp_path / "python.toml").write_text(_IMPLICATION_TOML + "weight = 3\n")
|
||||||
|
with pytest.raises(ObligationFactsError, match="weight"):
|
||||||
|
load_obligation_facts(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_implication_fact_rejects_missing_or_misshapen_names(tmp_path: Path) -> None:
|
||||||
|
from openultrasast.semantic.obligations.facts import ObligationFactsError, load_obligation_facts
|
||||||
|
|
||||||
|
(tmp_path / "python.toml").write_text('version = "1"\nlanguage = "python"\n\n[[implication]]\nid = "broken"\nimplies = "owner"\n')
|
||||||
|
with pytest.raises(ObligationFactsError, match="authority"):
|
||||||
|
load_obligation_facts(tmp_path)
|
||||||
|
(tmp_path / "python.toml").write_text('version = "1"\nlanguage = "python"\n\n[[implication]]\nid = "broken"\nauthority = "admin"\n')
|
||||||
|
with pytest.raises(ObligationFactsError, match="implies"):
|
||||||
|
load_obligation_facts(tmp_path)
|
||||||
|
(tmp_path / "python.toml").write_text(
|
||||||
|
'version = "1"\nlanguage = "python"\n\n[[implication]]\nid = "broken"\nauthority = "admin"\nimplies = ""\n'
|
||||||
|
)
|
||||||
|
with pytest.raises(ObligationFactsError, match="implies"):
|
||||||
|
load_obligation_facts(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sufficient_insufficient_and_unknown_fixtures_produce_the_specified_relations() -> None:
|
||||||
|
# The task's three fixture grades: sufficient -> MUST, insufficient -> NO, unknown -> UNKNOWN.
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import (
|
||||||
|
UNKNOWN_AUTHORITY,
|
||||||
|
EvidenceRelation,
|
||||||
|
authority_satisfies,
|
||||||
|
parse_authority,
|
||||||
|
)
|
||||||
|
|
||||||
|
implications = _implication_fixtures()
|
||||||
|
sufficient = (
|
||||||
|
(parse_authority("admin"), parse_authority("admin")),
|
||||||
|
(parse_authority("staff"), parse_authority("authenticated")),
|
||||||
|
(parse_authority(["admin", "owner"]), parse_authority("staff")),
|
||||||
|
)
|
||||||
|
for held, required in sufficient:
|
||||||
|
assert authority_satisfies(held, required, implications) is EvidenceRelation.MUST, (held, required)
|
||||||
|
insufficient = (
|
||||||
|
(parse_authority("guest"), parse_authority("admin")),
|
||||||
|
(parse_authority("staff"), parse_authority("authenticated"), ()),
|
||||||
|
)
|
||||||
|
for fixture in insufficient:
|
||||||
|
held, required, *rest = fixture
|
||||||
|
assert authority_satisfies(held, required, *rest) is EvidenceRelation.NO, fixture
|
||||||
|
assert authority_satisfies(UNKNOWN_AUTHORITY, parse_authority("admin"), implications) is EvidenceRelation.UNKNOWN
|
||||||
|
assert authority_satisfies(parse_authority("admin"), UNKNOWN_AUTHORITY, implications) is EvidenceRelation.UNKNOWN
|
||||||
Reference in New Issue
Block a user