Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/ctrlrun/decision.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# SPDX-FileCopyrightText: 2026 The CTRLRun contributors
# SPDX-License-Identifier: Apache-2.0
"""The decision vocabulary, below everything that produces or records one.

**Why this module exists rather than these two names living in `policy.py`.** They did, and it
put a cycle in the module map: `state.py` imports `receipt.py`, `receipt.py` imported
`policy.py` for exactly these two names, `policy.py` reaches `authority.py` from inside two
functions, and `authority.py` imports `state.py`. A v0.7 review found it and
`docs/ARCHITECTURE.md` §6 has recorded it since 2026-09-12, as a named item before v1.0 rather
than something to change in a release pass.

Nothing was ever broken at run time, which is why it survived five milestones: the two edges out
of `policy.py` are function-level and run after every module is loaded, so `import ctrlrun`
resolves in one order and the suite passes. What it cost was §6's own rule, **dependencies point
downward only** -- with a cycle in place that sentence describes import order rather than the
module map, and the map is what tells a contributor what a module may know about.

**Why this edge and not one of the other three.** `receipt.py` is the module everything else
records through; the table in §6 lists it as used by *everything else*. An evidence type reaching
**up** into the decider is the edge that most contradicts the map, and what it reached up for was
pure vocabulary: a three-member `StrEnum` and a reason string, no behaviour either way. The other
candidate was moving `policy.py`'s two deferred imports, but `_canonical_authority` and
`hash_with_authority` genuinely need authority's canonicalization, and relocating them decides
who owns the policy hash, which is a real behavioural question rather than a placement one.

**No public name moves.** `policy.py` re-exports both, so `from ctrlrun.policy import Decision`
still resolves, `SPEC-v0.1.md` §8's frozen `__init__` block is literally unchanged, and
`from ctrlrun import Decision` is the same object it always was. The cycle was the only thing
that changed shape.

This module imports nothing from the package, and `test_the_module_graph_has_no_cycle` fails if
it ever does.
"""

from __future__ import annotations

from enum import StrEnum
from typing import Final


class Decision(StrEnum):
"""What may happen to an action: exactly three outcomes in v0.1 (SPEC-v0.1 §3.3).

`StrEnum`, so a member renders as its value in receipts and CLI output (SPEC-v0.1 §6.1).
"""

ALLOW = "allow"
APPROVE = "approve"
DENY = "deny"


#: SPEC-v0.8 §8.4 — the refusal a deployment gets under a policy nobody approved. Its own
#: reason, never folded into `unknown_action` or a generic denial: "this policy was never
#: approved" and "this policy denies this action" are different facts and an operator acts on
#: them differently.
#:
#: It sits beside `Decision` rather than in `policy.py` because `receipt.py` buckets it with the
#: other refusal reasons and was the second half of the import that made the cycle.
POLICY_UNAPPROVED: Final = "policy_unapproved"
29 changes: 5 additions & 24 deletions src/ctrlrun/jwt_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@
from .identity import IdentityContext
from .revocation import FEED_STALE, RevocationFeed

# `_NoRedirects` is re-exported, not merely used: it was defined here until v0.12, and both
# `tests/test_revocation_feed.py` and anything else reaching for `jwt_identity._NoRedirects`
# still resolve. It moved down to break the layering cycle §6 forbids; it did not change.
from .revocation import _NoRedirects as _NoRedirects

_LOG = logging.getLogger("ctrlrun")

#: The module this provider needs, and the extra that carries it.
Expand Down Expand Up @@ -486,30 +491,6 @@ def _fetch(self) -> Mapping[str, Any]:
return document


class _NoRedirects(urllib.request.HTTPRedirectHandler):
"""A redirect handler that redirects nowhere (SPEC-v0.3 §3.4).

`urllib.request.build_opener` does **not** drop `HTTPRedirectHandler` when it is handed an
`HTTPSHandler` — the default classes it removes are only the ones an argument is an
instance or subclass of, and the two are unrelated. An opener built that way still follows
a 302, and `HTTPRedirectHandler` permits `http`, `https` and `ftp` targets: an open
redirect on the issuer's domain would make this process fetch its signing keys, in
cleartext, from wherever the redirect pointed. Those keys are cached for the life of the
process, so every token the attacker then signs verifies, with an arbitrary `agent` and
`user`. That is the whole authority model, bypassed at the one input that decides who
everybody is.

Subclassing and refusing is the reliable way to say "no redirects": passing an instance of
a subclass *does* displace the default, which passing an unrelated handler does not.
"""

def redirect_request(
self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str
) -> None:
_LOG.warning("the JWK Set at %s redirected to %s; refusing to follow", req.full_url, newurl)
return None


class _Key:
"""One usable verification key, and the algorithm it constrains itself to, if any."""

Expand Down
25 changes: 7 additions & 18 deletions src/ctrlrun/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from datetime import date, datetime, time
from enum import StrEnum
from functools import cached_property, partial
from pathlib import Path
from types import MappingProxyType
Expand All @@ -30,6 +29,13 @@
import yaml

from .action import Action, PlainValue, canonical_bytes

# Re-exported, not merely used. `SPEC-v0.1.md` §8 freezes `from .policy import Decision, Policy`
# in `__init__.py`, and `adapter.py` imports `Decision` from here too. Both names moved down to
# break the module cycle §6 records, and both still resolve from this module because that block
# is a frozen public surface and a cycle is not a reason to move a published import path.
from .decision import POLICY_UNAPPROVED as POLICY_UNAPPROVED
from .decision import Decision as Decision
from .effect import template_placeholders
from .errors import InvalidArgument, PolicyError

Expand Down Expand Up @@ -185,12 +191,6 @@
#: rules were mutually exclusive.
POLICY_CHANGE_ACTION: Final = "ctrlrun.policy.change"

#: SPEC-v0.8 §8.4 — the refusal a deployment gets under a policy nobody approved. Its own
#: reason, never folded into `unknown_action` or a generic denial: "this policy was never
#: approved" and "this policy denies this action" are different facts and an operator acts on
#: them differently.
POLICY_UNAPPROVED: Final = "policy_unapproved"

#: SPEC-v0.10 §4.5 — the two upstream refusals, separately observable because "the server
#: changed" and "nobody has checked" are different findings an operator fixes differently.
#: `UPSTREAM_UNVERIFIED` is the fail-closed half and the one to get right: a pin that does
Expand Down Expand Up @@ -334,17 +334,6 @@
}


class Decision(StrEnum):
"""What may happen to an action: exactly three outcomes in v0.1 (SPEC-v0.1 §3.3).

`StrEnum`, so a member renders as its value in receipts and CLI output (SPEC-v0.1 §6.1).
"""

ALLOW = "allow"
APPROVE = "approve"
DENY = "deny"


@dataclass(frozen=True)
class Evaluation:
"""A decision and the reason it was reached, e.g. `rule[1]` or `unknown_action`."""
Expand Down
6 changes: 5 additions & 1 deletion src/ctrlrun/receipt.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@
APPROVER_UNENTITLED,
VerifiedApprover,
)

# `decision.py` imports nothing from the package, so this is downward and the cycle
# `state -> receipt -> policy -> authority -> state` that ARCHITECTURE §6 recorded is gone.
# These two names were the whole of what a receipt needed from the decider.
from .decision import POLICY_UNAPPROVED, Decision
from .errors import CTRLRunError, InvalidArgument
from .policy import POLICY_UNAPPROVED, Decision

#: SPEC-v0.3 §12.2. The bump landed with build-list item 1, because that is when the first v2
#: field appeared — the principal's claims, issuer and expiry. `execution` and `would_have`
Expand Down
48 changes: 43 additions & 5 deletions src/ctrlrun/revocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,45 @@
_LOG = logging.getLogger("ctrlrun.revocation")


#: `_NoRedirects` logs here and not to this module's `_LOG`. It moved down from `jwt_identity.py`
#: to break the layering cycle `jwt_identity <-> revocation`, and it warned on the `ctrlrun`
#: logger from both callers before the move, because `revocation.py` was importing the class from
#: there. Keeping that name keeps every existing handler and filter pointed at the same place: a
#: refactor that silently re-routes a security warning is a refactor that loses it.
_REDIRECT_LOG = logging.getLogger("ctrlrun")


class _NoRedirects(urllib.request.HTTPRedirectHandler):
"""A redirect handler that redirects nowhere (SPEC-v0.3 §3.4).

**Defined here, below both callers, and used by `jwt_identity.py` too.** It lived in
`jwt_identity.py` and `revocation.py` imported it from inside `_opener` -- deliberately, to
avoid a second copy, and that deferred import was a layering cycle `ARCHITECTURE.md` §6
forbids. One copy was always right; the direction was wrong. `jwt_identity.py` already
imports this module at module level, so defining it here needs no new module and no new edge.

`urllib.request.build_opener` does **not** drop `HTTPRedirectHandler` when it is handed an
`HTTPSHandler` — the default classes it removes are only the ones an argument is an
instance or subclass of, and the two are unrelated. An opener built that way still follows
a 302, and `HTTPRedirectHandler` permits `http`, `https` and `ftp` targets: an open
redirect on the issuer's domain would make this process fetch its signing keys, in
cleartext, from wherever the redirect pointed. Those keys are cached for the life of the
process, so every token the attacker then signs verifies, with an arbitrary `agent` and
`user`. That is the whole authority model, bypassed at the one input that decides who
everybody is. The revocation feed is the same argument one step along: a redirect there
decides which revocations this process never hears about.

Subclassing and refusing is the reliable way to say "no redirects": passing an instance of
a subclass *does* displace the default, which passing an unrelated handler does not.
"""

def redirect_request(
self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str
) -> None:
_REDIRECT_LOG.warning("%s redirected to %s; refusing to follow", req.full_url, newurl)
return None


def _utc_now() -> datetime:
return datetime.now(UTC)

Expand Down Expand Up @@ -348,11 +387,10 @@ def refresh(self) -> None:
self._read_at = now

def _opener(self) -> Any:
"""HTTPS, and follows nothing. `jwt_identity._NoRedirects`, reused deliberately: two
copies of this handler would be two things to keep correct at the one input that
decides who everybody is."""
from .jwt_identity import _NoRedirects

"""HTTPS, and follows nothing. `_NoRedirects` is defined in this module: two copies of
this handler would be two things to keep correct at the one input that decides who
everybody is, and importing it from `jwt_identity.py` was the layering cycle §6
forbids."""
return urllib.request.build_opener(
urllib.request.HTTPSHandler(context=ssl.create_default_context()), _NoRedirects()
)
Expand Down
Loading
Loading