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.
- sentrykit/__init__.py +200 -0
- sentrykit/audit.py +967 -0
- sentrykit/cedar.py +750 -0
- sentrykit/cli.py +1405 -0
- sentrykit/config.py +300 -0
- sentrykit/constraints.py +928 -0
- sentrykit/context.py +393 -0
- sentrykit/decorator.py +252 -0
- sentrykit/engine.py +1509 -0
- sentrykit/envelope.py +513 -0
- sentrykit/errors.py +252 -0
- sentrykit/integrations/__init__.py +1 -0
- sentrykit/integrations/base.py +767 -0
- sentrykit/integrations/fastapi.py +361 -0
- sentrykit/integrations/flask.py +342 -0
- sentrykit/integrations/mcp.py +929 -0
- sentrykit/integrations/pydantic_ai.py +728 -0
- sentrykit/integrations/tools.py +479 -0
- sentrykit/masking.py +459 -0
- sentrykit/policy/__init__.py +40 -0
- sentrykit/policy/loader.py +764 -0
- sentrykit/policy/pack/10-rbac.cedar +9 -0
- sentrykit/policy/pack/20-groups.cedar +9 -0
- sentrykit/policy/pack/30-ownership.cedar +8 -0
- sentrykit/policy/pack/40-delegation.cedar +40 -0
- sentrykit/policy/pack/50-expiry.cedar +24 -0
- sentrykit/policy/pack/60-break-glass.cedar +34 -0
- sentrykit/policy/pack/70-separation-of-duties.cedar +22 -0
- sentrykit/policy/pack/80-jit-elevation.cedar +32 -0
- sentrykit/policy/pack/entities.json +3 -0
- sentrykit/policy/pack/pack.cedarschema +45 -0
- sentrykit/policy/pack/templates/time-boxed-grant.cedartemplate +5 -0
- sentrykit/py.typed +0 -0
- sentrykit/registry.py +666 -0
- sentrykit/stores/__init__.py +116 -0
- sentrykit/stores/base.py +870 -0
- sentrykit/stores/composite.py +635 -0
- sentrykit/stores/filesystem.py +400 -0
- sentrykit/stores/jsonfile.py +701 -0
- sentrykit/stores/jsonl.py +357 -0
- sentrykit/stores/memory.py +500 -0
- sentrykit-0.1.0rc1.dist-info/METADATA +541 -0
- sentrykit-0.1.0rc1.dist-info/RECORD +46 -0
- sentrykit-0.1.0rc1.dist-info/WHEEL +4 -0
- sentrykit-0.1.0rc1.dist-info/entry_points.txt +20 -0
- 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())
|