sentrykit 0.1.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. sentrykit/__init__.py +200 -0
  2. sentrykit/audit.py +967 -0
  3. sentrykit/cedar.py +750 -0
  4. sentrykit/cli.py +1405 -0
  5. sentrykit/config.py +300 -0
  6. sentrykit/constraints.py +928 -0
  7. sentrykit/context.py +393 -0
  8. sentrykit/decorator.py +252 -0
  9. sentrykit/engine.py +1509 -0
  10. sentrykit/envelope.py +513 -0
  11. sentrykit/errors.py +252 -0
  12. sentrykit/integrations/__init__.py +1 -0
  13. sentrykit/integrations/base.py +767 -0
  14. sentrykit/integrations/fastapi.py +361 -0
  15. sentrykit/integrations/flask.py +342 -0
  16. sentrykit/integrations/mcp.py +929 -0
  17. sentrykit/integrations/pydantic_ai.py +728 -0
  18. sentrykit/integrations/tools.py +479 -0
  19. sentrykit/masking.py +459 -0
  20. sentrykit/policy/__init__.py +40 -0
  21. sentrykit/policy/loader.py +764 -0
  22. sentrykit/policy/pack/10-rbac.cedar +9 -0
  23. sentrykit/policy/pack/20-groups.cedar +9 -0
  24. sentrykit/policy/pack/30-ownership.cedar +8 -0
  25. sentrykit/policy/pack/40-delegation.cedar +40 -0
  26. sentrykit/policy/pack/50-expiry.cedar +24 -0
  27. sentrykit/policy/pack/60-break-glass.cedar +34 -0
  28. sentrykit/policy/pack/70-separation-of-duties.cedar +22 -0
  29. sentrykit/policy/pack/80-jit-elevation.cedar +32 -0
  30. sentrykit/policy/pack/entities.json +3 -0
  31. sentrykit/policy/pack/pack.cedarschema +45 -0
  32. sentrykit/policy/pack/templates/time-boxed-grant.cedartemplate +5 -0
  33. sentrykit/py.typed +0 -0
  34. sentrykit/registry.py +666 -0
  35. sentrykit/stores/__init__.py +116 -0
  36. sentrykit/stores/base.py +870 -0
  37. sentrykit/stores/composite.py +635 -0
  38. sentrykit/stores/filesystem.py +400 -0
  39. sentrykit/stores/jsonfile.py +701 -0
  40. sentrykit/stores/jsonl.py +357 -0
  41. sentrykit/stores/memory.py +500 -0
  42. sentrykit-0.1.0rc1.dist-info/METADATA +541 -0
  43. sentrykit-0.1.0rc1.dist-info/RECORD +46 -0
  44. sentrykit-0.1.0rc1.dist-info/WHEEL +4 -0
  45. sentrykit-0.1.0rc1.dist-info/entry_points.txt +20 -0
  46. sentrykit-0.1.0rc1.dist-info/licenses/LICENSE +201 -0
sentrykit/cli.py ADDED
@@ -0,0 +1,1405 @@
1
+ """``sentrykit`` — the maintenance and audit command line.
2
+
3
+ Three things an operator needs that a library API is the wrong shape for: checking that a
4
+ policy directory is sound *before* it is deployed, moving a grant, and turning a year of
5
+ decisions into a document somebody can read.
6
+
7
+ ```bash
8
+ sentrykit policy validate ./policies
9
+ sentrykit grant 'User::"ana"' --role oncall --expires 2026-09-01 \
10
+ --store memory:// --as 'User::"root"'
11
+ sentrykit revoke 4f2c... --reason "shift ended" --store memory:// --as 'User::"root"'
12
+ sentrykit audit-report --from 2026-01-01 --to 2026-12-31 \
13
+ --out audit.md --sink jsonl://./audit.jsonl
14
+ ```
15
+
16
+ Reachable as the ``sentrykit`` console script and as ``python -m sentrykit.cli``; both call
17
+ :func:`main`.
18
+
19
+ ## No CLI framework
20
+
21
+ :mod:`argparse` from the standard library, and nothing else. Three command groups over a
22
+ stdlib parser is a few dozen lines of wiring, and a dependency bought with every install of a
23
+ library whose entire pitch is that it is thin is a bad trade. Nothing here imports a web
24
+ framework or an agent SDK either: the CLI is part of the core, and the core stays
25
+ framework-free.
26
+
27
+ ## The admin surface governs itself
28
+
29
+ ``grant`` and ``revoke`` are not privileged back doors into the store. Each one is a
30
+ ``@protect``-ed callable, decided by the same :class:`~sentrykit.engine.Governor` the
31
+ application uses, against ``Action::"admin.grant"`` / ``Action::"admin.revoke"`` on
32
+ ``Resource::"admin.grants"`` — so a policy can say who may administer grants, *and which
33
+ grants they may make*, because the proposed subject and role are ordinary arguments subject to
34
+ ordinary argument filtering. The mutation is then written to the audit sink as its own
35
+ record (architecture §5).
36
+
37
+ That means an allowed ``grant`` writes **two** audit records — the decision that permitted it,
38
+ and the mutation that happened — and a refused one writes **one**, the refusal. The scenario
39
+ tests assert those counts, because a decision that is right and a log that is wrong is still
40
+ a failure for an audit-first library.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import argparse
46
+ import json
47
+ import os
48
+ import re
49
+ import sys
50
+ from collections import Counter
51
+ from dataclasses import dataclass, field
52
+ from datetime import UTC, datetime, timedelta
53
+ from pathlib import Path
54
+ from typing import TYPE_CHECKING, Final, Literal, cast
55
+
56
+ from sentrykit import __version__
57
+ from sentrykit.audit import AuthzAuditRecord, ChainVerdict, verify_chain
58
+ from sentrykit.cedar import EntityRef, format_policies, validate_policies
59
+ from sentrykit.config import ENV_PREFIX, get_settings
60
+ from sentrykit.context import establish
61
+ from sentrykit.decorator import protect
62
+ from sentrykit.engine import install
63
+ from sentrykit.envelope import ENVELOPE_KEY, last_envelope
64
+ from sentrykit.errors import ConfigurationError, SentrykitError
65
+ from sentrykit.policy.loader import pack_root
66
+ from sentrykit.registry import build_audit_sink, build_grant_store
67
+ from sentrykit.stores.base import (
68
+ WILDCARD_ACTION,
69
+ AuditWindow,
70
+ GrantSpec,
71
+ PolicyBundle,
72
+ SupportsClose,
73
+ )
74
+
75
+ if TYPE_CHECKING: # pragma: no cover - imported for annotations only
76
+ from collections.abc import Iterable, Mapping, Sequence
77
+
78
+ from sentrykit.context import Principal
79
+ from sentrykit.engine import Governor
80
+ from sentrykit.stores.base import GrantKind, GrantRecord, GrantStore
81
+
82
+ __all__ = [
83
+ "ADMIN_RESOURCE",
84
+ "GRANT_ACTION",
85
+ "REVOKE_ACTION",
86
+ "AuditReport",
87
+ "build_parser",
88
+ "build_report",
89
+ "main",
90
+ "render_markdown",
91
+ ]
92
+
93
+ GRANT_ACTION: Final = 'Action::"admin.grant"'
94
+ """The Cedar action a ``sentrykit grant`` asks permission for."""
95
+
96
+ REVOKE_ACTION: Final = 'Action::"admin.revoke"'
97
+ """The Cedar action a ``sentrykit revoke`` asks permission for."""
98
+
99
+ ADMIN_RESOURCE: Final = 'Resource::"admin.grants"'
100
+ """The resource both admin actions are decided against."""
101
+
102
+ EXIT_OK: Final = 0
103
+ """Everything asked for succeeded."""
104
+
105
+ EXIT_FAILED: Final = 1
106
+ """The command ran and reported a failure — invalid policies, a refusal, a missing grant."""
107
+
108
+ ACTOR_ENV: Final = "SENTRYKIT_ACTOR"
109
+ """Environment variable naming the operator, when ``--as`` is omitted."""
110
+
111
+ DEFAULT_POLICY_GLOB: Final = "*.cedar"
112
+ """Which files ``policy validate`` reads, unless ``--pattern`` says otherwise."""
113
+
114
+ SCHEMA_GLOB: Final = "*.cedarschema"
115
+ """Which files supply the schema a directory is validated against."""
116
+
117
+ PARSE_SENTINEL: Final = "<parse>"
118
+ """``policy_id`` Cedar reports when a failure could not be attributed to a policy.
119
+
120
+ :func:`~sentrykit.cedar.validate_policies` uses it for a parse error, which by definition has
121
+ no ``@id`` to name. Printing "policy ``<parse>`` failed" would tell an operator nothing, so
122
+ these are reported at file level instead.
123
+ """
124
+
125
+ CHANNEL: Final = "cli"
126
+ """``context.channel`` the admin decisions are made under, so a policy can require it."""
127
+
128
+ _DATE_ONLY: Final = re.compile(r"^\d{4}-\d{2}-\d{2}$")
129
+ """A bare date, as opposed to a full timestamp. The two are windowed differently."""
130
+
131
+ _GRANT_COLUMNS: Final = ("id", "kind", "subject", "value", "action", "expires_at", "revoked_at")
132
+ """Fields ``sentrykit grants`` prints, all of them read off
133
+ :meth:`~sentrykit.stores.base.GrantRecord.as_record` so the listing cannot show anything a
134
+ grant record does not already expose."""
135
+
136
+
137
+ # --- report model --------------------------------------------------------------------------
138
+
139
+
140
+ @dataclass(frozen=True, slots=True)
141
+ class AuditReport:
142
+ """A period's decisions, aggregated — the yearly audit report as a query, not as manual
143
+ log-reading.
144
+
145
+ Aggregated by **outcome** rather than by decision, because ``allow``/``deny`` collapses
146
+ six materially different refusals (and both admin mutations) into two buckets, and an
147
+ auditor asking "how often did a call fail validation" cannot get that back out again.
148
+
149
+ Attributes:
150
+ window: The query this report answers.
151
+ sink: The name of the sink that answered it.
152
+ generated_at: When the report was built.
153
+ total: How many records the window matched.
154
+ decisions: How many of them were authorization decisions.
155
+ mutations: How many were admin grant mutations.
156
+ allowed: Decisions that permitted the call.
157
+ denied: Decisions that refused it.
158
+ by_outcome: Record count per :data:`~sentrykit.audit.AuditOutcome`, descending.
159
+ by_action: Record count per action uid, descending.
160
+ by_principal: Record count per principal uid, descending.
161
+ by_policy: Refusal *and* allow count per determining policy id, descending — the
162
+ column that answers "which rule is actually doing the work".
163
+ by_obligation: How often each obligation was raised.
164
+ refusals: The refusals themselves, newest first, capped by the caller's ``--top``.
165
+ chain: The tamper-evidence verdict over the matched records, or ``None`` when the
166
+ window filtered by identity and the records are therefore a non-contiguous slice
167
+ of the chain rather than a run of it.
168
+ verified: How many records' own ``record_hash`` recomputed correctly. Always
169
+ meaningful, filtered window or not.
170
+ records_lost: How many records the window's own records admit were built, masked and
171
+ then refused by the sink — the sum of their ``dropped_since_last_write``. An
172
+ intact chain says nothing about these: the chain advances only on a successful
173
+ write, so the records that survived an outage link perfectly across it. This is
174
+ the only number in the report that can say the log is incomplete.
175
+ """
176
+
177
+ window: AuditWindow
178
+ sink: str = ""
179
+ generated_at: datetime | None = None
180
+ total: int = 0
181
+ decisions: int = 0
182
+ mutations: int = 0
183
+ allowed: int = 0
184
+ denied: int = 0
185
+ by_outcome: Mapping[str, int] = field(default_factory=dict)
186
+ by_action: Mapping[str, int] = field(default_factory=dict)
187
+ by_principal: Mapping[str, int] = field(default_factory=dict)
188
+ by_policy: Mapping[str, int] = field(default_factory=dict)
189
+ by_obligation: Mapping[str, int] = field(default_factory=dict)
190
+ refusals: tuple[AuthzAuditRecord, ...] = ()
191
+ chain: ChainVerdict | None = None
192
+ verified: int = 0
193
+ records_lost: int = 0
194
+
195
+
196
+ def build_report(
197
+ records: Iterable[AuthzAuditRecord],
198
+ *,
199
+ window: AuditWindow,
200
+ sink: str = "",
201
+ generated_at: datetime | None = None,
202
+ top: int = 20,
203
+ ) -> AuditReport:
204
+ """Aggregate a window's worth of records.
205
+
206
+ Pure: it reads an iterable and returns a value, touching no sink and no clock. That is
207
+ what lets the report be asserted exactly in a test over a seeded log.
208
+
209
+ Args:
210
+ records: The records, oldest first, as :meth:`~sentrykit.stores.base.AuditSink.query`
211
+ returns them.
212
+ window: The query they answer, carried onto the report.
213
+ sink: The sink's name, for the report header.
214
+ generated_at: The report timestamp. Pass one to make a test deterministic.
215
+ top: How many refusals to carry through to :attr:`AuditReport.refusals`.
216
+
217
+ Returns:
218
+ The aggregate.
219
+ """
220
+ matched = tuple(records)
221
+
222
+ outcomes: Counter[str] = Counter()
223
+ actions: Counter[str] = Counter()
224
+ principals: Counter[str] = Counter()
225
+ policies: Counter[str] = Counter()
226
+ obligations: Counter[str] = Counter()
227
+
228
+ decisions = mutations = allowed = denied = verified = 0
229
+ records_lost = 0
230
+ refusals: list[AuthzAuditRecord] = []
231
+
232
+ for record in matched:
233
+ outcomes[record.outcome] += 1
234
+ records_lost += record.dropped_since_last_write
235
+ if record.action:
236
+ actions[record.action] += 1
237
+ principals[record.principal.uid] += 1
238
+ policies.update(record.determining_policies)
239
+ obligations.update(record.obligations)
240
+ if record.verify():
241
+ verified += 1
242
+ if record.kind == "mutation":
243
+ # Counted here and nowhere else. Folding a grant into "allowed" would collapse
244
+ # two different things: an admin conferring a role is not a call that was
245
+ # permitted, it is a change to what will be permitted next.
246
+ mutations += 1
247
+ continue
248
+ decisions += 1
249
+ if record.decision == "allow":
250
+ allowed += 1
251
+ else:
252
+ denied += 1
253
+ refusals.append(record)
254
+
255
+ return AuditReport(
256
+ window=window,
257
+ sink=sink,
258
+ generated_at=generated_at,
259
+ total=len(matched),
260
+ decisions=decisions,
261
+ mutations=mutations,
262
+ allowed=allowed,
263
+ denied=denied,
264
+ by_outcome=_ranked(outcomes),
265
+ by_action=_ranked(actions),
266
+ by_principal=_ranked(principals),
267
+ by_policy=_ranked(policies),
268
+ by_obligation=_ranked(obligations),
269
+ # Newest first, so the section an operator reads top-down opens on what just happened.
270
+ refusals=tuple(reversed(refusals))[: max(top, 0)],
271
+ chain=verify_chain(matched) if _spans_the_chain(window) else None,
272
+ verified=verified,
273
+ records_lost=records_lost,
274
+ )
275
+
276
+
277
+ def _ranked(counts: Counter[str]) -> dict[str, int]:
278
+ """Order a tally by descending count, ties broken by key.
279
+
280
+ Deterministic on purpose: ``Counter.most_common`` leaves ties in insertion order, which
281
+ depends on the order records happened to be written, and a report a test cannot assert
282
+ exactly is a report nobody can diff between two periods either.
283
+ """
284
+ ordered = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
285
+ return dict(ordered)
286
+
287
+
288
+ def _spans_the_chain(window: AuditWindow) -> bool:
289
+ """Whether ``window``'s records are a contiguous run of the log rather than a slice.
290
+
291
+ Time bounds keep a run contiguous; every other filter picks records out of the middle. A
292
+ ``broken_at`` over a filtered set would mean "you filtered", not "somebody tampered", and
293
+ a tamper-evidence line that cries wolf is worse than none — so
294
+ :attr:`AuditReport.chain` is left unset instead.
295
+ """
296
+ return (
297
+ window.principal is None
298
+ and window.action is None
299
+ and window.resource is None
300
+ and window.decision is None
301
+ and window.kind is None
302
+ )
303
+
304
+
305
+ def render_markdown(report: AuditReport) -> str:
306
+ """Render a report as markdown.
307
+
308
+ Markdown rather than PDF: it is diffable, it renders in every forge and doc site, and it
309
+ costs no dependency. Anyone who needs a PDF runs the markdown through the converter they
310
+ already have.
311
+
312
+ Args:
313
+ report: The aggregate.
314
+
315
+ Returns:
316
+ The document, ending in a newline.
317
+ """
318
+ return _document(report)
319
+
320
+
321
+ def _document(report: AuditReport, title: str | None = None) -> str:
322
+ """Render ``report``, optionally under an operator-supplied title.
323
+
324
+ :class:`AuditReport` carries no title of its own — it is the aggregate, not the
325
+ presentation — so ``--title`` is threaded through here rather than onto the value object,
326
+ and :func:`render_markdown` stays a pure function of the report.
327
+
328
+ Args:
329
+ report: The aggregate.
330
+ title: The document heading. Defaults to one naming the window.
331
+
332
+ Returns:
333
+ The document, ending in a newline.
334
+ """
335
+ window = report.window
336
+ lines: list[str] = [
337
+ f"# {title or _default_title(window)}",
338
+ "",
339
+ f"- Window: {_moment(window.start)} to {_moment(window.end)} (end exclusive)",
340
+ f"- Sink: {report.sink or 'unknown'}",
341
+ f"- Generated: {_moment(report.generated_at)}",
342
+ f"- Filters: {_describe_filters(window)}",
343
+ "",
344
+ "## Summary",
345
+ "",
346
+ "| Measure | Count |",
347
+ "| --- | ---: |",
348
+ f"| Records | {report.total} |",
349
+ f"| Decisions | {report.decisions} |",
350
+ f"| Mutations | {report.mutations} |",
351
+ f"| Allowed | {report.allowed} |",
352
+ f"| Denied | {report.denied} |",
353
+ "",
354
+ ]
355
+ lines += _counts("Outcomes", "Outcome", report.by_outcome)
356
+ lines += _counts("Actions", "Action", report.by_action)
357
+ lines += _counts("Principals", "Principal", report.by_principal)
358
+ lines += _counts("Determining policies", "Policy", report.by_policy)
359
+ lines += _counts("Obligations", "Obligation", report.by_obligation)
360
+ lines += _mutations(report)
361
+ lines += _refusals(report)
362
+ lines += _tamper_evidence(report)
363
+ return "\n".join(lines).rstrip("\n") + "\n"
364
+
365
+
366
+ def _default_title(window: AuditWindow) -> str:
367
+ """Name the report after the period it covers, or say it covers everything."""
368
+ if window.start is None or window.end is None:
369
+ return "Audit report"
370
+ return f"Audit report: {window.start.date().isoformat()} to {window.end.date().isoformat()}"
371
+
372
+
373
+ def _moment(value: datetime | None) -> str:
374
+ """Render a timestamp for the report header, or ``unbounded`` when there is none."""
375
+ return "unbounded" if value is None else value.isoformat()
376
+
377
+
378
+ def _describe_filters(window: AuditWindow) -> str:
379
+ """List the identity filters in force, so a reader knows what the counts exclude."""
380
+ named = {
381
+ "principal": window.principal,
382
+ "action": window.action,
383
+ "resource": window.resource,
384
+ "decision": window.decision,
385
+ "kind": window.kind,
386
+ }
387
+ applied = [f"{key}={value}" for key, value in named.items() if value is not None]
388
+ return ", ".join(applied) if applied else "none"
389
+
390
+
391
+ def _cell(value: object) -> str:
392
+ """Render one table cell, escaping the pipe that would otherwise split the row."""
393
+ return str(value).replace("|", "\\|")
394
+
395
+
396
+ def _counts(heading: str, column: str, counts: Mapping[str, int]) -> list[str]:
397
+ """Render one aggregate as its own section, saying so when it is empty."""
398
+ lines = [f"## {heading}", ""]
399
+ if not counts:
400
+ return [*lines, "None recorded in this window.", ""]
401
+ lines += [f"| {column} | Count |", "| --- | ---: |"]
402
+ lines += [f"| {_cell(key)} | {value} |" for key, value in counts.items()]
403
+ lines.append("")
404
+ return lines
405
+
406
+
407
+ def _mutations(report: AuditReport) -> list[str]:
408
+ """Render admin mutations as their own section.
409
+
410
+ Separate from the decisions, because a grant is not a permitted call, and a report that
411
+ showed one as an "allow" would understate both.
412
+ """
413
+ lines = ["## Mutations", ""]
414
+ counted = {
415
+ outcome: count
416
+ for outcome, count in report.by_outcome.items()
417
+ if outcome in ("granted", "revoked")
418
+ }
419
+ if not counted:
420
+ return [*lines, "No grants were made or withdrawn in this window.", ""]
421
+ lines += ["| Mutation | Count |", "| --- | ---: |"]
422
+ lines += [f"| {_cell(outcome)} | {count} |" for outcome, count in counted.items()]
423
+ lines.append("")
424
+ return lines
425
+
426
+
427
+ def _refusals(report: AuditReport) -> list[str]:
428
+ """Render the refusals, newest first, saying so when the list was capped."""
429
+ lines = ["## Refusals", ""]
430
+ if not report.refusals:
431
+ return [*lines, "No call was refused in this window.", ""]
432
+ if report.denied > len(report.refusals):
433
+ lines += [
434
+ f"Showing the {len(report.refusals)} most recent of {report.denied} refusals.",
435
+ "",
436
+ ]
437
+ lines += [
438
+ "| Time | Principal | Action | Outcome | Reason |",
439
+ "| --- | --- | --- | --- | --- |",
440
+ ]
441
+ lines += [
442
+ "| "
443
+ + " | ".join(
444
+ (
445
+ _cell(record.timestamp.isoformat()),
446
+ _cell(record.principal.uid),
447
+ _cell(record.action),
448
+ _cell(record.outcome),
449
+ _cell(record.reason or ""),
450
+ )
451
+ )
452
+ + " |"
453
+ for record in report.refusals
454
+ ]
455
+ lines.append("")
456
+ return lines
457
+
458
+
459
+ def _tamper_evidence(report: AuditReport) -> list[str]:
460
+ """Render what can be said about tampering, and be explicit about what cannot.
461
+
462
+ The per-record check is always meaningful. The chain check is only meaningful over a
463
+ contiguous run, so a filtered window says why it was skipped rather than reporting an
464
+ intact chain it did not verify.
465
+
466
+ An intact chain and a complete log are different claims, and this section must not let
467
+ the first be read as the second. The chain advances only on a write that landed, so
468
+ records written either side of a sink outage link to each other perfectly — which is why
469
+ a window whose records admit losses says so beside the verdict rather than under it.
470
+ """
471
+ lines = [
472
+ "## Tamper evidence",
473
+ "",
474
+ f"- Records whose own hash recomputes: {report.verified} of {report.total}.",
475
+ ]
476
+ verdict = report.chain
477
+ if verdict is None:
478
+ lines.append(
479
+ "- Chain: not verified. This window filters by identity, so its records are a "
480
+ "non-contiguous slice of the log rather than a run of it."
481
+ )
482
+ elif verdict.ok and report.records_lost:
483
+ lines.append(
484
+ "- Chain: intact across the records that were written — but this window is "
485
+ "**incomplete**, and the chain cannot show that."
486
+ )
487
+ elif verdict.ok:
488
+ lines.append("- Chain: intact across the window.")
489
+ else:
490
+ lines.append(f"- Chain: BROKEN at record {verdict.broken_at}. {verdict.reason}.")
491
+ if report.records_lost:
492
+ lines.append(
493
+ f"- Records lost: {report.records_lost}. The sink refused that many records, "
494
+ "which were built and masked and never stored. The decisions they described still "
495
+ "happened, and nothing here says what they were."
496
+ )
497
+ lines.append("")
498
+ return lines
499
+
500
+
501
+ # --- the self-governed admin surface ---------------------------------------------------------
502
+
503
+
504
+ @protect(action=GRANT_ACTION, resource=ADMIN_RESOURCE)
505
+ def _propose_grant(
506
+ *,
507
+ principal: str,
508
+ kind: str,
509
+ subject: str,
510
+ value: str | None = None,
511
+ action: str = WILDCARD_ACTION,
512
+ expires_at: datetime | None = None,
513
+ reason: str | None = None,
514
+ ) -> dict[str, object]:
515
+ """Ask the policy whether this grant may be made, and in what shape.
516
+
517
+ The body is deliberately a mirror: everything it returns came *back* through the
518
+ pipeline, so the caller builds its :class:`~sentrykit.stores.base.GrantSpec` from what
519
+ policy processed rather than from ``argv``. That is what makes the admin surface
520
+ argument-*filtered* and not merely gated — an operator entitled to grant ``oncall`` and
521
+ nothing else has their proposal narrowed here, by the same constraint machinery that
522
+ narrows any other protected call.
523
+
524
+ Args:
525
+ principal: Who is to receive the grant, as a Cedar uid or a bare id.
526
+ kind: ``role``, ``parent`` or ``attribute``.
527
+ subject: The role name, parent uid or attribute name.
528
+ value: The attribute value, for ``kind="attribute"``.
529
+ action: Which action the grant applies to; ``*`` for every action.
530
+ expires_at: When the grant stops applying.
531
+ reason: The justification recorded on the grant and in the audit trail.
532
+
533
+ Returns:
534
+ The same fields, as policy processed them.
535
+ """
536
+ return {
537
+ "principal": principal,
538
+ "kind": kind,
539
+ "subject": subject,
540
+ "value": value,
541
+ "action": action,
542
+ "expires_at": expires_at,
543
+ "reason": reason,
544
+ }
545
+
546
+
547
+ @protect(action=REVOKE_ACTION, resource=ADMIN_RESOURCE)
548
+ def _propose_revoke(*, grant_id: str, reason: str | None = None) -> dict[str, object]:
549
+ """Ask the policy whether this grant may be withdrawn.
550
+
551
+ Args:
552
+ grant_id: The grant to withdraw.
553
+ reason: Why.
554
+
555
+ Returns:
556
+ The same fields, as policy processed them.
557
+ """
558
+ return {"grant_id": grant_id, "reason": reason}
559
+
560
+
561
+ def _processed(proposal: Mapping[str, object]) -> dict[str, object]:
562
+ """Strip the ``_authz`` envelope the decorator attached to a mapping result.
563
+
564
+ The envelope is a report *about* the call; treating it as one of the call's fields would
565
+ put it into the grant spec.
566
+ """
567
+ return {name: value for name, value in proposal.items() if name != ENVELOPE_KEY}
568
+
569
+
570
+ # --- shared wiring -----------------------------------------------------------------------
571
+
572
+
573
+ def _resolve_sink_dsn(requested: str | None) -> str | None:
574
+ """Decide which audit sink to use: the flag, else ``SENTRYKIT_AUDIT_SINK``."""
575
+ return requested or get_settings().audit_sink
576
+
577
+
578
+ def _resolve_actor(store: GrantStore, requested: str | None) -> Principal:
579
+ """Resolve the operator this command acts as, refusing when there is none.
580
+
581
+ Fails closed twice over, because both halves are ways to end up acting as nobody: an
582
+ unnamed actor, and a named actor the grant store has never heard of. Neither is allowed
583
+ to fall back on a synthesized principal — an admin surface that invents its own caller
584
+ has no admin surface at all.
585
+
586
+ Args:
587
+ store: The grant store the actor is materialized from.
588
+ requested: Whatever ``--as`` carried, or ``None``.
589
+
590
+ Returns:
591
+ The materialized principal.
592
+
593
+ Raises:
594
+ ConfigurationError: If no actor was named, or the store holds nothing for them.
595
+ """
596
+ named = requested or os.environ.get(ACTOR_ENV) or ""
597
+ if not named.strip():
598
+ raise ConfigurationError(
599
+ f"no actor was named; pass --as 'User::\"you\"' or set {ACTOR_ENV}. "
600
+ "Refusing to administer grants as nobody."
601
+ )
602
+ actor = store.get_principal(named)
603
+ if actor is None:
604
+ raise ConfigurationError(
605
+ f"grant store {store.name!r} holds no principal {named!r}; refusing (fail-closed)"
606
+ )
607
+ return actor
608
+
609
+
610
+ def _report_refusal(exc: SentrykitError) -> int:
611
+ """Print a refusal the way an operator needs to read it, and fail the command.
612
+
613
+ The envelope carries the decision and the one-sentence reason; the exception carries the
614
+ typed detail. Printing both means a denial and an argument rejection do not read alike.
615
+
616
+ Args:
617
+ exc: The refusal raised by the protected callable.
618
+
619
+ Returns:
620
+ :data:`EXIT_FAILED`, so the caller can ``return`` this directly.
621
+ """
622
+ envelope = last_envelope()
623
+ print(f"sentrykit: refused: {exc}", file=sys.stderr)
624
+ if envelope is not None:
625
+ print(f" decision: {envelope.decision}", file=sys.stderr)
626
+ if envelope.reason:
627
+ print(f" reason: {envelope.reason}", file=sys.stderr)
628
+ if envelope.determining_policies:
629
+ print(
630
+ f" determining policies: {', '.join(envelope.determining_policies)}",
631
+ file=sys.stderr,
632
+ )
633
+ else:
634
+ # No determining policy means nothing permitted it, which is a default deny —
635
+ # and the overwhelmingly likely cause is that no policy for the admin actions
636
+ # has been written yet. The shipped pack deliberately contains none: a permit
637
+ # for the action that hands out authority is not something a library gets to
638
+ # ship on your behalf. Saying so here saves an operator staring at an empty
639
+ # list wondering which rule refused them.
640
+ print(
641
+ # ASCII only: this goes to a terminal, and a cp1252 console renders an
642
+ # em-dash as a replacement character. Every other line the CLI prints is
643
+ # already ASCII, and a test keeps it that way.
644
+ " no policy permitted this action. The shipped pack does not include one "
645
+ "for the admin actions, by design. Write a permit for "
646
+ f"{GRANT_ACTION} / {REVOKE_ACTION} on {ADMIN_RESOURCE} and point --policy "
647
+ "at it. See docs/policies.md, 'Administering grants'.",
648
+ file=sys.stderr,
649
+ )
650
+ print("The grant store was not touched.", file=sys.stderr)
651
+ return EXIT_FAILED
652
+
653
+
654
+ def _close(backend: object) -> None:
655
+ """Release a backend that holds a resource, and leave the rest alone."""
656
+ if isinstance(backend, SupportsClose):
657
+ backend.close()
658
+
659
+
660
+ def _emit(payload: Mapping[str, object], *, as_json: bool, lines: Sequence[str]) -> None:
661
+ """Print either the JSON document or the human lines, never both."""
662
+ if as_json:
663
+ print(json.dumps(dict(payload), indent=2, sort_keys=True, default=str))
664
+ return
665
+ for line in lines:
666
+ print(line)
667
+
668
+
669
+ def _parse_instant(value: str, flag: str) -> tuple[datetime, bool]:
670
+ """Read a bare date or a full ISO-8601 timestamp, as UTC.
671
+
672
+ Args:
673
+ value: What the operator typed.
674
+ flag: The flag it came from, for the error message.
675
+
676
+ Returns:
677
+ The instant, and whether it was written as a bare date — which is what decides
678
+ whether an exclusive upper bound has to be nudged to the following day.
679
+
680
+ Raises:
681
+ ConfigurationError: If the value is neither.
682
+ """
683
+ text = value.strip()
684
+ bare = bool(_DATE_ONLY.match(text))
685
+ try:
686
+ parsed = datetime.fromisoformat(text)
687
+ except ValueError as exc:
688
+ raise ConfigurationError(
689
+ f"{flag} is {value!r}; expected a date (2026-09-01) or an ISO-8601 timestamp"
690
+ ) from exc
691
+ # Naive means UTC, for the reason ``coerce_value`` gives: which records a report covers
692
+ # must not depend on the timezone of the host that happened to run it.
693
+ return (parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)), bare
694
+
695
+
696
+ # --- policy validate ---------------------------------------------------------------------
697
+
698
+
699
+ def _validate_policies(args: argparse.Namespace) -> int:
700
+ """Validate and lint a policy directory.
701
+
702
+ Args:
703
+ args: The parsed ``policy validate`` arguments.
704
+
705
+ Returns:
706
+ :data:`EXIT_FAILED` when validation failed, :data:`EXIT_OK` otherwise. Formatting
707
+ warnings never fail the command: reviewed policy text that is merely untidy still
708
+ decides correctly, and a gate that failed on it would be turned off.
709
+
710
+ Raises:
711
+ ConfigurationError: If the directory does not exist or matches no policy files.
712
+ """
713
+ requested: str | None = args.directory
714
+ pattern: str = args.pattern
715
+ requested_schema: str | None = args.schema
716
+ format_check: bool = args.format_check
717
+ as_json: bool = args.json
718
+ directory = Path(requested) if requested else pack_root()
719
+ if not directory.is_dir():
720
+ raise ConfigurationError(f"{str(directory)!r} is not a directory")
721
+
722
+ files = sorted(path for path in directory.glob(pattern) if path.is_file())
723
+ if not files:
724
+ raise ConfigurationError(f"no file in {str(directory)!r} matches {pattern!r}")
725
+
726
+ schema_path = _schema_for(directory, requested_schema)
727
+ combined = "\n\n".join(path.read_text(encoding="utf-8") for path in files)
728
+
729
+ # Always probed, even with --no-format-check: this is the only thing that can attribute
730
+ # a parse failure to a *file*, since validation sees one concatenated policy set.
731
+ unparseable, unformatted = _lint(files)
732
+
733
+ file_errors: list[str] = []
734
+ # Cedar's validator ignores unknown annotations entirely, so an argument ceiling declared
735
+ # in one is type-checked by nobody unless this command does it. Reported as a file-level
736
+ # error because the message names the policy itself.
737
+ ceiling_error = _ceiling_error(combined)
738
+ policy_errors: dict[str, list[str]] = {}
739
+ validated = schema_path is not None
740
+ passed = False
741
+ if schema_path is not None:
742
+ outcome = validate_policies(combined, schema_path.read_text(encoding="utf-8"))
743
+ passed = outcome.passed
744
+ for policy_id, messages in sorted(outcome.grouped().items()):
745
+ if policy_id == PARSE_SENTINEL:
746
+ file_errors.extend(messages)
747
+ else:
748
+ policy_errors[policy_id] = messages
749
+
750
+ lines = [
751
+ f"policy validate: {directory}",
752
+ f" policies: {len(files)} file(s) matching {pattern!r}",
753
+ f" schema: {schema_path.name if schema_path is not None else 'none found'}",
754
+ ]
755
+ lines += [f" error: the policy set does not parse: {message}" for message in file_errors]
756
+ if ceiling_error is not None:
757
+ lines.append(f" error: {ceiling_error}")
758
+ lines += [f" error: {name} does not parse" for name in unparseable]
759
+ lines += [
760
+ f" error: policy {policy_id!r}: {message}"
761
+ for policy_id, messages in sorted(policy_errors.items())
762
+ for message in messages
763
+ ]
764
+ if format_check:
765
+ lines += [
766
+ f" warning: {name} is not formatted as cedar would write it (not rewritten)"
767
+ for name in unformatted
768
+ ]
769
+
770
+ if ceiling_error is not None:
771
+ # An argument ceiling that will not load is a refusal at wiring time, so it fails the
772
+ # command whether or not a schema was there to type-check the rest.
773
+ lines.append(" validation: failed - an argument ceiling does not load")
774
+ exit_code = EXIT_FAILED
775
+ elif not validated:
776
+ lines.append(
777
+ f" validation: skipped - no {SCHEMA_GLOB} in the directory and no --schema; "
778
+ "nothing was type-checked"
779
+ )
780
+ exit_code = EXIT_OK
781
+ elif passed:
782
+ lines.append(" validation: passed")
783
+ exit_code = EXIT_OK
784
+ else:
785
+ counted = sum(len(messages) for messages in policy_errors.values())
786
+ parts: list[str] = []
787
+ if policy_errors:
788
+ parts.append(f"{counted} error(s) in {len(policy_errors)} policy/policies")
789
+ if file_errors:
790
+ parts.append(f"{len(file_errors)} file-level error(s)")
791
+ lines.append(f" validation: failed - {' and '.join(parts)}")
792
+ exit_code = EXIT_FAILED
793
+
794
+ _emit(
795
+ {
796
+ "directory": str(directory),
797
+ "pattern": pattern,
798
+ "files": [path.name for path in files],
799
+ "schema": schema_path.name if schema_path is not None else None,
800
+ "validated": validated,
801
+ "passed": passed if validated else None,
802
+ "file_errors": file_errors,
803
+ "ceiling_error": ceiling_error,
804
+ "unparseable": list(unparseable),
805
+ "policy_errors": policy_errors,
806
+ "unformatted": list(unformatted) if format_check else [],
807
+ "exit_code": exit_code,
808
+ },
809
+ as_json=as_json,
810
+ lines=lines,
811
+ )
812
+ return exit_code
813
+
814
+
815
+ def _ceiling_error(policies: str) -> str | None:
816
+ """Report the first unloadable ``@constraints`` ceiling in ``policies``, if any.
817
+
818
+ Building a bundle is what parses ceilings, and it is the same call a governor makes at
819
+ wiring — so this asks the question in exactly the form a deployment will.
820
+
821
+ Args:
822
+ policies: The concatenated policy text.
823
+
824
+ Returns:
825
+ The message, or ``None`` when every declared ceiling loads. A text that does not
826
+ parse at all returns ``None``: that is the schema validator's finding to report, and
827
+ saying it twice in different words helps nobody.
828
+ """
829
+ try:
830
+ PolicyBundle.from_text(policies)
831
+ except ConfigurationError as exc:
832
+ message = str(exc)
833
+ return message if "ceiling" in message else None
834
+ return None
835
+
836
+
837
+ def _schema_for(directory: Path, requested: str | None) -> Path | None:
838
+ """Find the schema a directory is validated against.
839
+
840
+ Args:
841
+ directory: The policy directory.
842
+ requested: An explicit ``--schema`` path, or ``None``.
843
+
844
+ Returns:
845
+ The schema file, or ``None`` when the directory ships none — which is a finding the
846
+ caller reports, not a pass.
847
+
848
+ Raises:
849
+ ConfigurationError: If an explicitly named schema is missing.
850
+ """
851
+ if requested:
852
+ path = Path(requested)
853
+ if not path.is_file():
854
+ raise ConfigurationError(f"--schema {requested!r} is not a file")
855
+ return path
856
+ found = sorted(path for path in directory.glob(SCHEMA_GLOB) if path.is_file())
857
+ return found[0] if found else None
858
+
859
+
860
+ def _lint(files: Sequence[Path]) -> tuple[tuple[str, ...], tuple[str, ...]]:
861
+ """Compare each file against Cedar's own formatting.
862
+
863
+ Args:
864
+ files: The policy files, in the order they were concatenated.
865
+
866
+ Returns:
867
+ The names of the files that do not parse, and of the ones that parse but are not
868
+ formatted the way Cedar would write them. Never rewrites anything: a validation
869
+ command that edited reviewed policy text would be editing the thing under review.
870
+ """
871
+ unparseable: list[str] = []
872
+ unformatted: list[str] = []
873
+ for path in files:
874
+ text = path.read_text(encoding="utf-8")
875
+ try:
876
+ formatted = format_policies(text)
877
+ except ConfigurationError:
878
+ unparseable.append(path.name)
879
+ continue
880
+ if formatted.strip() != text.strip():
881
+ unformatted.append(path.name)
882
+ return tuple(unparseable), tuple(unformatted)
883
+
884
+
885
+ # --- grant / revoke ----------------------------------------------------------------------
886
+
887
+
888
+ def _install_from(args: argparse.Namespace) -> tuple[Governor, GrantStore]:
889
+ """Wire the governor the admin commands are decided by, and hand back its store.
890
+
891
+ Wired with :func:`~sentrykit.engine.install` rather than by hand, so the admin surface
892
+ runs against exactly the substrate the application does — same policy union, same store,
893
+ same sink — and a policy that works in the service works here.
894
+
895
+ Args:
896
+ args: The parsed ``grant``/``revoke`` arguments.
897
+
898
+ Returns:
899
+ The installed governor and the grant store the mutation will be applied to.
900
+
901
+ Raises:
902
+ ConfigurationError: If a DSN names an unknown backend or the store cannot be built.
903
+ """
904
+ store_dsn: str = args.store
905
+ policy_dsn: str = args.policy
906
+ sink_dsn = _resolve_sink_dsn(args.sink)
907
+ governor = install(policy_source=policy_dsn, grant_store=store_dsn, audit_sink=sink_dsn)
908
+ store = governor.grants
909
+ if store is None: # pragma: no cover - build_grant_store raises before this can happen
910
+ raise ConfigurationError(f"--store {store_dsn!r} built no grant store")
911
+ if sink_dsn is None:
912
+ # Not fatal -- a developer against ``memory://`` has no sink and needs the command to
913
+ # work -- but never silent: an unrecorded mutation is the one an auditor cannot find.
914
+ print(
915
+ f"sentrykit: warning: no audit sink is configured (--sink or "
916
+ f"{ENV_PREFIX}AUDIT_SINK); this mutation will not be recorded.",
917
+ file=sys.stderr,
918
+ )
919
+ return governor, store
920
+
921
+
922
+ def _run_grant(args: argparse.Namespace) -> int:
923
+ """Confer a grant, governed by the same engine the application is.
924
+
925
+ Returns:
926
+ :data:`EXIT_OK` when the grant was made, :data:`EXIT_FAILED` when policy refused it.
927
+ """
928
+ principal: str = args.principal
929
+ action: str = args.action or WILDCARD_ACTION
930
+ expires: str | None = args.expires
931
+ reason: str | None = args.reason
932
+ actor_uid: str | None = args.actor
933
+ as_json: bool = args.json
934
+
935
+ kind, subject, value = _grant_target(args)
936
+ expires_at = _parse_instant(expires, "--expires")[0] if expires else None
937
+ governor, store = _install_from(args)
938
+ actor = _resolve_actor(store, actor_uid)
939
+
940
+ with establish(actor, channel=CHANNEL):
941
+ try:
942
+ proposal = _propose_grant(
943
+ principal=principal,
944
+ kind=kind,
945
+ subject=subject,
946
+ value=value,
947
+ action=action,
948
+ expires_at=expires_at,
949
+ reason=reason,
950
+ )
951
+ except SentrykitError as exc:
952
+ return _report_refusal(exc)
953
+
954
+ # Built from what came *back*, never from argv: an argument the policy narrowed or filled
955
+ # in has to be the one that lands in the store, or the filtering was theatre.
956
+ processed = _processed(proposal)
957
+ spec = GrantSpec(
958
+ principal=EntityRef.parse(str(processed["principal"]), default_type="User"),
959
+ kind=cast("GrantKind", processed["kind"]),
960
+ subject=str(processed["subject"]),
961
+ value=processed["value"],
962
+ action=str(processed["action"]),
963
+ granted_by=str(actor.uid),
964
+ expires_at=cast("datetime | None", processed["expires_at"]),
965
+ reason=cast("str | None", processed["reason"]),
966
+ )
967
+ record = store.grant(spec)
968
+ _record_mutation(governor, record, actor=actor, outcome="granted", reason=spec.reason)
969
+
970
+ rendered = record.as_record()
971
+ _emit(
972
+ {"outcome": "granted", "grant": rendered},
973
+ as_json=as_json,
974
+ lines=[
975
+ f"Granted {spec.kind} {spec.subject!r} to {spec.principal}.",
976
+ f" grant id: {record.id}",
977
+ f" expires: {rendered['expires_at'] or 'never'}",
978
+ f" reason: {rendered['reason'] or 'none given'}",
979
+ ],
980
+ )
981
+ return EXIT_OK
982
+
983
+
984
+ def _grant_target(args: argparse.Namespace) -> tuple[str, str, str | None]:
985
+ """Read the mutually exclusive ``--role``/``--parent``/``--attribute`` triple.
986
+
987
+ Args:
988
+ args: The parsed ``grant`` arguments.
989
+
990
+ Returns:
991
+ The grant kind, its subject, and the attribute value where there is one.
992
+
993
+ Raises:
994
+ ConfigurationError: If ``--attribute`` is not spelled ``key=value``.
995
+ """
996
+ role: str | None = args.role
997
+ parent: str | None = args.parent
998
+ attribute: str | None = args.attribute
999
+ if role:
1000
+ return "role", role, None
1001
+ if parent:
1002
+ return "parent", parent, None
1003
+ name, separator, value = (attribute or "").partition("=")
1004
+ if not separator or not name.strip():
1005
+ raise ConfigurationError(f"--attribute {attribute!r} is not spelled key=value")
1006
+ return "attribute", name.strip(), value
1007
+
1008
+
1009
+ def _run_revoke(args: argparse.Namespace) -> int:
1010
+ """Withdraw a grant, governed and logged exactly as conferring one is.
1011
+
1012
+ Returns:
1013
+ :data:`EXIT_OK` when the grant was withdrawn, :data:`EXIT_FAILED` when policy refused.
1014
+ """
1015
+ requested_id: str = args.grant_id
1016
+ requested_reason: str | None = args.reason
1017
+ actor_uid: str | None = args.actor
1018
+ as_json: bool = args.json
1019
+
1020
+ governor, store = _install_from(args)
1021
+ actor = _resolve_actor(store, actor_uid)
1022
+
1023
+ with establish(actor, channel=CHANNEL):
1024
+ try:
1025
+ proposal = _propose_revoke(grant_id=requested_id, reason=requested_reason)
1026
+ except SentrykitError as exc:
1027
+ return _report_refusal(exc)
1028
+
1029
+ processed = _processed(proposal)
1030
+ grant_id = str(processed["grant_id"])
1031
+ reason = cast("str | None", processed["reason"])
1032
+ record = store.revoke(grant_id, revoked_by=str(actor.uid), reason=reason)
1033
+ _record_mutation(governor, record, actor=actor, outcome="revoked", reason=reason)
1034
+
1035
+ rendered = record.as_record()
1036
+ _emit(
1037
+ {"outcome": "revoked", "grant": rendered},
1038
+ as_json=as_json,
1039
+ lines=[
1040
+ f"Revoked {record.spec.kind} {record.spec.subject!r} from {record.spec.principal}.",
1041
+ f" grant id: {record.id}",
1042
+ f" revoked: {rendered['revoked_at']}",
1043
+ f" reason: {rendered['revoked_reason'] or 'none given'}",
1044
+ ],
1045
+ )
1046
+ return EXIT_OK
1047
+
1048
+
1049
+ def _record_mutation(
1050
+ governor: Governor,
1051
+ record: GrantRecord,
1052
+ *,
1053
+ actor: Principal,
1054
+ outcome: Literal["granted", "revoked"],
1055
+ reason: str | None,
1056
+ ) -> None:
1057
+ """Write the mutation half of the trail — the second of the two records.
1058
+
1059
+ The decision that permitted the mutation was recorded by the governor when
1060
+ :func:`_propose_grant` ran; this records that the change actually happened. Both, or the
1061
+ log answers "was it allowed" without answering "did it occur".
1062
+
1063
+ Args:
1064
+ governor: The governor whose trail the record goes to.
1065
+ record: The stored grant.
1066
+ actor: Who made the change.
1067
+ outcome: ``granted`` or ``revoked``.
1068
+ reason: The justification.
1069
+ """
1070
+ trail = governor.audit
1071
+ if trail is None: # pragma: no cover - install always wires a trail
1072
+ return
1073
+ trail.record_mutation(record.as_record(), actor=actor, outcome=outcome, reason=reason)
1074
+
1075
+
1076
+ # --- grants ------------------------------------------------------------------------------
1077
+
1078
+
1079
+ def _run_grants(args: argparse.Namespace) -> int:
1080
+ """List a principal's grants.
1081
+
1082
+ Read-only, and therefore *not* governed: there is nothing to authorize when nothing
1083
+ changes. It exists because :func:`_run_revoke` takes an id, and an operator who has lost
1084
+ one would otherwise have to grep the audit log for it.
1085
+
1086
+ Returns:
1087
+ :data:`EXIT_OK`.
1088
+
1089
+ Raises:
1090
+ ConfigurationError: If the store DSN names an unknown backend.
1091
+ """
1092
+ store_dsn: str = args.store
1093
+ principal: str = args.principal
1094
+ include_inactive: bool = args.include_inactive
1095
+ as_json: bool = args.json
1096
+ store = build_grant_store(store_dsn)
1097
+ if store is None: # pragma: no cover - build_grant_store raises before this can happen
1098
+ raise ConfigurationError(f"--store {store_dsn!r} built no grant store")
1099
+ try:
1100
+ held = store.grants_for(principal, include_inactive=include_inactive)
1101
+ # Everything printed comes off ``as_record``, the same rendering the audit trail
1102
+ # writes, so this listing cannot expose anything a grant record does not already.
1103
+ rendered = [record.as_record() for record in held]
1104
+ finally:
1105
+ _close(store)
1106
+
1107
+ lines = [f"{principal} holds {len(rendered)} grant(s)."]
1108
+ lines += [
1109
+ " " + " ".join(f"{column}={entry.get(column) or '-'}" for column in _GRANT_COLUMNS)
1110
+ for entry in rendered
1111
+ ]
1112
+ _emit({"principal": principal, "grants": rendered}, as_json=as_json, lines=lines)
1113
+ return EXIT_OK
1114
+
1115
+
1116
+ # --- audit-report ------------------------------------------------------------------------
1117
+
1118
+
1119
+ def _run_audit_report(args: argparse.Namespace) -> int:
1120
+ """Aggregate a window of the audit log into a markdown document.
1121
+
1122
+ Returns:
1123
+ :data:`EXIT_OK`.
1124
+
1125
+ Raises:
1126
+ ConfigurationError: If no sink is configured, or a bound will not parse.
1127
+ """
1128
+ principal: str | None = args.principal
1129
+ action: str | None = args.action
1130
+ top: int = args.top
1131
+ title: str | None = args.title
1132
+ out: str | None = args.out
1133
+
1134
+ start, _ = _parse_instant(args.start, "--from")
1135
+ end, bare = _parse_instant(args.end, "--to")
1136
+ if bare:
1137
+ # ``AuditWindow`` bounds are half-open, and an operator writing ``--to 2026-12-31``
1138
+ # means "including that day". A timestamp is used exactly, because someone who wrote
1139
+ # one meant it.
1140
+ end += timedelta(days=1)
1141
+
1142
+ window = AuditWindow(start=start, end=end, principal=principal, action=action)
1143
+ sink_dsn = _resolve_sink_dsn(args.sink)
1144
+ if sink_dsn is None:
1145
+ raise ConfigurationError(
1146
+ f"no audit sink is configured; pass --sink or set {ENV_PREFIX}AUDIT_SINK"
1147
+ )
1148
+ sink = build_audit_sink(sink_dsn)
1149
+ if sink is None: # pragma: no cover - build_audit_sink raises before this can happen
1150
+ raise ConfigurationError(f"--sink {sink_dsn!r} built no audit sink")
1151
+ try:
1152
+ report = build_report(
1153
+ sink.query(window),
1154
+ window=window,
1155
+ sink=sink.name,
1156
+ generated_at=datetime.now(UTC),
1157
+ top=top,
1158
+ )
1159
+ finally:
1160
+ _close(sink)
1161
+
1162
+ # The heading echoes what the operator typed, not the half-open bound the window
1163
+ # carries: someone who asked for "--to 2026-12-31" should not read a title saying
1164
+ # 2027-01-01 and wonder whether they got a day too many. The header line below the
1165
+ # title still states the exact bounds, marked exclusive.
1166
+ document = _document(report, title or f"Audit report: {args.start} to {args.end}")
1167
+ if out is None:
1168
+ print(document, end="")
1169
+ return EXIT_OK
1170
+ destination = Path(out)
1171
+ destination.parent.mkdir(parents=True, exist_ok=True)
1172
+ destination.write_text(document, encoding="utf-8")
1173
+ print(f"Wrote {report.total} record(s) to {destination}.")
1174
+ return EXIT_OK
1175
+
1176
+
1177
+ # --- parser ------------------------------------------------------------------------------
1178
+
1179
+
1180
+ def build_parser() -> argparse.ArgumentParser:
1181
+ """Build the argument parser for every command.
1182
+
1183
+ Separated from :func:`main` so a test can assert the surface — flags, defaults, help —
1184
+ without running anything.
1185
+
1186
+ Returns:
1187
+ The parser. Sub-commands are dispatched through the ``command`` destination.
1188
+ """
1189
+ parser = argparse.ArgumentParser(
1190
+ prog="sentrykit",
1191
+ description="Validate policies, administer grants, and report on the audit log.",
1192
+ )
1193
+ parser.add_argument("--version", action="version", version=f"sentrykit {__version__}")
1194
+ commands = parser.add_subparsers(dest="command", metavar="<command>", required=True)
1195
+
1196
+ policy = commands.add_parser("policy", help="Inspect a policy directory.")
1197
+ policy_commands = policy.add_subparsers(
1198
+ dest="policy_command", metavar="<subcommand>", required=True
1199
+ )
1200
+ validate = policy_commands.add_parser(
1201
+ "validate",
1202
+ help="Type-check and lint a policy directory.",
1203
+ description=(
1204
+ "Type-checks the directory's policies against its Cedar schema and reports "
1205
+ "files that differ from cedar's own formatting. Formatting is a warning, never a "
1206
+ "failure, and no file is ever rewritten. With no directory, validates the "
1207
+ "shipped policy pack."
1208
+ ),
1209
+ )
1210
+ validate.add_argument(
1211
+ "directory", nargs="?", default=None, help="Policy directory (default: the shipped pack)."
1212
+ )
1213
+ validate.add_argument(
1214
+ "--schema", default=None, help=f"Schema file (default: the {SCHEMA_GLOB})."
1215
+ )
1216
+ validate.add_argument(
1217
+ "--pattern",
1218
+ default=DEFAULT_POLICY_GLOB,
1219
+ help="Glob for policy files (default: %(default)s).",
1220
+ )
1221
+ validate.add_argument(
1222
+ "--no-format-check",
1223
+ dest="format_check",
1224
+ action="store_false",
1225
+ help="Skip the formatting comparison.",
1226
+ )
1227
+ validate.add_argument("--json", action="store_true", help="Emit the findings as JSON.")
1228
+
1229
+ grant = commands.add_parser(
1230
+ "grant",
1231
+ help="Confer a grant. Governed by the policy set and written to the audit log.",
1232
+ description=(
1233
+ "Confers a grant. The mutation is itself authorized against "
1234
+ f"{GRANT_ACTION} on {ADMIN_RESOURCE}, so a policy decides both who may "
1235
+ "administer grants and which grants they may make; the proposed role, subject "
1236
+ "and expiry are ordinary arguments and are filtered like any others."
1237
+ ),
1238
+ )
1239
+ grant.add_argument("principal", help="Who receives the grant, e.g. 'User::\"ana\"'.")
1240
+ target = grant.add_mutually_exclusive_group(required=True)
1241
+ target.add_argument("--role", default=None, help="Grant a role.")
1242
+ target.add_argument("--parent", default=None, help="Grant a parent edge, e.g. 'Team::\"eu\"'.")
1243
+ target.add_argument("--attribute", default=None, metavar="K=V", help="Grant an attribute.")
1244
+ grant.add_argument(
1245
+ "--action",
1246
+ default=WILDCARD_ACTION,
1247
+ help="Action the grant applies to (default: %(default)s, meaning every action).",
1248
+ )
1249
+ grant.add_argument(
1250
+ "--expires",
1251
+ default=None,
1252
+ help="When the grant stops applying: a date (2026-09-01, read as 00:00 UTC) or an "
1253
+ "ISO-8601 timestamp. A naive value is read as UTC.",
1254
+ )
1255
+ grant.add_argument("--reason", default=None, help="Why the grant is being made.")
1256
+ _add_admin_arguments(grant)
1257
+
1258
+ revoke = commands.add_parser(
1259
+ "revoke",
1260
+ help="Withdraw a grant. Governed and logged exactly as conferring one is.",
1261
+ description=(
1262
+ f"Withdraws a grant. Authorized against {REVOKE_ACTION} on {ADMIN_RESOURCE} and "
1263
+ "recorded as a mutation in the audit log. Revocation marks the grant rather than "
1264
+ "deleting it, so 'who had what, when' survives."
1265
+ ),
1266
+ )
1267
+ revoke.add_argument("grant_id", metavar="GRANT_ID", help="The id `sentrykit grants` lists.")
1268
+ revoke.add_argument("--reason", default=None, help="Why the grant is being withdrawn.")
1269
+ _add_admin_arguments(revoke)
1270
+
1271
+ grants = commands.add_parser(
1272
+ "grants",
1273
+ help="List a principal's grants.",
1274
+ description=(
1275
+ "Lists the grants a principal holds. Read-only, so it is not governed - it "
1276
+ "exists because `revoke` takes an id, and every column comes off the grant "
1277
+ "record's own audit rendering."
1278
+ ),
1279
+ )
1280
+ grants.add_argument("principal", help="Whose grants to list, e.g. 'User::\"ana\"'.")
1281
+ grants.add_argument("--store", required=True, metavar="DSN", help="Grant store DSN.")
1282
+ grants.add_argument(
1283
+ "--include-inactive", action="store_true", help="Include revoked and expired grants."
1284
+ )
1285
+ grants.add_argument("--json", action="store_true", help="Emit the listing as JSON.")
1286
+
1287
+ report = commands.add_parser(
1288
+ "audit-report",
1289
+ help="Aggregate a window of the audit log into markdown.",
1290
+ description=(
1291
+ "Queries the audit sink for a period and aggregates it by outcome, action, "
1292
+ "principal, determining policy and obligation. --to is INCLUSIVE of the day it "
1293
+ "names: the window's upper bound is exclusive, so a bare date has one day added "
1294
+ "to it. A value carrying a time is used exactly."
1295
+ ),
1296
+ )
1297
+ report.add_argument(
1298
+ "--from",
1299
+ dest="start",
1300
+ required=True,
1301
+ metavar="DATE",
1302
+ help="Start of the window, inclusive: a date or an ISO-8601 timestamp.",
1303
+ )
1304
+ report.add_argument(
1305
+ "--to",
1306
+ dest="end",
1307
+ required=True,
1308
+ metavar="DATE",
1309
+ help="End of the window. A bare date INCLUDES that whole day; a timestamp is exact.",
1310
+ )
1311
+ report.add_argument("--out", default=None, metavar="PATH", help="Write here (default: stdout).")
1312
+ report.add_argument(
1313
+ "--sink",
1314
+ default=None,
1315
+ metavar="DSN",
1316
+ help="Audit sink DSN (default: SENTRYKIT_AUDIT_SINK).",
1317
+ )
1318
+ report.add_argument("--principal", default=None, help="Only this principal's records.")
1319
+ report.add_argument("--action", default=None, help="Only this action's records.")
1320
+ report.add_argument(
1321
+ "--top", type=int, default=20, help="How many refusals to list (default: %(default)s)."
1322
+ )
1323
+ report.add_argument("--title", default=None, help="Document title.")
1324
+ return parser
1325
+
1326
+
1327
+ def _add_admin_arguments(parser: argparse.ArgumentParser) -> None:
1328
+ """Add the wiring flags both admin commands share.
1329
+
1330
+ Shared rather than repeated because the two commands are the same operation over
1331
+ different verbs, and a ``--store`` that meant something subtly different on one of them
1332
+ would be a trap.
1333
+ """
1334
+ parser.add_argument(
1335
+ "--as",
1336
+ dest="actor",
1337
+ default=None,
1338
+ metavar="ACTOR",
1339
+ help=f"Who is acting (default: ${ACTOR_ENV}). Refused when neither is set.",
1340
+ )
1341
+ parser.add_argument("--store", required=True, metavar="DSN", help="Grant store DSN.")
1342
+ # Required, with no default. The obvious default is the shipped pack, and the shipped
1343
+ # pack deliberately contains no permit for the admin actions -- so defaulting to it makes
1344
+ # every mutation refuse, and the operator reads a refusal about their entitlement when
1345
+ # the truth is that they never named a policy source. A usage error says what is actually
1346
+ # missing, at the only moment it is cheap to fix.
1347
+ parser.add_argument(
1348
+ "--policy",
1349
+ required=True,
1350
+ metavar="DSN",
1351
+ help=(
1352
+ "Policy source deciding the mutation, e.g. file://./policies. The shipped "
1353
+ f"pack:// contains no permit for {GRANT_ACTION} / {REVOKE_ACTION}; write one "
1354
+ "and name it here."
1355
+ ),
1356
+ )
1357
+ parser.add_argument(
1358
+ "--sink",
1359
+ default=None,
1360
+ metavar="DSN",
1361
+ help="Audit sink DSN (default: SENTRYKIT_AUDIT_SINK).",
1362
+ )
1363
+ parser.add_argument("--json", action="store_true", help="Emit the result as JSON.")
1364
+
1365
+
1366
+ def main(argv: Sequence[str] | None = None) -> int:
1367
+ """Run the CLI.
1368
+
1369
+ Args:
1370
+ argv: The arguments, defaulting to :data:`sys.argv` ``[1:]``.
1371
+
1372
+ Returns:
1373
+ :data:`EXIT_OK` when everything asked for succeeded, :data:`EXIT_FAILED` when the
1374
+ command ran and reported a failure. A usage error exits ``2`` through argparse, and a
1375
+ :class:`~sentrykit.errors.ConfigurationError` is reported as a message on stderr
1376
+ rather than as a traceback — an operator mistyping a DSN has made a mistake, not
1377
+ found a bug.
1378
+ """
1379
+ args = build_parser().parse_args(argv)
1380
+ command: str = args.command
1381
+ try:
1382
+ if command == "policy":
1383
+ subcommand: str = args.policy_command
1384
+ if subcommand == "validate":
1385
+ return _validate_policies(args)
1386
+ if command == "grant":
1387
+ return _run_grant(args)
1388
+ if command == "revoke":
1389
+ return _run_revoke(args)
1390
+ if command == "grants":
1391
+ return _run_grants(args)
1392
+ if command == "audit-report":
1393
+ return _run_audit_report(args)
1394
+ except SentrykitError as exc:
1395
+ # Every failure an operator can cause -- a mistyped DSN, an unknown grant id, a sink
1396
+ # that will not open -- arrives here as a typed error and leaves as one line on
1397
+ # stderr. A traceback would suggest they had found a bug.
1398
+ print(f"sentrykit: {exc}", file=sys.stderr)
1399
+ return EXIT_FAILED
1400
+ # Unreachable: argparse rejects an unknown command before dispatch.
1401
+ raise ConfigurationError(f"unknown command {command!r}") # pragma: no cover
1402
+
1403
+
1404
+ if __name__ == "__main__": # pragma: no cover - exercised as a subprocess
1405
+ raise SystemExit(main())