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
39 changes: 39 additions & 0 deletions src/ctrlrun/authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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",
Expand Down
59 changes: 54 additions & 5 deletions src/ctrlrun/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
from ..reporting import (
budget_document,
budget_lines,
hop_document,
hop_lines,
inspection_for,
ledger_rows,
since_boundary,
Expand Down Expand Up @@ -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.

Expand All @@ -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:
Expand Down Expand Up @@ -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`.

Expand Down
30 changes: 29 additions & 1 deletion src/ctrlrun/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <it>` 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)
Expand Down Expand Up @@ -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,
Expand Down
108 changes: 107 additions & 1 deletion src/ctrlrun/reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}

Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading