diff --git a/src/ctrlrun/authority.py b/src/ctrlrun/authority.py index 57f37715..85826f6b 100644 --- a/src/ctrlrun/authority.py +++ b/src/ctrlrun/authority.py @@ -943,6 +943,44 @@ def unmatched_shape(grant: Grant, action: Action) -> str | None: return None +def narrowed_dimensions(parent: Grant, child: Grant) -> tuple[str, ...]: + """Which of §5.4's rows `child` makes strictly stricter than `parent` (SPEC-v0.10 §6.2). + + **A reporting helper. It decides nothing**, and that is the line that keeps §2.2's + one-relation rule intact: `contained_dimension` stays the only thing any decision calls, and a + build where this disagreed with it would be wrong about a rendering rather than about an + authorization. + + It exists because `contained_dimension` computes the **complement**: the first row a child + *violates*, or `None` where it is contained. An operator reading a refused action's chain needs + the other question, *which link took the resource away*, and no name in the tree answered it. + + Plural, in `DIMENSIONS` order. A hop narrowed on every dimension narrows on several at once + (T470), so a singular answer has no definition. + """ + narrowed: list[str] = [] + if parent.subject != child.subject: + narrowed.append("subject") + if set(parent.actions) != set(child.actions): + narrowed.append("actions") + if parent.resources != child.resources and child.resources is not None: + narrowed.append("resources") + if dict(parent.constraints) != dict(child.constraints): + narrowed.append("constraints") + if parent.environments != child.environments and child.environments is not None: + narrowed.append("environments") + if child.expires_at is not None and ( + parent.expires_at is None or child.expires_at < parent.expires_at + ): + narrowed.append("expires_at") + if parent.tasks != child.tasks and child.tasks is not None: + narrowed.append("tasks") + if parent.budgets != child.budgets and child.budgets is not None: + narrowed.append("budgets") + order = {name: index for index, name in enumerate(DIMENSIONS)} + return tuple(sorted(narrowed, key=lambda name: order[name])) + + def contained_dimension(parent: Grant, child: Grant) -> str | None: """The first §5.4 row `child` violates, or `None` where it is contained on every one. @@ -2342,6 +2380,7 @@ def _reject_unknown_keys(mapping: Mapping[Any, Any], allowed: Iterable[str], whe "grant_from_yaml", "grant_to_json", "matches", + "narrowed_dimensions", "new_delegation_id", "unmatched_shape", "validate_pattern", diff --git a/src/ctrlrun/cli/main.py b/src/ctrlrun/cli/main.py index 74406aaf..230d5c48 100644 --- a/src/ctrlrun/cli/main.py +++ b/src/ctrlrun/cli/main.py @@ -44,6 +44,8 @@ from ..reporting import ( budget_document, budget_lines, + hop_document, + hop_lines, inspection_for, ledger_rows, since_boundary, @@ -621,10 +623,19 @@ def resolve(effect_key: str, committed: bool, failed: bool, store_url: str | Non "grant_id", help="Show this grant's budgets instead: consumed, held, and what holds it.", ) +@click.option( + "--hop", + "hop_id", + help="Show this hop or delegation instead: who issued it, and what each link narrowed.", +) @click.option("--json", "as_json", is_flag=True, help="Emit one JSON object instead.") @STORE_URL_OPTION def inspect( - action_id: str | None, grant_id: str | None, as_json: bool, store_url: str | None + action_id: str | None, + grant_id: str | None, + hop_id: str | None, + as_json: bool, + store_url: str | None, ) -> None: """Show one action's whole history: proposal, decision, approval, effect, receipt. @@ -635,15 +646,24 @@ def inspect( The third number is the one that matters at 3am. A budget that refuses while it looks nowhere near its limit is almost always one unresolved effect: `ctrlrun resolve` clears it. """ - if (action_id is None) == (grant_id is None): - # §7.1 keeps both behind one command, which makes "which of the two did you mean" this - # command's own question. Neither names a subject; both name two. + given = [ + name + for name, value in (("ACTION_ID", action_id), ("--grant", grant_id), ("--hop", hop_id)) + if value + ] + if len(given) != 1: + # `v0.9 §7.1` keeps them behind one command, which makes "which of these did you mean" + # this command's own question. None names a subject; each names two. raise click.UsageError( - "give an ACTION_ID, or --grant GRANT_ID, and not both: they inspect different things" + "give exactly one of ACTION_ID, --grant GRANT_ID or --hop DELEGATION_ID: they " + "inspect different things" ) if grant_id is not None: _inspect_grant(grant_id, as_json, store_url) return + if hop_id is not None: + _inspect_hop(hop_id, as_json, store_url) + return assert action_id is not None store = _store(store_url) try: @@ -672,6 +692,35 @@ def inspect( click.echo(line) +def _inspect_hop(hop_id: str, as_json: bool, store_url: str | None) -> None: + """SPEC-v0.10 §6.2, behind `ctrlrun inspect --hop`. + + Answers about a **hop or an ordinary delegation alike**, because an operator paged about a + refusal does not yet know which kind they have: `created_via` is rendered rather than filtered + on. + """ + try: + control = _control_on(store_url) + authority = control._authority + if authority is None: + raise click.ClickException( + "this configuration has no 'authority:' section, so it holds no hops " + "(SPEC-v0.3 §4.1)" + ) + document = hop_document(hop_id, authority, control._store) + if document is None: + # Exits non-zero with nothing on stdout, as `inspect` does for an unknown action, so + # a script cannot mistake "no such hop" for "a hop with no chain". + raise click.ClickException(f"no hop {hop_id}") + except CTRLRunError as exc: + raise _fail(exc) from exc + if as_json: + click.echo(json.dumps(document, ensure_ascii=False, indent=2)) + return + for line in hop_lines(document): + click.echo(line) + + def _inspect_grant(grant_id: str, as_json: bool, store_url: str | None) -> None: """SPEC-v0.9 §7.2, behind `ctrlrun inspect --grant`. diff --git a/src/ctrlrun/control.py b/src/ctrlrun/control.py index 118e82e3..4858e26d 100644 --- a/src/ctrlrun/control.py +++ b/src/ctrlrun/control.py @@ -739,6 +739,34 @@ def __init__(self, reason: str) -> None: _UNLISTED_RANK: Final = _RANKS[NO_AUTHORITY] + 1 +def _where_to_look(result: AuthorityResult) -> str: + """SPEC-v0.10 §6.3 — the command, with its argument filled in, never a placeholder. + + **The argument is always the PRESENTED hop**, with whatever the refusal knows about the chain + named in the prose beside it. An earlier draft of §6.3 had `missing_parent_id` print the id it + names; that id is by construction the record the store could **not** read, so + `inspect --hop ` is the unknown-id path and exits non-zero. A refusal whose one suggested + command is guaranteed to fail is worse than no suggestion: it sends an operator to a dead end + and teaches them the line is noise. + + `authority_revoked` gets the same treatment for the same reason, measured: `_check_chain` + returns no id for the revoked node, so the only id in hand is the leaf. + + Nothing is suggested where there is no id, which is a principal that presented no hop and + holds no delegation: `inspect --hop` has no argument there and the operator's question is a + different one. + """ + hop = result.hop or result.delegation_id + if hop is None: + return "" + detail = "" + if result.missing_parent_id is not None: + detail = f"; {result.missing_parent_id} in its chain could not be read" + elif result.expired_parent_id is not None: + detail = f"; {result.expired_parent_id} above it has expired" + return f"{detail}. ctrlrun inspect --hop {hop}" + + def _rank(reason: str) -> int: """Where `reason` sits in the declared order (SPEC-v0.10 §5).""" rank: int = _RANKS.get(reason, _UNLISTED_RANK) @@ -1179,7 +1207,7 @@ def _refuse_authority( effect_key=effect_key, ) raise AuthorityDenied( - f"{action.name} denied: {result.reason}", + f"{action.name} denied: {result.reason}{_where_to_look(result)}", reason=result.reason, action_id=action.action_id, grant_id=result.grant_id, diff --git a/src/ctrlrun/reporting.py b/src/ctrlrun/reporting.py index 7ffc335b..cdc8540d 100644 --- a/src/ctrlrun/reporting.py +++ b/src/ctrlrun/reporting.py @@ -21,7 +21,7 @@ from typing import Any, Final from .approval import ApprovalRecord -from .authority import Budget +from .authority import Authority, Budget, _delegation_from_record, narrowed_dimensions from .effect import EffectRecord, EffectState from .errors import CTRLRunError, InvalidArgument from .policy import OBSERVE, Decision @@ -51,6 +51,12 @@ #: surface question and a separate one. BUDGET_SCHEMA: Final = "ctrlrun.budget/v1" +#: SPEC-v0.10 §6.2. Its own document rather than a key inside `ctrlrun.inspection/v2`, on +#: `v0.9 §10.1`'s argument for `ctrlrun.budget/v1`: that one answers about an **action** and this +#: answers about an **authority record**, and a reader handed one would have to know which shape +#: it got before it could read either. +HOP_SCHEMA: Final = "ctrlrun.hop/v1" + #: The three relative units of SPEC-v0.3 §6.4, and the `timedelta` keyword each names. _RELATIVE_UNITS: Final[Mapping[str, str]] = {"m": "minutes", "h": "hours", "d": "days"} @@ -318,6 +324,106 @@ def budget_document( } +def hop_document( + delegation_id: str, + authority: Authority, + store: StateStore, +) -> dict[str, Any] | None: + """One authority record and the chain above it (SPEC-v0.10 §6.2), or `None` if unknown. + + The 3am question §6.2 exists for is **which envelope did the peer actually hold, and which hop + narrowed it**. The chain answers the first; `narrowed[]` on each step answers the second, which + is the part nothing could answer before: an operator could see that a chain was valid or not, + and never which link took the resource away. + + `depth` is **derived by walking to the root**, never read from the stored column, for + `v0.3 §5.5`'s reason: a row edited directly in the database must not be able to assert its way + to a shorter chain, and a view that trusted the column would launder exactly that edit. + + One command answers about a hop and about an ordinary delegation alike, which is why + `created_via` is rendered rather than filtered on: an operator paged about a refusal does not + yet know which kind they have. + """ + record = store.get_delegation(delegation_id) + if record is None: + return None + delegation = _delegation_from_record(record) + walk = authority._walk(delegation, store=store) + chain: list[dict[str, Any]] = [] + steps = [*walk.nodes] + for index, node in enumerate(steps): + parent_grant = ( + steps[index + 1].grant if index + 1 < len(steps) else (walk.root if walk.root else None) + ) + parent_record = store.get_delegation(node.parent_id) + chain.append( + { + "id": node.delegation_id, + "parent_id": node.parent_id, + "depth": len(steps) - index, + "created_via": str(node.created_via), + "revoked_at": _iso_or_none(node.revoked_at), + "narrowed": list(narrowed_dimensions(parent_grant, node.grant)) + if parent_grant is not None + else [], + } + ) + if parent_record is None: + break + return { + "schema": HOP_SCHEMA, + "hop": delegation.delegation_id, + "created_by": { + "agent": delegation.created_by.agent, + "user": delegation.created_by.user, + }, + "created_at": _iso_or_none(delegation.created_at), + "created_via": str(delegation.created_via), + "subject": { + "agent": delegation.grant.subject.agent, + "user": delegation.grant.subject.user, + }, + "depth": len(walk.nodes), + "revoked_at": _iso_or_none(delegation.revoked_at), + "root_id": walk.root_id, + "missing_parent_id": walk.missing_parent_id, + "chain": chain, + } + + +def _iso_or_none(value: datetime | None) -> str | None: + return None if value is None else value.isoformat() + + +def hop_lines(document: Mapping[str, Any]) -> list[str]: + """§6.2's view for a terminal, from the same document `--json` emits. + + One producer, for `inspection_for`'s reason: two builders that agree today disagree later. + """ + created = document["created_by"] + who = created["agent"] + (f" for {created['user']}" if created["user"] else "") + lines = [ + f"{document['created_via']} {document['hop']}", + f" created by {who} at {document['created_at']}", + f" issued to {document['subject']['agent']}" + + (f" for {document['subject']['user']}" if document["subject"]["user"] else ""), + f" depth {document['depth']}, walked to the root rather than read from the row", + ] + if document["revoked_at"]: + lines.append(f" REVOKED at {document['revoked_at']}") + for step in document["chain"]: + narrowed = ", ".join(step["narrowed"]) or "nothing" + mark = " REVOKED" if step["revoked_at"] else "" + lines.append(f" {step['id']} (depth {step['depth']}) narrows {narrowed}{mark}") + if document["missing_parent_id"]: + # §6.3's rule: the id the store could not read is named in the prose and never printed as + # a command's argument, because `inspect --hop` on it is the unknown-id path. + lines.append(f" the chain stops here: {document['missing_parent_id']} could not be read") + elif document["root_id"]: + lines.append(f" root {document['root_id']}") + return lines + + def budget_lines(document: Mapping[str, Any]) -> list[str]: """§7.2's view for a terminal, from the same document `--json` emits. diff --git a/tests/test_hop_surfaces.py b/tests/test_hop_surfaces.py new file mode 100644 index 00000000..dbc74253 --- /dev/null +++ b/tests/test_hop_surfaces.py @@ -0,0 +1,277 @@ +"""SPEC-v0.10 §6, item 5: the operator surfaces for a hop. + +The 3am question is `v0.9 §7.2`'s shape one level up: **which envelope did the peer actually hold, +and which hop narrowed it**. An operator could already see whether a chain was valid; what nothing +answered was which link took the resource away. + +No new command: `v0.9 §7.1`'s reasoning applies unchanged, and a hop is one more thing `inspect` +answers about. +""" + +from __future__ import annotations + +import json + +import pytest +from click.testing import CliRunner + +from ctrlrun.action import Principal +from ctrlrun.authority import Authority, grant_from_yaml, narrowed_dimensions +from ctrlrun.cli.main import main +from ctrlrun.control import Control +from ctrlrun.policy import Policy +from ctrlrun.reporting import HOP_SCHEMA, hop_document +from ctrlrun.state import SQLiteStateStore + +DOC = """ +schema: ctrlrun.policy/v7 +actions: + stripe.refund: + effect: "refund:{payment}" + decision: allow +authority: + max_delegation_depth: 3 + grants: + - id: head-of-support + subject: { agent: "planner" } + actions: ["stripe.refund", "stripe.refund.partial"] + resources: ["payment:*"] + environments: ["production", "staging"] + delegable: true + expires_at: "2027-01-01T00:00:00Z" +""" + +MIDDLE = """ +subject: { agent: "relay" } +actions: ["stripe.refund"] +resources: ["payment:EU-*"] +environments: ["production"] +delegable: true +expires_at: "2026-12-01T00:00:00Z" +""" + +LEAF = """ +subject: { agent: "worker" } +actions: ["stripe.refund"] +resources: ["payment:EU-1"] +environments: ["production"] +expires_at: "2026-10-01T00:00:00Z" +""" + + +def _chain(tmp_path): + authority = Authority.from_yaml(DOC, source="t") + policy = Policy.from_yaml(DOC, source="t") + store = SQLiteStateStore(str(tmp_path / "s.db")) + control = Control(policy, store, authority=authority) + first = control.hop( + "head-of-support", grant_from_yaml(MIDDLE, source="t"), by=Principal(agent="planner") + ) + second = control.hop( + first.delegation_id, grant_from_yaml(LEAF, source="t"), by=Principal(agent="relay") + ) + return control, store, authority, first, second + + +@pytest.mark.authority +def test_T503_inspect_hop_renders_every_ancestor_and_what_each_narrowed(tmp_path): + """§6.2. `chain[]` carries one entry per link with the dimensions that link narrowed, which + is the part that answers the question. Depth is **derived by walking to the root**, never read + from the stored column (`v0.3 §5.5`): a row edited with a text editor must not be able to + assert its way to a shorter chain, and a view that trusted the column would launder that.""" + _, store, authority, first, second = _chain(tmp_path) + + document = hop_document(second.delegation_id, authority, store) + + assert document is not None + assert document["schema"] == HOP_SCHEMA + assert document["hop"] == second.delegation_id + assert document["created_via"] == "hop" + assert document["created_by"] == {"agent": "relay", "user": None} + assert document["subject"] == {"agent": "worker", "user": None} + assert document["depth"] == 2 + assert document["root_id"] == "head-of-support" + + ids = [step["id"] for step in document["chain"]] + assert ids == [second.delegation_id, first.delegation_id] + # The leaf narrowed resources (EU-* to EU-1) and the expiry; the middle narrowed the actions + # it dropped, its resources, and its own expiry. + assert "resources" in document["chain"][0]["narrowed"] + assert "expires_at" in document["chain"][0]["narrowed"] + assert "actions" in document["chain"][1]["narrowed"] + + +@pytest.mark.authority +def test_T504_an_unknown_hop_exits_non_zero_with_nothing_on_stdout(tmp_path, monkeypatch): + """As `inspect` does for an unknown action, so a script cannot mistake "no such hop" for "a + hop with no chain".""" + _, store, _authority, _, _ = _chain(tmp_path) + store.close() + monkeypatch.chdir(tmp_path) + (tmp_path / "ctrlrun.yaml").write_text(DOC) + + result = CliRunner().invoke( + main, ["inspect", "--hop", "dlg_" + "0" * 32, "--store-url", f"sqlite:{tmp_path}/s.db"] + ) + + assert result.exit_code != 0 + assert result.stdout.strip() == "" + + +@pytest.mark.authority +def test_T506_json_emits_the_keys_section_6_2_names(tmp_path): + """§6.2's table, asserted by key set so a field cannot be added without a test going red.""" + _, store, authority, _, second = _chain(tmp_path) + + document = hop_document(second.delegation_id, authority, store) + + assert document is not None + assert set(document) == { + "schema", + "hop", + "created_by", + "created_at", + "created_via", + "subject", + "depth", + "revoked_at", + "root_id", + "missing_parent_id", + "chain", + } + assert set(document["chain"][0]) == { + "id", + "parent_id", + "depth", + "created_via", + "revoked_at", + "narrowed", + } + json.dumps(document) # portable JSON, like every other surface (v0.6 §11) + + +@pytest.mark.authority +def test_T505_a_refusal_prints_the_command_with_the_presented_hop_filled_in(tmp_path): + """§6.3. **The argument is the presented hop**, never the id the store could not read. + + That id is by construction unreadable, so `inspect --hop ` is T504's path and exits + non-zero: a refusal whose one suggested command is guaranteed to fail is worse than no + suggestion, because it teaches an operator the line is noise. + """ + from ctrlrun.errors import AuthorityDenied + + control, _store, _authority, _first, second = _chain(tmp_path) + from ctrlrun.action import Action + + proposed = Action( + name="stripe.refund", + resource="payment:US-9", # outside the leaf's envelope + arguments={"amount": 10, "payment": "US-9"}, + principal=Principal(agent="worker"), + environment="production", + ) + + with pytest.raises(AuthorityDenied) as refused: + control.execute(proposed, lambda: "ok", "refund:US-9", hop=second.delegation_id) + + message = str(refused.value) + assert f"ctrlrun inspect --hop {second.delegation_id}" in message, message + + +@pytest.mark.authority +def test_T505b_the_printed_command_is_one_that_works(tmp_path): + """Asserted by **running** what the refusal printed, which is what forbids suggesting an id + the store cannot read.""" + _, store, authority, _, second = _chain(tmp_path) + + document = hop_document(second.delegation_id, authority, store) + + assert document is not None, ( + "the refusal suggests `inspect --hop `; if that id does not resolve, the " + "suggestion is a dead end" + ) + + +def test_narrowed_dimensions_never_contradicts_contained_dimension(tmp_path): + """§6.2's rule that the helper decides nothing. It answers the complement of + `contained_dimension`, so on a **contained** pair that function answers `None` and this one + answers a subset of `DIMENSIONS`; the two are checked against each other on the same pairs so + a disagreement is still caught.""" + from ctrlrun.authority import DIMENSIONS, contained_dimension + + parent = Authority.from_yaml(DOC, source="t").grants["head-of-support"] + child = grant_from_yaml(MIDDLE, source="t") + + assert contained_dimension(parent, child) is None, "the fixture must be a contained pair" + narrowed = narrowed_dimensions(parent, child) + assert set(narrowed) <= set(DIMENSIONS) + assert list(narrowed) == sorted(narrowed, key=DIMENSIONS.index), "DIMENSIONS order" + + +@pytest.mark.authority +def test_T503b_depth_is_walked_even_when_the_stored_column_lies(tmp_path): + """`v0.3 §5.5`: depth is **derived by walking to the root**, never read from the stored + column, so a row edited directly in the database cannot assert its way to a shorter chain. + + A mutation run is why this test exists. Reading `delegation.depth` instead of walking passed + every other test in this file, because a chain created through `Control.hop` has a column that + agrees with the walk. **The column only lies when somebody edits it**, which is the whole + threat the rule is about, so the test has to do the editing. + """ + _control, store, authority, _first, second = _chain(tmp_path) + record = store.get_delegation(second.delegation_id) + assert record is not None + store._connection().execute( + "UPDATE delegations SET depth = 1 WHERE delegation_id = ?", (second.delegation_id,) + ) + store._connection().commit() # a text editor's lie: "I am one link down" + + document = hop_document(second.delegation_id, authority, store) + + assert document is not None + assert document["depth"] == 2, ( + "the view read the stored column, which is the edit v0.3 §5.5 exists to refuse; a " + "surface that trusted it would launder exactly that edit into an operator's answer" + ) + assert document["chain"][0]["depth"] == 2 + + +@pytest.mark.authority +def test_T505c_a_broken_chain_suggests_the_presented_hop_and_names_the_unreadable_one(tmp_path): + """§6.3's rule, and the case that separates it from the obvious alternative. + + `missing_parent_id` names the record the store could **not** read, so + `inspect --hop ` is T504's unknown-id path and exits non-zero. The refusal therefore + prints the **presented** hop, which does resolve, and names the unreadable one in prose. + + A mutation run is why this test exists: printing the missing id instead passed every other + test here, because none of them broke a chain. + """ + from ctrlrun.action import Action + from ctrlrun.errors import AuthorityDenied + + control, store, _authority, first, second = _chain(tmp_path) + # The shape a hand-edited store has, which `v0.3 §5.6` rule 1 exists for. + store._connection().execute( + "DELETE FROM delegations WHERE delegation_id = ?", (first.delegation_id,) + ) + store._connection().commit() + + proposed = Action( + name="stripe.refund", + resource="payment:EU-1", + arguments={"amount": 10, "payment": "EU-1"}, + principal=Principal(agent="worker"), + environment="production", + ) + + with pytest.raises(AuthorityDenied) as refused: + control.execute(proposed, lambda: "ok", "refund:EU-1", hop=second.delegation_id) + + message = str(refused.value) + assert f"ctrlrun inspect --hop {second.delegation_id}" in message, message + assert f"--hop {first.delegation_id}" not in message, ( + "the suggested command names the record the store could not read, so running it exits " + "non-zero: a dead end teaches an operator the line is noise" + ) + assert first.delegation_id in message, "the unreadable id belongs in the prose"