diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b93a9a..ff896e25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,23 @@ any change to one appears here. The task reaches the authority decision and the receipt, and **never the action hash**: a field on `Action` would move every hash in existence and invalidate every stored approval. +- **Consequence budgets, in the document** (SPEC-v0.9 §2). A grant may carry `budgets:`, each a + `metric`, a `limit` and a `window`. They load, validate, render into the policy hash, and + attenuate down a delegation chain. **Nothing counts yet**: the ledger and the spending are + separate items, so this release note describes a contract and not an enforcement. + + **The window axis reads backwards, and it is worth stating plainly.** Over the same limit a + *shorter* window is a *higher rate*: a child of 100,000 per hour under a parent of 100,000 per + day is 24 times the parent's authority, and is rejected. A child of 100,000 per week is one + seventh the rate, and is accepted. Containment is existential: for every parent budget there + must exist a child budget on the same metric with `limit <=` and `window >=`, so one child + budget may discharge several of its parent's. + + A metric names an action argument, or `count`. Its value must be a **non-negative integer that + is not a `bool`**, so money is budgeted in minor units, as `examples/authority/payments.yaml` + already does for every constraint. The kernel does not know what any metric means: there is no + branch on a metric name anywhere. + - **Scope providers** (SPEC-v0.9 §5). `Control.execute(scope=...)` and `@protect(scope=...)` take a callable that answers what the calling principal's assigned scope is; **the kernel matches** this action's resource into it, with the relation a grant's `resources:` already uses. It runs diff --git a/docs/SPEC-v0.9.md b/docs/SPEC-v0.9.md index a27c7915..c3124335 100644 --- a/docs/SPEC-v0.9.md +++ b/docs/SPEC-v0.9.md @@ -182,6 +182,19 @@ checked. `count` is always available and always means one per action. It is the budget an operator reaches for first and the only one whose meaning does not depend on the document. +**Which makes it the one metric the kernel does branch on, and §12's do-not-build line has to say +so rather than be contradicted by it.** The line forbids a branch that *ranks* or *classifies* +metrics: a taxonomy, a default limit for a name the kernel thinks it recognises, an opinion about +which of two is more serious. `count` is none of those. It is a second **source** for the number, +the way a metric naming an argument is a source, and item 5 resolves it in one place with no +ordering over names. + +**An action argument literally named `count` does not win**, and it is worth saying which does: the +kernel-supplied meaning takes it, because an operator writing `metric: count` means "how many", and +a document that could silently retarget it at an argument would make the one metric whose meaning +does not depend on the document depend on it. An operator who wants to sum an argument called +`count` renames the argument. + Every other metric names an **action argument**, by name, and its value is summed. `metric: amount` sums `action.arguments["amount"]`. @@ -377,8 +390,21 @@ hash does not move, and every approval bound to that hash by `v0.6 §7.1` stays document that now permits a thousand times more. `SPEC-v0.8.md` §5.2 gives this reason for `max_ttl` and it is the same defect in the same field list. -Rendered in sorted order, like `constraints`, so two documents differing only in list order hash -alike. +**Rendered in document order, not sorted, and this amends an earlier draft of this sentence.** The +draft said sorted "like `constraints`, so two documents differing only in list order hash alike", +and `constraints` really does behave that way. Budgets do not, for two reasons. §2.2 gives the list +order meaning, because the first budget to refuse is the one named in the refusal, so two documents +differing in list order differ in what an operator is told. And the hash's job is to move when the +document changes: sorting makes two different documents hash alike, which is the direction that +loses evidence rather than the direction that creates it. + +**The window renders as integer seconds, not as the document's ISO-8601 spelling**, which amends +§10's `Budget` row as originally written. That row said the document's own spelling was the +canonical one; it is not implementable, because `canonical_grants` renders parsed grants and +`Budget` keeps a `timedelta` rather than the source text. Seconds is also what +`_canonical_envelope` already renders `max_ttl` as. The consequence is stated rather than hidden: +`PT24H` and `P1D` hash alike, which is correct, since they are the same window and a hash that +distinguished them would move for a document that changed nothing. --- @@ -1472,9 +1498,12 @@ defects will be: and the test is written with the parent at `PT24H` and the child at `PT1H` so the 24x figure is on the page next to the assertion. - T402 a child window **longer** than its parent's is accepted, same limit. -- T401a a budget on a grant none of whose actions carries an `effect:` template is a **load error** - naming the grant, the budget and the actions (§2.4.1). The case is - `examples/authority/payments.yaml`'s `reconciliation` grant, whose actions are reads. +- T401a **belongs to the item that owns §2.4.1's check, and that is not item 3.** §2.4.1 was a load + error in a draft and is not one now: the probes in that section show a loader cannot see a + decorator-supplied `effect=` and cannot run at all on the standalone-authority path. The check is + at execute time, after the effect key resolves and before `_secure`, so it is **item 5's**, beside + the charge it protects. Listed here rather than deleted, because a test id that vanished would + look like an oversight. - T402a the matching rule of §2.6.1, over the two-budget parent §2.2 exists for: each of the four rows of that section's worked table is a case. - T403 a child omitting a budget its parent carries is rejected. @@ -1572,8 +1601,8 @@ One justification per row. Anything not here is a spec amendment before it is co | Addition | Why an existing name does not serve | |---|---| -| `Budget` (`metric: str`, `limit: int`, `window: timedelta`) | nothing in `authority.py` carries a quantity over a period. **`window` is a `timedelta` in Python and an ISO-8601 duration in the document and in `_canonical_grant`**, because a `timedelta` is not a `PlainValue` (`action.py:19`) and so cannot render through `canonical_bytes`; the document's own spelling is the canonical one, which is what §2.8's hash covers | -| `Grant.budgets` | `constraints` decides one action and cannot count | +| `Budget` (`metric: str`, `limit: int`, `window: timedelta`) | nothing in `authority.py` carries a quantity over a period. `window` is a `timedelta` in Python, an ISO-8601 duration in the document, and **integer seconds in `_canonical_grant`**, because a `timedelta` is not a `PlainValue` (`action.py:19`) and cannot render through `canonical_bytes`. An earlier draft of this row said the document's spelling was canonical; it is not implementable, since `canonical_grants` renders parsed grants and a `Budget` keeps no source text. §2.8 carries the consequence | +| `Grant.budgets` | `constraints` decides one action and cannot count. A `Budget`'s window is a **whole number of seconds**, bounded, because it is stored and hashed as integer seconds: a sub-second window would round to `0` on the way into a delegation row and read back unreadable, dead for ever | | `Grant.tasks` | no dimension names a unit of work | | `DIMENSIONS` grows from six entries to eight | **an exported public name whose value changes** (`authority.py:126`, `__all__` at `authority.py:1787`). `verify/scenarios.py` iterates it for G9 and prints `len(DIMENSIONS)`, so this is not a private constant. §9.6 has the test | | `Charge` | the store needs a value object for what a reservation spends, carrying the whole predicate (§3.3.1) | @@ -1632,7 +1661,7 @@ No new module. Budgets and tasks are `authority.py`; the ledger is `state.py`, ` | Situation | Outcome | |---|---| | A budget's metric names an argument the action does not carry | **refused** (§2.3). Never zero | -| A budget sits on a grant none of whose actions carries an `effect:` template | **load error** (§2.4.1), naming the grant, the budget and the actions | +| An action whose effect key resolved to `None`, under a grant that budgets | **refused at execute** (§2.4.1), after the key resolves and before the reservation. **Not a load error**: §2.4.1's probes show a loader cannot see a decorator-supplied `effect=`, and cannot run at all on the standalone-authority path | | A budget limit is a float, a `Decimal`, a decimal string, negative, or not a number | **load error**, at the loader and at the constructor (§2.2, §2.3) | | A child grant's budget exceeds its parent's limit, **or shortens its window** | **rejected at delegation** (§2.6): a shorter window over the same limit is a higher rate | | A parent budget is matched by no child budget on that metric | **rejected at delegation** (§2.6.1) | diff --git a/examples/authority/payments.yaml b/examples/authority/payments.yaml index 0b253c07..73c44ef9 100644 --- a/examples/authority/payments.yaml +++ b/examples/authority/payments.yaml @@ -40,6 +40,14 @@ authority: # unchanged; naming one here binds this authority, and every delegation beneath it, to # the refund runs it was granted for. `ctrlrun verify` grades G24 against this grant. tasks: ["refund-run:*"] + # SPEC-v0.9 §2. How much, over which metric, in how long. `amount` names an action + # argument and is summed; the limit is in minor units like every band above, so this is + # €1,000.00 of refunds in any rolling 24 hours, across this grant and every delegation + # beneath it (§2.7). A grant that omits `budgets:` budgets nothing, which is what the two + # grants below do and why they are unchanged. Nothing sums a metric yet: the ledger and + # the spending are later items, and `ctrlrun verify` grades G22 against this once they land. + budgets: + - { metric: amount, limit: 100000, window: PT24H } # €1,000.00 a day # 2. A service account that reconciles, and may never move money. It holds no `delegable` # key at all, which is the default and means it cannot create a delegation. diff --git a/src/ctrlrun/authority.py b/src/ctrlrun/authority.py index 2fe4f73e..d37ea55f 100644 --- a/src/ctrlrun/authority.py +++ b/src/ctrlrun/authority.py @@ -32,7 +32,7 @@ from dataclasses import dataclass, field, replace from datetime import datetime, timedelta from types import MappingProxyType -from typing import Any, Final, Literal +from typing import Any, Final, Literal, cast from .action import Action, PlainValue, Principal from .errors import AuthorityEscalation, IdentityError, InvalidArgument, PolicyError @@ -153,6 +153,7 @@ # `_narrowed` carrying the field raises `VerifyInternalError`; adding the containment row # without growing it reports "6 of 6" and exercises neither, which is the silent failure. "tasks", + "budgets", ) #: §5.4 — how a child operand must compare with its parent's, per operator. `neq` is equality @@ -189,6 +190,9 @@ # the action on every task, so `policy.py` refuses it in a `v6` document rather than # ignoring it (§10.1). "tasks", + # SPEC-v0.9 §2.2, the other `v7` key, gated for the same reason: an older reader would + # ignore the limit and spend without bound. + "budgets", } ) _SUBJECT_KEYS: Final = frozenset({"agent", "user"}) @@ -348,6 +352,67 @@ def matches(self, principal: Principal) -> bool: return True +def _is_int(value: object) -> bool: + """A real integer: `bool` is an `int` in Python and is not one here (SPEC-v0.9 §2.3). + + `verify/scenarios.py` and `_parse_max_delegation_depth` already guard this trap; the budget + loader uses the same predicate rather than a third spelling of it. `amount: false` would + otherwise be a "non-negative integer" whose value is zero, which is §2.3's own sentence about + absence-as-zero wearing a bool's clothes. + """ + return isinstance(value, int) and not isinstance(value, bool) + + +@dataclass(frozen=True) +class Budget: + """How much, over what, in how long (SPEC-v0.9 §2.2). + + A metric names where the number comes from, a limit is what the sum may reach, and a window + is what the sum is taken over. The kernel **does not know what any metric means**: there is no + branch on a metric name anywhere, no ranking of two metrics, and no default limit for one the + kernel thinks it recognises (§2.3, and §12 carries it as a do-not-build line). + + Validated here as well as in the loader, on `Grant.__post_init__`'s rule: `Control.delegate` + takes a `Grant` built in Python, and §2.6's containment relation is undefined on a budget + whose window is negative or whose limit is a string. + """ + + metric: str + limit: int + window: timedelta + + def __post_init__(self) -> None: + if not isinstance(self.metric, str) or not self.metric.strip(): + raise InvalidArgument( + f"a budget metric must be a non-empty string, got {self.metric!r}" + ) + if not _is_int(self.limit) or self.limit < 0: + # §2.3: a float would drift, a bool is an int wearing a costume, and a string that + # looks like a number is a decimal in disguise. None of the three is coerced. + raise InvalidArgument( + f"budget {self.metric!r}: 'limit' must be a non-negative integer, got " + f"{self.limit!r}. Money is budgeted in minor units (SPEC-v0.9 §2.3)" + ) + if not isinstance(self.window, timedelta) or self.window <= timedelta(0): + raise InvalidArgument( + f"budget {self.metric!r}: 'window' must be a positive duration, got {self.window!r}" + ) + # **Whole seconds, because a `Budget` that cannot round-trip is not a legal one.** + # `grant_to_json` renders integer seconds, so `timedelta(milliseconds=500)` would store as + # `0` and read back as an unreadable delegation, dead for ever. `Control.delegate` takes a + # `Grant` built in Python, which is §2.2's whole reason for validating here as well as in + # the loader, and the loader's grammar is integer-only anyway. + if self.window.microseconds: + raise InvalidArgument( + f"budget {self.metric!r}: 'window' must be a whole number of seconds, got " + f"{self.window!r}; it is stored and hashed as integer seconds (SPEC-v0.9 §2.8)" + ) + if self.window.total_seconds() > _MAX_WINDOW_SECONDS: + raise InvalidArgument( + f"budget {self.metric!r}: 'window' may not exceed {_MAX_WINDOW_SECONDS} seconds" + ) + + @dataclass(frozen=True) class Grant: """One permission: this subject may propose these actions, under these limits (§4.2). @@ -371,6 +436,11 @@ class Grant: #: is `v0.3 §4.2`'s rule for `resources` and the reason every existing grant upgrades #: untouched. A child that omits it under a parent that names it is rejected (§6.2). tasks: tuple[str, ...] | None = None + #: SPEC-v0.9 §2.2 — how much this grant may spend, over which metric, in how long. A list and + #: not a mapping: two budgets on one metric over two windows is the first thing an operator + #: asks for, and a mapping keyed by metric cannot express it. `None` budgets nothing, which is + #: every grant written before v0.9 and why they all upgrade untouched. + budgets: tuple[Budget, ...] | None = None def __post_init__(self) -> None: # An empty id is legal only on the `Control.delegate` path and only until the call @@ -415,6 +485,19 @@ def __post_init__(self) -> None: ) for pattern in self.tasks: validate_pattern(pattern, separator=TASK_SEPARATOR, where=f"grant {self.id!r} task") + if self.budgets is not None: + object.__setattr__(self, "budgets", tuple(self.budgets)) + if not self.budgets: + raise InvalidArgument( + f"grant {self.id!r}: 'budgets' must be a non-empty list, or absent — " + "an absent 'budgets' is what budgets nothing (SPEC-v0.9 §2.2)" + ) + for budget in self.budgets: + if not isinstance(budget, Budget): + raise InvalidArgument( + f"grant {self.id!r}: a budget must be an authority.Budget, " + f"got {_type_name(budget)}" + ) for key, condition in self.constraints.items(): if not isinstance(condition, Condition): raise InvalidArgument( @@ -601,6 +684,23 @@ def grant_to_json(grant: Grant) -> str: # §5.6's re-check would then refuse it `authority_escalation` on `tasks` forever. The # round trip is the containment, not a convenience. "tasks": None if grant.tasks is None else list(grant.tasks), + # SPEC-v0.9 §2.7, and item 1's lesson repeated: a delegation is stored as this JSON and + # read back on **every** evaluation (`v0.3 §5.6`), so a dimension missing here reads back + # as `None`. The child would be unbudgeted while its parent carried a limit, and §5.6's + # re-check would refuse it `authority_escalation` on `budgets` forever. The round trip is + # the containment, not a convenience. + "budgets": ( + None + if grant.budgets is None + else [ + { + "metric": budget.metric, + "limit": budget.limit, + "window": int(budget.window.total_seconds()), + } + for budget in grant.budgets + ] + ), } return json.dumps(document, sort_keys=True, separators=(",", ":"), ensure_ascii=False) @@ -645,11 +745,73 @@ def grant_from_json(text: str, *, delegation_id: str) -> Grant: expires_at=None if expires_at is None else datetime.fromisoformat(str(expires_at)), delegable=bool(document.get("delegable", False)), tasks=_optional_tuple(document.get("tasks"), delegation_id), + budgets=_budgets_from_json(document.get("budgets"), delegation_id), ) - except (InvalidArgument, PolicyError, TypeError, ValueError) as exc: + except (ArithmeticError, InvalidArgument, PolicyError, TypeError, ValueError) as exc: raise _UnreadableError(delegation_id, str(exc)) from exc +#: SPEC-v0.9 §2.2 — the widest window a budget may carry, in seconds: a hundred years, which is +#: past any rolling window an operator means and well inside what `timedelta` can hold. It exists +#: so a stored row cannot raise `OverflowError` out of `Authority.evaluate`, which `_candidates` +#: would turn into a deployment-wide outage rather than one unreadable delegation. +_MAX_WINDOW_SECONDS: Final = 100 * 365 * 24 * 60 * 60 + + +def _budgets_from_json(value: object, delegation_id: str) -> tuple[Budget, ...] | None: + """Read a delegation's budgets back, validating exactly what the loader validated. + + `grant_from_json`'s rule for every other dimension: a row read back is re-validated, because + a store is a place an attacker with write access reaches and `v0.3 §5.2` requires reading a + grant back to check what loading one from YAML checks. + """ + if value is None: + return None + if not isinstance(value, list) or not value: + raise _UnreadableError(delegation_id, "grant_json 'budgets' is not a non-empty list") + parsed: list[Budget] = [] + for entry in value: + if not isinstance(entry, Mapping): + raise _UnreadableError(delegation_id, "grant_json has a budget that is not an object") + # The loader's key set is closed (`_BUDGET_KEYS`) and this docstring claims to validate + # exactly what the loader validates, so it is closed here too. An independent review found + # the two disagreeing: a stored row could carry a key the document could not. + unknown = set(entry) - _BUDGET_KEYS + if unknown: + raise _UnreadableError( + delegation_id, f"grant_json budget has unknown keys {sorted(unknown)}" + ) + window = entry.get("window") + # **The bool trap, one field over from where `limit` closes it**, and an independent + # review found it: `timedelta(seconds=True)` is a ONE-SECOND window, and a shorter window + # is a higher rate, so the failure grants authority. `0.5` is a sub-second window the + # loader's integer-only grammar cannot express. Same predicate as `limit`, not a second + # spelling of it. The bound is what keeps `timedelta` from raising `OverflowError` out of + # `Authority.evaluate`, which `_candidates` makes a deployment-wide outage: it reads every + # delegation row on every evaluation, so one corrupt row would deny nothing and crash + # everything, for every principal and every action. + if not _is_int(window) or not 0 < cast("int", window) <= _MAX_WINDOW_SECONDS: + raise _UnreadableError( + delegation_id, + f"grant_json budget 'window' must be a positive whole number of seconds up to " + f"{_MAX_WINDOW_SECONDS}, got {window!r}", + ) + try: + # `cast` and not a check: `Budget.__post_init__` validates all three, and a second + # copy of that grammar here is the drift §2.2 forbids. The types are the store's + # word, which is exactly what re-validating exists to distrust. + parsed.append( + Budget( + metric=cast("str", entry.get("metric")), + limit=cast("int", entry.get("limit")), + window=timedelta(seconds=cast("int", window)), + ) + ) + except (ArithmeticError, InvalidArgument, TypeError, ValueError) as exc: + raise _UnreadableError(delegation_id, f"grant_json budget: {exc}") from exc + return tuple(parsed) + + def _optional_tuple(value: object, delegation_id: str) -> tuple[str, ...] | None: """A list of strings, or `None`. Takes the row's id because it raises past `grant_from_json`'s `except` clause, and evidence that cannot name the corrupted row is @@ -723,9 +885,49 @@ def contained_dimension(parent: Grant, child: Grant) -> str | None: or not _patterns_contained(parent.tasks, child.tasks, separator=TASK_SEPARATOR) ): return "tasks" + if not _budgets_contained(parent.budgets, child.budgets): + return "budgets" return None +def _budgets_contained(parent: tuple[Budget, ...] | None, child: tuple[Budget, ...] | None) -> bool: + """§2.6.1: for every parent budget there must exist a child budget that discharges it. + + **The window axis reads backwards on first encounter, and the backwards reading is the + dangerous one**, so it is spelled out rather than left to the comparison. Over the same limit + a *shorter* window is a *higher rate*, and therefore more authority: a parent of 100,000 per + rolling day is widened, not narrowed, by a child of 100,000 per rolling hour, which is + 2,400,000 a day. A draft of this rule compared `<=` on the window and would have accepted that + child at 24x while rejecting the child of 100,000 per week, which is one seventh the rate. + + The proof, given §2.3's non-negative values: take any interval of length `window_p`; it sits + inside some interval of length `window_c`, whose sum is at most `limit_c`, which is at most + `limit_p`. So every spend pattern the child permits, the parent permits. Non-negativity is + what makes the sum monotonic over nested intervals, and without it none of this holds. + + **Existential, not positional**: one child budget may discharge several parent budgets, and a + child may add budgets on metrics the parent does not budget. Matching by metric alone is + undecidable the moment a parent carries two budgets on `amount`, which is the case §2.2 exists + for; matching by `(metric, window)` would make the window axis vacuous. + """ + if parent is None: + # A parent that budgets nothing constrains nothing here, and a child may add its own. + return True + for outer in parent: + if not any( + inner.metric == outer.metric + and inner.limit <= outer.limit + and inner.window >= outer.window + for inner in (child or ()) + ): + # `v0.3 §5.4`: omission never means unlimited. A child that drops the parent's budget + # is rejected rather than inheriting it, for that section's reason: a child that + # silently inherited would look, in the file and in the receipt, like one authorized + # for what it says. + return False + return True + + def _subject_contained(parent: Subject, child: Subject) -> bool: """§5.4's `subject` row: two things that look like naming the grantee are widening. @@ -1529,6 +1731,25 @@ def _canonical_grant(grant: Grant) -> PlainValue: # SPEC-v0.9 §6.7 — a dimension outside the hash is one an operator widens without the # hash moving, which is `SPEC-v0.8 §5.2`'s reason for `max_ttl` in this same field list. "tasks": None if grant.tasks is None else list(grant.tasks), + # SPEC-v0.9 §2.8, the same reason and the sharper case: an operator widens a budget from + # 10,000 to 10,000,000, the hash does not move, and every approval bound to it by + # `v0.6 §7.1` stays valid against a document that now permits a thousand times more. + # Rendered in document order, which §2.2 makes meaningful. + "budgets": ( + None + if grant.budgets is None + else [ + { + "metric": budget.metric, + "limit": budget.limit, + # Integer seconds, exactly as `_canonical_envelope` renders `max_ttl`: a + # `timedelta` is not a `PlainValue` (`action.py:19`) and so cannot go through + # `canonical_bytes`, and seconds is the spelling this file already uses. + "window": int(budget.window.total_seconds()), + } + for budget in grant.budgets + ] + ), } @@ -1776,6 +1997,7 @@ def _parse_grant(entry: object, where: str, *, unassigned: bool = False) -> Gran expires_at=_parse_expires_at(entry, where), delegable=delegable, tasks=(_parse_patterns(entry["tasks"], "tasks", where) if "tasks" in entry else None), + budgets=(_parse_budgets(entry["budgets"], where) if "budgets" in entry else None), ) except InvalidArgument as exc: # The model refuses what the loader refuses (§4.8), so the loader delegates the @@ -1783,6 +2005,41 @@ def _parse_grant(entry: object, where: str, *, unassigned: bool = False) -> Gran raise PolicyError(f"{where}: {exc}") from exc +_BUDGET_KEYS: Final = frozenset({"metric", "limit", "window"}) + + +def _parse_budgets(value: object, where: str) -> tuple[Budget, ...]: + """SPEC-v0.9 §2.2. A list of `{metric, limit, window}`, and nothing else. + + The grammar is closed for `v0.1 §3.1`'s reason: a key this loader silently dropped would be a + limit an operator wrote and nothing enforced. Errors carry the index, because a document with + two budgets on one metric is the case §2.2 exists for and "one of them is wrong" is not an + error message somebody can act on. + """ + if not isinstance(value, list) or not value: + raise PolicyError( + f"{where}: 'budgets' must be a non-empty list of " + "{metric, limit, window} mappings, or absent" + ) + parsed: list[Budget] = [] + for index, entry in enumerate(value): + spot = f"{where}: budgets[{index}]" + if not isinstance(entry, Mapping): + raise PolicyError(f"{spot}: must be a mapping, got {_type_name(entry)}") + _reject_unknown_keys(entry, _BUDGET_KEYS, spot) + for key in sorted(_BUDGET_KEYS): + if key not in entry: + raise PolicyError(f"{spot}: {key!r} is required") + window = _parse_duration(entry["window"], f"{spot}: window") + try: + parsed.append(Budget(metric=entry["metric"], limit=entry["limit"], window=window)) + except InvalidArgument as exc: + # §2.2 — the model refuses what the loader refuses, so the loader delegates the + # grammar to it rather than keeping a second copy that can drift. + raise PolicyError(f"{spot}: {exc}") from exc + return tuple(parsed) + + def _parse_subject(value: object, where: str) -> Subject: if not isinstance(value, Mapping): raise PolicyError( diff --git a/src/ctrlrun/policy.py b/src/ctrlrun/policy.py index 95743afc..b1a6a051 100644 --- a/src/ctrlrun/policy.py +++ b/src/ctrlrun/policy.py @@ -1166,6 +1166,10 @@ def require_v4(document: Mapping[Any, Any], schema: str, source: str) -> None: "an older reader would ignore the binding and authorise the grant on every task, which " "is the whole of what the key restricts" ), + "budgets": ( + "an older reader would ignore the limit and let the grant spend without bound, which is " + "the whole of what the key restricts" + ), } diff --git a/src/ctrlrun/verify/scenarios.py b/src/ctrlrun/verify/scenarios.py index 0e1e186f..b9749d3b 100644 --- a/src/ctrlrun/verify/scenarios.py +++ b/src/ctrlrun/verify/scenarios.py @@ -4278,6 +4278,9 @@ def _narrow(parent: Grant, selection: _Selection) -> tuple[Grant, Principal, dic # SPEC-v0.9 §8.0 — carried, or `_narrowed`'s own guard below raises # `VerifyInternalError` on any document that names tasks, before a single widening runs. tasks=None if parent.tasks is None else parent.tasks, + # SPEC-v0.9 §8.0, the same coupling `tasks` has: carried, or `_narrowed`'s own guard + # raises `VerifyInternalError` on any document that budgets, before a widening runs. + budgets=None if parent.budgets is None else parent.budgets, ) offending = contained_dimension(parent, child) if offending is not None: @@ -4325,6 +4328,15 @@ def _widen(parent: Grant, narrowed: Grant, dimension: str) -> Grant | None: if parent.tasks is None: return None return replace(narrowed, tasks=(DEEP_WILDCARD,)) + if dimension == "budgets": + # SPEC-v0.9 §2.6. Widened on the **limit**, which is the axis that reads forwards; the + # window axis reads backwards and a widening there would be a *shorter* window, which is + # the case a draft of the rule got wrong. One axis is enough to exercise the dimension, + # and the loud one is the one an operator would recognise in a counterexample. + if not parent.budgets: + return None + widened = tuple(replace(budget, limit=budget.limit + 1) for budget in parent.budgets) + return replace(narrowed, budgets=widened) raise VerifyInternalError(f"G9: unknown containment dimension {dimension!r}") @@ -4351,4 +4363,6 @@ def _omit(narrowed: Grant, parent: Grant, dimension: str) -> Grant | None: return None if parent.expires_at is None else replace(narrowed, expires_at=None) if dimension == "tasks": return None if parent.tasks is None else replace(narrowed, tasks=None) + if dimension == "budgets": + return None if parent.budgets is None else replace(narrowed, budgets=None) raise VerifyInternalError(f"G9: unknown containment dimension {dimension!r}") diff --git a/tests/test_budget_document.py b/tests/test_budget_document.py new file mode 100644 index 00000000..c00536ef --- /dev/null +++ b/tests/test_budget_document.py @@ -0,0 +1,327 @@ +"""T399 to T407: the budget in the document (SPEC-v0.9 §2). + +Parse, validate, canonicalise into the policy hash, and contain. **Nothing counts anything in +this item**: no ledger, no reservation, no execution. Item 4 builds the counter and item 5 +spends it. + +The window axis is the one to get right. A draft of §2.6 had it inverted, and the rule as written +would have accepted a child spending 24x its parent's authority while rejecting the child that was +genuinely narrower. T401 and T402 are written in both directions for that reason. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest + +from ctrlrun.authority import ( + Authority, + Budget, + Grant, + Subject, + canonical_grants, + contained_dimension, +) +from ctrlrun.errors import InvalidArgument, PolicyError +from ctrlrun.policy import Policy + +pytestmark = pytest.mark.authority + +DAY = timedelta(hours=24) +HOUR = timedelta(hours=1) +WEEK = timedelta(days=7) +MONTH = timedelta(days=30) + +DOCUMENT = """ +schema: ctrlrun.policy/v7 +environment: prod +actions: + payments.refund: + decision: allow +authority: + grants: + - id: payments-agent + subject: {agent: "payer"} + actions: ["payments.*"] + budgets: + - metric: amount + limit: 100000 + window: PT24H +""" + + +def _grant(*budgets: Budget, **overrides) -> Grant: + fields = dict( + id=overrides.pop("id", "g"), + subject=Subject(agent="payer", user="ada"), + actions=("payments.refund",), + budgets=budgets or None, + ) + fields.update(overrides) + return Grant(**fields) + + +def test_T399_a_well_formed_budget_loads_and_renders() -> None: + authority = Authority.from_yaml(DOCUMENT, source="") + grant = authority.grants["payments-agent"] + assert grant.budgets is not None + budget = grant.budgets[0] + assert (budget.metric, budget.limit, budget.window) == ("amount", 100000, DAY) + + +def test_T400_a_child_limit_above_its_parents_is_rejected() -> None: + parent = _grant(Budget("amount", 100000, DAY)) + child = _grant(Budget("amount", 100001, DAY)) + assert contained_dimension(parent, child) == "budgets" + + +def test_T401_a_child_window_SHORTER_than_its_parents_is_rejected() -> None: + """§2.6's axis that reads backwards, with the 24x case on the page beside the assertion. + + Parent: 100,000 per rolling day. Child: 100,000 per rolling **hour**, which is 2,400,000 a + day. A draft of the rule accepted exactly this. + """ + parent = _grant(Budget("amount", 100000, DAY)) + child = _grant(Budget("amount", 100000, HOUR)) + assert contained_dimension(parent, child) == "budgets" + + +def test_T402_a_child_window_LONGER_than_its_parents_is_accepted() -> None: + """The same limit over a week is one seventh the rate: narrower, and contained.""" + parent = _grant(Budget("amount", 100000, DAY)) + child = _grant(Budget("amount", 100000, WEEK)) + assert contained_dimension(parent, child) is None + + +def test_T402a_the_pairing_rule_over_the_two_budget_parent_SS2_2_exists_for() -> None: + """§2.6.1's four worked rows. A mapping keyed by metric could not express this parent.""" + parent = _grant(Budget("amount", 100000, DAY), Budget("amount", 500000, MONTH)) + # Row 1: each parent budget discharged by its own child budget. + both = _grant(Budget("amount", 50000, DAY), Budget("amount", 100000, MONTH)) + assert contained_dimension(parent, both) is None + # Row 2: ONE child budget discharges both, being under each limit and at least as long as + # each window. A 30-day cap of 50,000 implies a 24-hour cap of 50,000. + one = _grant(Budget("amount", 50000, MONTH)) + assert contained_dimension(parent, one) is None + # Row 3: the monthly parent budget is discharged by nothing. 50,000/day x 30 is 1,500,000. + daily_only = _grant(Budget("amount", 50000, DAY)) + assert contained_dimension(parent, daily_only) == "budgets" + # Row 4: shorter window, higher rate. + hourly = _grant(Budget("amount", 100000, HOUR)) + assert contained_dimension(parent, hourly) == "budgets" + + +def test_T403_a_child_omitting_a_budget_its_parent_carries_is_rejected() -> None: + """`v0.3 §5.4`, unchanged and with no exception for budgets.""" + parent = _grant(Budget("amount", 100000, DAY)) + assert contained_dimension(parent, _grant()) == "budgets" + + +def test_T404_a_child_budget_on_a_metric_the_parent_does_not_budget_is_an_addition() -> None: + parent = _grant(Budget("amount", 100000, DAY)) + child = _grant(Budget("amount", 50000, DAY), Budget("count", 10, DAY)) + assert contained_dimension(parent, child) is None + + +def test_T405_a_budget_changed_in_the_document_moves_the_policy_hash() -> None: + one = canonical_grants(Authority.from_yaml(DOCUMENT, source="")) + widened = canonical_grants( + Authority.from_yaml(DOCUMENT.replace("limit: 100000", "limit: 10000000"), source="") + ) + assert one != widened, "a budget outside the canonical render is one outside the hash" + + +def test_T405a_a_budget_in_a_break_glass_envelope_moves_the_hash_too() -> None: + """`canonical_grants` walks envelopes through `_canonical_grant`; §2.8's reason for both.""" + envelope = """ +schema: ctrlrun.policy/v7 +environment: prod +actions: + payments.refund: + decision: allow +authority: + grants: + - id: everyday + subject: {agent: "ops"} + actions: ["payments.read"] + break_glass: + incident: + subject: {agent: "oncall-*"} + actions: ["payments.*"] + max_ttl: PT4H + budgets: + - {metric: amount, limit: 50000, window: PT24H} +""" + one = canonical_grants(Authority.from_yaml(envelope, source="")) + two = canonical_grants( + Authority.from_yaml(envelope.replace("limit: 50000", "limit: 5000000"), source="") + ) + assert one != two + + +def test_T406_two_budgets_on_one_metric_load_in_document_order() -> None: + text = DOCUMENT.replace( + " - metric: amount\n limit: 100000\n window: PT24H\n", + " - {metric: amount, limit: 100000, window: PT24H}\n" + " - {metric: amount, limit: 500000, window: P30D}\n", + ) + grant = Authority.from_yaml(text, source="").grants["payments-agent"] + assert grant.budgets is not None + assert [b.window for b in grant.budgets] == [DAY, MONTH], "list order is the document's" + + +@pytest.mark.parametrize( + ("bad", "why"), + [ + ("limit: 1.5", "a float limit"), + ('limit: "100.50"', "a decimal string limit"), + ("limit: -1", "a negative limit"), + ("limit: true", "a bool limit, which is an int in Python"), + ("window: PT0S", "a zero window"), + ("window: -PT1H", "a negative window"), + ("metric: 7", "a non-string metric"), + ], +) +def test_T407_the_loader_and_the_constructor_refuse_the_same_shapes(bad: str, why: str) -> None: + """§2.2's rule: the model refuses exactly what the loader refuses. + + The decimal **string** is the one to write first: YAML hands a quoted number back as a `str`, + so `"100.50"` is the shape an operator actually produces, and coercing it would put the drift + back through the door `v0.1 §2.3` closed. + """ + key = bad.split(":")[0] + text = DOCUMENT.replace(f"{key}: 100000" if key == "limit" else f"{key}: PT24H", bad) + if key == "metric": + text = DOCUMENT.replace("metric: amount", bad) + with pytest.raises(PolicyError): + Policy.from_yaml(text, source="") + + +@pytest.mark.parametrize( + ("limit", "window"), + [(1.5, DAY), (-1, DAY), (True, DAY), (100, timedelta(0)), (100, -HOUR)], +) +def test_T407a_the_constructor_refuses_what_the_loader_refuses(limit, window) -> None: + with pytest.raises(InvalidArgument): + Budget("amount", limit, window) + + +def test_T407b_a_budget_is_refused_in_a_v6_document() -> None: + older = DOCUMENT.replace("ctrlrun.policy/v7", "ctrlrun.policy/v6") + with pytest.raises(PolicyError) as caught: + Policy.from_yaml(older, source="") + assert "budgets" in str(caught.value) + + +# --- reading a stored budget back (SPEC-v0.9 §2.2, `v0.3 §5.2`) ------------------------------- + + +@pytest.mark.parametrize( + ("window", "why"), + [ + (10**15, "an oversized int, which timedelta answers with OverflowError"), + (float("inf"), "an infinity, same"), + (True, "a bool, which timedelta reads as ONE SECOND"), + (0.5, "a sub-second float the loader's grammar cannot express"), + (0, "a zero window"), + (-1, "a negative window"), + ("PT24H", "the document's spelling, which is not what is stored"), + ], +) +def test_T407c_a_corrupt_stored_window_is_unreadable_and_never_an_exception(window, why) -> None: + """An independent review found two of these escaping, and one of them deployment-wide. + + `OverflowError` is in neither `except` tuple, so an oversized window raised **out of** + `Authority.evaluate`. `_candidates` reads every delegation row on every evaluation, so one + corrupt row denied nothing and crashed everything, for every principal and every action, with + no event and no receipt for an operator to find. + + And `timedelta(seconds=True)` is a one-second window: the bool trap one field over from where + `limit` closes it, failing in the direction that **grants** authority, since a shorter window + is a higher rate. + """ + from ctrlrun.authority import _budgets_from_json + + with pytest.raises(Exception) as caught: + _budgets_from_json([{"metric": "a", "limit": 5, "window": window}], "dlg_" + "0" * 32) + assert type(caught.value).__name__ == "_UnreadableError", ( + f"{why}: must be unreadable, not {type(caught.value).__name__}" + ) + + +def test_T407d_a_corrupt_row_denies_an_unrelated_principal_rather_than_crashing() -> None: + """The blast radius, driven end to end rather than argued. + + `Authority._candidates` reads every delegation row on every evaluation, so the failure mode + for an unreadable row has to be a refusal. Before the fix this raised `OverflowError`. + """ + import json + + from ctrlrun.action import Action, Principal + from ctrlrun.state import DelegationRecord, InMemoryStateStore + + document = """ +schema: ctrlrun.policy/v7 +authority: + grants: + - id: other + subject: {agent: "reconciliation-agent"} + actions: ["payments.read"] +""" + authority = Authority.from_yaml(document, source="", standalone=True) + store = InMemoryStateStore() + grant = { + "subject": {"agent": "payer", "user": "ada"}, + "actions": ["payments.refund"], + "resources": None, + "constraints": {}, + "environments": None, + "expires_at": None, + "delegable": False, + "tasks": None, + "budgets": [{"metric": "amount", "limit": 5, "window": 10**15}], + } + now = datetime(2026, 9, 13, tzinfo=UTC) + store.put_delegation( + DelegationRecord( + delegation_id="dlg_" + "0" * 32, + parent_id="other", + depth=1, + grant_json=json.dumps(grant), + created_by_agent="x", + created_by_user=None, + created_via="api", + created_at=now, + revoked_at=None, + revoked_by=None, + ) + ) + unrelated = Action( + name="payments.read", arguments={}, principal=Principal(agent="reconciliation-agent") + ) + result = authority.evaluate(unrelated, now=now, store=store) + assert not result.passed + assert result.reason == "authority_unreadable", ( + "one corrupt row must deny with a reason, not raise out of evaluate for the deployment" + ) + + +def test_T407e_a_stored_budget_may_not_carry_a_key_the_document_could_not() -> None: + """§2.2's rule in the direction the review found it broken: the loader's key set is closed, + so the reader's is too, or a stored row carries what no document can express.""" + from ctrlrun.authority import _budgets_from_json + + with pytest.raises(Exception) as caught: + _budgets_from_json( + [{"metric": "a", "limit": 5, "window": 86400, "surprise": 1}], "dlg_" + "0" * 32 + ) + assert type(caught.value).__name__ == "_UnreadableError" + + +def test_T407f_a_sub_second_window_is_refused_at_construction() -> None: + """A `Budget` that cannot round-trip is not a legal one: `grant_to_json` renders integer + seconds, so `timedelta(milliseconds=500)` would store as `0` and read back dead for ever.""" + with pytest.raises(InvalidArgument) as caught: + Budget("amount", 100, timedelta(milliseconds=500)) + assert "whole number of seconds" in str(caught.value) diff --git a/tests/test_cli_store.py b/tests/test_cli_store.py index d752f1a8..275701bb 100644 --- a/tests/test_cli_store.py +++ b/tests/test_cli_store.py @@ -219,6 +219,15 @@ def _delegable_child(parent) -> str: } if parent.expires_at is not None: document["expires_at"] = parent.expires_at.isoformat() + if parent.budgets is not None: + document["budgets"] = [ + { + "metric": budget.metric, + "limit": budget.limit, + "window": f"PT{int(budget.window.total_seconds())}S", + } + for budget in parent.budgets + ] if parent.tasks is not None: # SPEC-v0.9 §6.2 — one more dimension under the same structural rule this docstring # states. A helper that restated five of six would silently stop being "every dimension diff --git a/tests/test_verify_authority.py b/tests/test_verify_authority.py index a53d81af..694b02ab 100644 --- a/tests/test_verify_authority.py +++ b/tests/test_verify_authority.py @@ -60,6 +60,8 @@ delegable: true expires_at: "2027-01-01T00:00:00Z" tasks: ["refund-run:*"] + budgets: + - {metric: amount, limit: 500000, window: PT24H} """ #: Grants that name actions the policy does not list: nothing is authorized, which is a