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/audit.py ADDED
@@ -0,0 +1,967 @@
1
+ """The audit record, the hash chain, and the one path from a decision to a sink.
2
+
3
+ An audit log has to survive scrutiny, which means four properties this module owns:
4
+
5
+ * **Reproducible.** The record pins ``policy_set_hash`` and snapshots the principal's
6
+ *effective* attributes at decision time, so a replay is possible even after the policy set
7
+ and the grants have both moved on.
8
+ * **Attributable.** Per-policy provenance names which source and which artifact produced each
9
+ determining policy — not just its id.
10
+ * **Tamper-evident.** Each record carries the previous record's hash, so altering any record
11
+ invalidates every record after it. Detection, not prevention; prevention is the sink's job
12
+ (S3 Object Lock, WORM).
13
+ * **Safe to keep forever.** A *decision's* sensitive values are masked before the write and
14
+ never after: its arguments, the principal's attributes, and the allow-sets, deny-sets and
15
+ patterns of the constraints that governed it. A grant mutation is the exception, and is
16
+ written as it stands — nothing on that path declares which of its fields are sensitive, so
17
+ there is no plan to mask by, and an attribute grant's value reaches the sink in the clear.
18
+
19
+ Masking is structural rather than conventional: :class:`AuditTrail` is the only thing in
20
+ sentrykit that calls :meth:`~sentrykit.stores.base.AuditSink.write`, and it masks on the way
21
+ in. A unit test asserts that statically — the audit-side analogue of the import scan that
22
+ keeps web frameworks out of the core — so a future caller that reaches around it fails the
23
+ suite rather than the compliance review.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import json
30
+ import logging
31
+ import threading
32
+ from collections.abc import Iterable, Mapping, Sequence
33
+ from dataclasses import dataclass, field
34
+ from datetime import UTC, datetime
35
+ from typing import Final, Literal, NamedTuple, TypeGuard
36
+ from uuid import uuid4
37
+
38
+ from pydantic import BaseModel, ConfigDict, Field
39
+
40
+ from sentrykit.cedar import CedarDecision
41
+ from sentrykit.config import get_settings
42
+ from sentrykit.constraints import RejectedArgument
43
+ from sentrykit.context import Invocation, Principal
44
+ from sentrykit.errors import AuditUnavailable
45
+ from sentrykit.masking import Masker, MaskStrategyName
46
+ from sentrykit.stores.base import AuditSink, AuditWindow, PolicyBundle
47
+
48
+ __all__ = [
49
+ "GENESIS_HASH",
50
+ "AuditKind",
51
+ "AuditOutcome",
52
+ "AuditTrail",
53
+ "AuthzAuditRecord",
54
+ "ChainVerdict",
55
+ "DecisionFacts",
56
+ "PrincipalSnapshot",
57
+ "canonical_json",
58
+ "verify_chain",
59
+ ]
60
+
61
+ _LOGGER: Final = logging.getLogger("sentrykit.audit")
62
+ """The *operational* logger. Audit records never travel through it — see the logging
63
+ standards' "two channels, never conflated". It reports on the trail, not from it."""
64
+
65
+ GENESIS_HASH: Final = "0" * 64
66
+ """The ``prev_hash`` of the first record in a chain. Fixed width, so a chain is scannable."""
67
+
68
+ AuditKind = Literal["decision", "mutation"]
69
+ """Whether a record describes an authorization decision or an admin grant mutation."""
70
+
71
+ AuditOutcome = Literal[
72
+ "allowed",
73
+ "denied",
74
+ "engine-error",
75
+ "arguments-rejected",
76
+ "no-valid-arguments",
77
+ "default-unavailable",
78
+ "signature-default-rejected",
79
+ "validation-failed",
80
+ "granted",
81
+ "revoked",
82
+ ]
83
+ """What actually happened.
84
+
85
+ Finer-grained than ``decision`` on purpose. "Cedar permitted the call but every argument the
86
+ caller proposed was refused" and "no policy permitted the call" are both denials to the
87
+ caller and completely different events to an auditor.
88
+ """
89
+
90
+ _MUTATION_ACTIONS: Final[dict[str, str]] = {
91
+ "granted": 'Action::"grant"',
92
+ "revoked": 'Action::"revoke"',
93
+ }
94
+ """The Cedar action a grant mutation is recorded under.
95
+
96
+ Spelled as a uid so an :class:`~sentrykit.stores.base.AuditWindow` filtering on ``action``
97
+ treats a mutation exactly like a decision — the admin surface is governed by the same engine
98
+ and belongs in the same query (architecture §5).
99
+ """
100
+
101
+ _VALUE_BEARING_RULES: Final[frozenset[str]] = frozenset({"allow", "deny", "pattern"})
102
+ """The rules of a described constraint that hold values rather than describe a shape.
103
+
104
+ An allow-set enumerates the very values the constraint exists to fence off — a permitted
105
+ peer's tenant id is as sensitive as the caller's own — and a per-principal ``pattern`` can
106
+ embed one as a literal, so both are masked before the write.
107
+
108
+ ``minimum`` and ``maximum`` are deliberately absent: a bound is a rule, not a value, and an
109
+ auditor has to be able to read what the principal was permitted to pass as well as what they
110
+ tried to. So are ``policy``, ``required`` and ``has_default``, which carry no caller data in
111
+ the first place.
112
+ """
113
+
114
+
115
+ def canonical_json(payload: Mapping[str, object]) -> bytes:
116
+ """Render a payload to the exact bytes the chain hashes.
117
+
118
+ Determinism is the whole requirement: sorted keys, no insignificant whitespace, UTF-8,
119
+ and no dependence on dict insertion order or platform. Anything unrenderable falls back
120
+ to its ``repr`` rather than raising — an audit record must not be able to fail the
121
+ decision it describes.
122
+
123
+ Args:
124
+ payload: The record payload, already JSON-normalized.
125
+
126
+ Returns:
127
+ The canonical encoding.
128
+ """
129
+ return json.dumps(
130
+ dict(payload),
131
+ sort_keys=True,
132
+ separators=(",", ":"),
133
+ ensure_ascii=False,
134
+ default=repr,
135
+ ).encode("utf-8")
136
+
137
+
138
+ class PrincipalSnapshot(BaseModel):
139
+ """The acting principal exactly as the decision saw them.
140
+
141
+ A snapshot, not a reference. Grants change, roles are revoked, attributes are corrected —
142
+ and a record that merely *pointed* at a principal would silently start describing a
143
+ different one. This is what makes a decision replayable long after the fact.
144
+
145
+ Attributes:
146
+ uid: The principal's Cedar uid.
147
+ type: Entity type.
148
+ id: Entity id.
149
+ roles: Role names, expanded to ``Role::"..."`` edges at decision time.
150
+ parents: Every other parent edge, as uids — the teams, projects and delegation edges
151
+ a multi-hop relationship was resolved through.
152
+ attributes: Effective Cedar attributes, masked.
153
+ constraints: The argument constraints in force, by action, as
154
+ :meth:`~sentrykit.constraints.ConstraintSet.describe` renders them — which
155
+ withholds the default *value* but emits every other rule as written.
156
+ :meth:`of` therefore does not mask them; :class:`AuditTrail` does, masking the
157
+ rules that carry values — the allow-set, the deny-set and the pattern — under
158
+ the name of the argument they govern. ``minimum`` and ``maximum`` reach the sink
159
+ as written, because a bound is a rule rather than a value.
160
+ """
161
+
162
+ model_config = ConfigDict(frozen=True, extra="forbid")
163
+
164
+ uid: str
165
+ type: str
166
+ id: str
167
+ roles: tuple[str, ...] = ()
168
+ parents: tuple[str, ...] = ()
169
+ attributes: dict[str, object] = Field(default_factory=dict)
170
+ constraints: dict[str, object] = Field(default_factory=dict)
171
+
172
+ @classmethod
173
+ def of(cls, principal: Principal) -> PrincipalSnapshot:
174
+ """Snapshot a principal. Does **not** mask — :class:`AuditTrail` does that.
175
+
176
+ That includes the constraints. They arrive *described* rather than dumped, which
177
+ withholds the default value, but an allow-set, a deny-set and a pattern still hold
178
+ the very values the constraint exists to fence off. :class:`AuditTrail` masks those
179
+ three on the way to the sink and leaves the bounds as written — so a caller using
180
+ this snapshot for anything else is holding raw values and has to treat them as such.
181
+ """
182
+ return cls(
183
+ uid=str(principal.uid),
184
+ type=principal.type,
185
+ id=principal.id,
186
+ roles=principal.roles,
187
+ parents=tuple(str(parent) for parent in principal.parents),
188
+ attributes=dict(principal.attributes),
189
+ constraints={
190
+ action: constraints.describe()
191
+ for action, constraints in principal.constraints.items()
192
+ },
193
+ )
194
+
195
+
196
+ class AuthzAuditRecord(BaseModel):
197
+ """One decision, or one admin mutation, as it is written to a sink.
198
+
199
+ Every field is either something an auditor asks for or something a replay needs. The
200
+ record is frozen: chaining produces a new record rather than mutating this one, so a
201
+ record that has been written can never be edited in place by construction.
202
+
203
+ One field is there to contradict the others. The chain advances solely on a successful
204
+ write, so the records either side of a sink outage link perfectly and :func:`verify_chain`
205
+ calls the run intact — which is true, nothing was altered, and is also exactly what a
206
+ caller probing the policy boundary while the sink was down would want it to say. So a
207
+ contiguous chain across a record does **not** mean nothing happened before it;
208
+ :attr:`dropped_since_last_write` says how much did. It is inside :attr:`record_hash`
209
+ whenever it is set, which puts the admission on the channel that counts as evidence rather
210
+ than in an operational log nobody can prove anything from afterwards.
211
+
212
+ Attributes:
213
+ record_id: Unique id for this record.
214
+ sequence: Position in the chain, starting at 0.
215
+ timestamp: When the decision was made.
216
+ invocation_id: Correlation id, shared with the Cedar result, the ``_authz`` envelope
217
+ and the operational logs.
218
+ kind: ``decision`` or ``mutation``.
219
+ principal: The effective-principal snapshot.
220
+ action: The action uid.
221
+ resource: The resource uid.
222
+ callable_name: Qualified name of the protected callable.
223
+ channel: ``http``, ``agent`` or ``internal``.
224
+ environment: The Cedar request context, masked. The proposed arguments are stripped
225
+ from it — they are carried in :attr:`proposed`, and duplicating them would mean
226
+ masking the same values twice, in two shapes, with two chances to miss one.
227
+ decision: ``allow`` or ``deny``, as the caller experienced it.
228
+ outcome: The finer-grained reason, see :data:`AuditOutcome`.
229
+ reason: One sentence, for a human reading the log.
230
+ proposed: What the caller asked for, masked. Untrusted input, recorded as evidence.
231
+ processed: The final argument set the policy decided and sentrykit passed,
232
+ masked. A parameter it left to the callable's own default is absent here and
233
+ named in :attr:`signature_defaults` instead.
234
+ rejected: Arguments the policy refused, with reasons and masked values.
235
+ defaults_applied: Arguments filled in from the principal's grants, masked.
236
+ signature_defaults: Governed parameters whose value came from the callable's own
237
+ signature rather than from the caller or from policy — names only, never values,
238
+ because the value is the application's own literal and the name is what an
239
+ auditor needs. :attr:`processed` does not carry these, by design: it is the set
240
+ sentrykit decided, and blurring the two would cost the distinction
241
+ :attr:`defaults_applied` exists to make. A non-empty tuple means the policy
242
+ checked that value and accepted it; a call where it was *refused* is a refusal,
243
+ with its own outcome.
244
+ obligations: Obligations the determining policies attached.
245
+ determining_policies: The policies that decided it.
246
+ provenance: Where each determining policy came from.
247
+ policy_set_hash: Digest of the policy logic evaluated, so the decision replays.
248
+ engine_errors: Cedar errors, when the engine failed rather than decided.
249
+ masked: Which fields were masked and with which strategy. Without this an auditor
250
+ cannot tell an ``hmac:...`` string from a value that genuinely looked like one.
251
+ grant: The grant record, for ``kind="mutation"``.
252
+ dropped_since_last_write: How many records were built, masked and then refused by the
253
+ sink between the previous record that landed and this one — the only thing that
254
+ tells a quiet log from a log with a hole in it. Zero on all but a vanishing
255
+ number of records, and covered by :attr:`record_hash` whenever it is not.
256
+ prev_hash: The previous record's :attr:`record_hash`, or :data:`GENESIS_HASH`.
257
+ record_hash: This record's digest, over everything above.
258
+ """
259
+
260
+ model_config = ConfigDict(frozen=True, extra="forbid")
261
+
262
+ record_id: str
263
+ sequence: int = 0
264
+ timestamp: datetime
265
+ invocation_id: str
266
+ kind: AuditKind = "decision"
267
+
268
+ principal: PrincipalSnapshot
269
+ action: str = ""
270
+ resource: str = ""
271
+ callable_name: str = ""
272
+ channel: str = "internal"
273
+ environment: dict[str, object] = Field(default_factory=dict)
274
+
275
+ decision: Literal["allow", "deny"]
276
+ outcome: AuditOutcome
277
+ reason: str | None = None
278
+
279
+ proposed: dict[str, object] = Field(default_factory=dict)
280
+ processed: dict[str, object] = Field(default_factory=dict)
281
+ rejected: tuple[RejectedArgument, ...] = ()
282
+ defaults_applied: dict[str, object] = Field(default_factory=dict)
283
+ signature_defaults: tuple[str, ...] = ()
284
+
285
+ obligations: tuple[str, ...] = ()
286
+ determining_policies: tuple[str, ...] = ()
287
+ provenance: tuple[dict[str, object], ...] = ()
288
+ policy_set_hash: str = ""
289
+ engine_errors: tuple[str, ...] = ()
290
+
291
+ masked: dict[str, MaskStrategyName] = Field(default_factory=dict)
292
+ grant: dict[str, object] | None = None
293
+ dropped_since_last_write: int = 0
294
+
295
+ prev_hash: str = GENESIS_HASH
296
+ record_hash: str = ""
297
+
298
+ def payload(self) -> dict[str, object]:
299
+ """Render everything the hash covers — that is, every field but :attr:`record_hash`.
300
+
301
+ :attr:`prev_hash` **is** covered, which is what binds the chain: re-pointing a record
302
+ at a different predecessor changes its own hash too.
303
+
304
+ :attr:`dropped_since_last_write` and :attr:`signature_defaults` are covered only when
305
+ they are set, and that asymmetry is deliberate. A log written before a field existed
306
+ hashed a payload without the key, so a key that always appeared — even at its default
307
+ — would change every one of those digests and make an existing log fail
308
+ re-verification. Omitting an unset one is still tamper-evident in both directions:
309
+ forging a count to zero drops the key and forging one onto a clean record adds it, and
310
+ either way the payload, and so the hash, is no longer the one that was written.
311
+
312
+ Any field added here from now on follows the same rule, and the reason is worth
313
+ keeping in mind rather than re-deriving: this method is the canonical pre-image, so
314
+ every key it emits is part of the integrity claim for every record ever written.
315
+ """
316
+ # ``warnings=False`` because a record must never raise or nag about the decision it
317
+ # describes; anything pydantic cannot render in JSON mode is caught by
318
+ # :func:`canonical_json`'s ``default=repr`` instead.
319
+ rendered: dict[str, object] = self.model_dump(mode="json", warnings=False)
320
+ rendered.pop("record_hash", None)
321
+ if not self.dropped_since_last_write:
322
+ rendered.pop("dropped_since_last_write", None)
323
+ if not self.signature_defaults:
324
+ rendered.pop("signature_defaults", None)
325
+ return rendered
326
+
327
+ def compute_hash(self) -> str:
328
+ """Compute this record's digest from :meth:`payload`."""
329
+ return hashlib.sha256(canonical_json(self.payload())).hexdigest()
330
+
331
+ def chained(
332
+ self,
333
+ *,
334
+ prev_hash: str,
335
+ sequence: int,
336
+ dropped_since_last_write: int = 0,
337
+ ) -> AuthzAuditRecord:
338
+ """Return a copy positioned in a chain, with :attr:`record_hash` computed.
339
+
340
+ All three arguments are the record's position in the log rather than anything the
341
+ decision knew, which is why the loss count arrives here and not at build time: it is
342
+ only knowable once the preceding writes have been attempted. Carrying it through this
343
+ method is what keeps it inside the digest — a count applied after the hash was
344
+ computed could be stripped off without trace, and a count applied by a second
345
+ ``model_copy`` at the call site would put chain state in two places.
346
+
347
+ Args:
348
+ prev_hash: The predecessor's hash, or :data:`GENESIS_HASH` for the first record.
349
+ sequence: Position in the chain.
350
+ dropped_since_last_write: Records the sink refused since the last one it accepted.
351
+
352
+ Returns:
353
+ The chained record, ready to write.
354
+ """
355
+ # Two copies rather than one mutation: the model is frozen, and it has to stay that
356
+ # way for "a written record cannot be edited in place" to be a property of the type.
357
+ positioned = self.model_copy(
358
+ update={
359
+ "prev_hash": prev_hash,
360
+ "sequence": sequence,
361
+ "dropped_since_last_write": dropped_since_last_write,
362
+ "record_hash": "",
363
+ }
364
+ )
365
+ return positioned.model_copy(update={"record_hash": positioned.compute_hash()})
366
+
367
+ def verify(self) -> bool:
368
+ """Whether :attr:`record_hash` still matches this record's content."""
369
+ return bool(self.record_hash) and self.record_hash == self.compute_hash()
370
+
371
+
372
+ class ChainVerdict(NamedTuple):
373
+ """The result of verifying a chain.
374
+
375
+ Attributes:
376
+ ok: Whether the chain is intact.
377
+ broken_at: Sequence number of the first bad record, or ``None``.
378
+ reason: What was wrong — a rewritten record, a broken link, a gap in the sequence.
379
+ """
380
+
381
+ ok: bool
382
+ broken_at: int | None = None
383
+ reason: str | None = None
384
+
385
+
386
+ def verify_chain(records: Iterable[AuthzAuditRecord]) -> ChainVerdict:
387
+ """Verify a run of records: each hash matches its content, and each link matches.
388
+
389
+ Checks both, because they catch different attacks. Re-hashing catches an edited record;
390
+ link checking catches a *deleted* one, which would otherwise leave every remaining record
391
+ individually valid.
392
+
393
+ Args:
394
+ records: The records, in write order.
395
+
396
+ Returns:
397
+ The verdict, naming the first record that fails. An empty run is intact by
398
+ definition. The first record's ``prev_hash`` is not required to be
399
+ :data:`GENESIS_HASH`, because a run is often a window over a longer log rather than
400
+ the log itself.
401
+ """
402
+ previous: AuthzAuditRecord | None = None
403
+ for record in records:
404
+ if not record.verify():
405
+ return ChainVerdict(False, record.sequence, "Record hash does not match its content")
406
+ if previous is not None:
407
+ if record.prev_hash != previous.record_hash:
408
+ return ChainVerdict(
409
+ False,
410
+ record.sequence,
411
+ "Record does not link to its predecessor; a record was removed or altered",
412
+ )
413
+ if record.sequence != previous.sequence + 1:
414
+ return ChainVerdict(
415
+ False,
416
+ record.sequence,
417
+ "Sequence numbers are not contiguous; a record is missing",
418
+ )
419
+ previous = record
420
+ return ChainVerdict(True)
421
+
422
+
423
+ def _probe(sink: AuditSink, attribute: str) -> object:
424
+ """Read an optional tail attribute off a sink, tolerating either shape.
425
+
426
+ A sink may expose its tail as a method (``tail_hash()``) or as a property
427
+ (``next_sequence``); neither is part of the :class:`~sentrykit.stores.base.AuditSink`
428
+ contract, because a sink that posts to an HTTP endpoint has no tail to report. Absent
429
+ means "cannot say", which leaves the trail starting a fresh chain.
430
+ """
431
+ value = getattr(sink, attribute, None)
432
+ return value() if callable(value) else value
433
+
434
+
435
+ def _is_sequence_number(value: object) -> TypeGuard[int]:
436
+ """Whether a probed value is usable as a chain position.
437
+
438
+ ``bool`` is excluded explicitly: it is an ``int`` in Python, and a sink that returned
439
+ ``False`` would otherwise silently reset the chain to sequence zero.
440
+ """
441
+ return isinstance(value, int) and not isinstance(value, bool)
442
+
443
+
444
+ def _mask_described_rules(
445
+ argument: str,
446
+ rules: Mapping[str, object],
447
+ masker: Masker,
448
+ strategy: MaskStrategyName,
449
+ ) -> dict[str, object]:
450
+ """Mask the value-bearing rules of one described constraint, leaving the rest alone.
451
+
452
+ Every value is masked under ``argument`` rather than under the rule's name, which is what
453
+ keeps an allow-set comparable with the same value masked as an argument: a digest in the
454
+ set still answers "was what the caller proposed something they were permitted to pass"
455
+ long after neither string can be read.
456
+
457
+ A rule whose value is not the list :meth:`~sentrykit.constraints.ConstraintSet.describe`
458
+ renders is masked whole rather than member by member. The value ends up unreadable either
459
+ way, so that is the direction a shape surprise should fall in.
460
+ """
461
+ masked: dict[str, object] = {}
462
+ for rule, value in rules.items():
463
+ if rule not in _VALUE_BEARING_RULES:
464
+ masked[rule] = value
465
+ elif isinstance(value, list):
466
+ masked[rule] = [masker.mask_value(argument, member, strategy) for member in value]
467
+ else:
468
+ masked[rule] = masker.mask_value(argument, value, strategy)
469
+ return masked
470
+
471
+
472
+ def _mask_described_constraints(
473
+ constraints: Mapping[str, object],
474
+ masker: Masker,
475
+ plan: Mapping[str, MaskStrategyName],
476
+ ) -> dict[str, object]:
477
+ """Mask every described constraint whose argument ``plan`` names.
478
+
479
+ ``describe`` renders ``{action: {argument: {rule: value}}}`` and a masking plan is keyed
480
+ by argument name, so the rules governing an argument and the argument itself mask under
481
+ the same key with nothing in between.
482
+
483
+ Anything not shaped that way falls through unchanged, for the reason
484
+ :func:`canonical_json` falls back to ``repr``: an audit record must not be able to fail
485
+ the decision it describes.
486
+ """
487
+ masked: dict[str, object] = {}
488
+ for action, described in constraints.items():
489
+ if not isinstance(described, Mapping):
490
+ masked[action] = described
491
+ continue
492
+ masked[action] = {
493
+ argument: (
494
+ _mask_described_rules(argument, rules, masker, plan[argument])
495
+ if argument in plan and isinstance(rules, Mapping)
496
+ else rules
497
+ )
498
+ for argument, rules in described.items()
499
+ }
500
+ return masked
501
+
502
+
503
+ @dataclass(frozen=True, slots=True)
504
+ class DecisionFacts:
505
+ """Everything the engine knows about one decision, handed to the trail.
506
+
507
+ A value object rather than the ``Plan`` itself, so :mod:`sentrykit.audit` does not import
508
+ :mod:`sentrykit.engine` — the dependency runs one way, and the trail stays testable
509
+ without a governor.
510
+
511
+ Attributes:
512
+ invocation: The normalized invocation, including the raw proposed arguments.
513
+ decision: What Cedar returned.
514
+ outcome: What happened, finer-grained than allow/deny.
515
+ allowed: Whether the call proceeded.
516
+ reason: One-sentence explanation, matching the envelope's.
517
+ processed: The final argument set, when the call proceeded.
518
+ rejected: Arguments the policy refused.
519
+ defaults_applied: Arguments filled from the principal's grants.
520
+ signature_defaults: Governed parameters the callable's own signature default filled,
521
+ having been checked against the constraint and accepted. Names only.
522
+ obligations: Obligations from the determining policies.
523
+ declared_sensitive: :func:`~sentrykit.masking.sensitive_fields` for the protected
524
+ callable, so a signature-level ``Sensitive`` marker is honored.
525
+ engine_errors: Cedar errors, when the engine failed.
526
+ """
527
+
528
+ invocation: Invocation
529
+ decision: CedarDecision
530
+ outcome: AuditOutcome
531
+ allowed: bool
532
+ reason: str | None = None
533
+ processed: Mapping[str, object] = field(default_factory=dict)
534
+ rejected: tuple[RejectedArgument, ...] = ()
535
+ defaults_applied: Mapping[str, object] = field(default_factory=dict)
536
+ signature_defaults: tuple[str, ...] = ()
537
+ obligations: tuple[str, ...] = ()
538
+ declared_sensitive: Mapping[str, MaskStrategyName | None] = field(default_factory=dict)
539
+ engine_errors: tuple[str, ...] = ()
540
+
541
+
542
+ class AuditTrail:
543
+ """Masks, chains, and writes — the only path from a decision to an
544
+ :class:`~sentrykit.stores.base.AuditSink`.
545
+
546
+ Funnelling every write through one object is what makes masking-before-the-write
547
+ enforceable rather than aspirational. Nothing else in sentrykit calls ``sink.write``, so
548
+ "masking runs before the write" is a property of one function instead of a rule every
549
+ future caller has to remember.
550
+
551
+ Args:
552
+ sink: Where records go. ``None`` disables auditing entirely — the trail then builds
553
+ no records at all, which is what keeps an unaudited governor as cheap as it was
554
+ before this hook existed.
555
+ masker: How sensitive values are masked. Defaults to a masker built from the
556
+ process settings.
557
+ bundle: The loaded policy bundle, for per-policy provenance and obligations. Omitted
558
+ when the governor was wired from raw policy text rather than from a source.
559
+ strict: Whether a sink failure refuses the governed call. Defaults to the effective
560
+ ``fail_mode`` — "can't prove it, don't do it".
561
+ """
562
+
563
+ __slots__ = (
564
+ "_bundle",
565
+ "_dropped",
566
+ "_lock",
567
+ "_masker",
568
+ "_prev_hash",
569
+ "_sequence",
570
+ "_sink",
571
+ "_strict",
572
+ )
573
+
574
+ def __init__(
575
+ self,
576
+ sink: AuditSink | None = None,
577
+ *,
578
+ masker: Masker | None = None,
579
+ bundle: PolicyBundle | None = None,
580
+ strict: bool = True,
581
+ ) -> None:
582
+ self._sink = sink
583
+ self._masker = masker
584
+ self._bundle = bundle
585
+ self._strict = strict
586
+ self._prev_hash: str = GENESIS_HASH
587
+ self._sequence: int = 0
588
+ # Starts at zero and is never resumed from the sink: a dropped record by definition
589
+ # never reached it, so there is nothing there to read. The consequence is worth being
590
+ # plain about — a process that dies while its sink is down takes its unreported count
591
+ # with it. This narrows the window in which a loss is invisible; it does not close it.
592
+ self._dropped: int = 0
593
+ # One trail is shared by every thread the host serves a request on, and advancing the
594
+ # chain is read-modify-write state. Taken in :meth:`_append` only: nothing can reach a
595
+ # trail before its constructor returns, so :meth:`_resume` needs no protection.
596
+ self._lock = threading.Lock()
597
+ if sink is not None:
598
+ self._resume(sink)
599
+
600
+ def _resume(self, sink: AuditSink) -> None:
601
+ """Pick the chain up where a previous process left it, when the sink can say.
602
+
603
+ Probed with :func:`getattr` rather than by importing a concrete backend: a chain that
604
+ restarts at :data:`GENESIS_HASH` on every process start is not a chain, but only some
605
+ sinks can cheaply report their tail, and the core must not know which ones. Both
606
+ halves matter — ``tail_hash`` alone would keep the links intact while the sequence
607
+ restarted at zero, which :func:`verify_chain` reads as a deleted record.
608
+
609
+ Two spellings of the sequence half are accepted: ``tail_sequence`` reports the last
610
+ record written (``-1`` for an empty log) and ``next_sequence`` reports the one to
611
+ write next. Both are optional, and a sink that offers neither simply starts at zero.
612
+ """
613
+ resumed = _probe(sink, "tail_hash")
614
+ if isinstance(resumed, str) and resumed:
615
+ self._prev_hash = resumed
616
+ last = _probe(sink, "tail_sequence")
617
+ if _is_sequence_number(last) and last >= 0:
618
+ self._sequence = last + 1
619
+ return
620
+ following = _probe(sink, "next_sequence")
621
+ if _is_sequence_number(following) and following >= 0:
622
+ self._sequence = following
623
+
624
+ @property
625
+ def sink(self) -> AuditSink | None:
626
+ """The configured sink, or ``None`` when auditing is off."""
627
+ return self._sink
628
+
629
+ @property
630
+ def enabled(self) -> bool:
631
+ """Whether a sink is configured. False means :meth:`record` does no work at all."""
632
+ return self._sink is not None
633
+
634
+ def _require_masker(self) -> Masker:
635
+ """Return the masker, building the settings-derived default on first use.
636
+
637
+ Deferred so that constructing a trail reads no configuration — and so a host that
638
+ calls :func:`~sentrykit.config.configure` after wiring still gets the key it set.
639
+ """
640
+ masker = self._masker
641
+ if masker is None:
642
+ masker = Masker.from_settings(get_settings())
643
+ self._masker = masker
644
+ return masker
645
+
646
+ def record(self, facts: DecisionFacts) -> AuthzAuditRecord | None:
647
+ """Build, mask, chain and write one decision record.
648
+
649
+ Args:
650
+ facts: What the engine decided.
651
+
652
+ Returns:
653
+ The written record, or ``None`` when auditing is disabled.
654
+
655
+ Raises:
656
+ AuditUnavailable: If the sink refused the write and ``strict`` is set.
657
+ """
658
+ if self._sink is None:
659
+ # Disabled: return before anything is built. An unaudited governor pays nothing.
660
+ return None
661
+ record, context = self._mask(facts)
662
+ written = self._append(record)
663
+ _LOGGER.debug("Recorded an authorization decision", extra=context)
664
+ return written
665
+
666
+ def record_mutation(
667
+ self,
668
+ grant: Mapping[str, object],
669
+ *,
670
+ actor: Principal,
671
+ outcome: AuditOutcome,
672
+ invocation_id: str | None = None,
673
+ reason: str | None = None,
674
+ ) -> AuthzAuditRecord | None:
675
+ """Write a record for an admin grant mutation.
676
+
677
+ The admin surface is governed by the same engine and logged into the same sink
678
+ (architecture §5), so a role assignment is as auditable as the call it later permits.
679
+
680
+ Args:
681
+ grant: The :meth:`~sentrykit.stores.base.GrantRecord.as_record` rendering.
682
+ actor: Who made the change.
683
+ outcome: ``granted`` or ``revoked``.
684
+ invocation_id: Correlation id; generated when omitted.
685
+ reason: The justification.
686
+
687
+ Returns:
688
+ The written record, or ``None`` when auditing is disabled.
689
+
690
+ Raises:
691
+ AuditUnavailable: If the sink refused the write and ``strict`` is set.
692
+ """
693
+ if self._sink is None:
694
+ return None
695
+ masker = self._require_masker()
696
+ # A mutation has no callable signature and no determining policies, so nothing
697
+ # declares which of its fields are sensitive and this plan comes back empty every
698
+ # time. The consequence, stated rather than left to be discovered: a mutation record
699
+ # reaches the sink unmasked -- an attribute grant's raw ``value``, the actor's own
700
+ # snapshot attributes, the grant's own described constraint. The values still pass
701
+ # through the masker rather than around it, so the day a plan can be derived here
702
+ # this call site is already the one that applies it; deriving one is a design change,
703
+ # not an oversight at this line.
704
+ plan = masker.plan()
705
+ record = AuthzAuditRecord(
706
+ record_id=uuid4().hex,
707
+ timestamp=datetime.now(UTC),
708
+ invocation_id=invocation_id or uuid4().hex,
709
+ kind="mutation",
710
+ principal=self._snapshot(actor, masker, plan),
711
+ action=_MUTATION_ACTIONS.get(outcome, ""),
712
+ channel="internal",
713
+ decision="allow",
714
+ outcome=outcome,
715
+ reason=reason,
716
+ grant=masker.mask_mapping(grant, plan),
717
+ masked=dict(plan),
718
+ )
719
+ return self._append(record)
720
+
721
+ def query(self, window: AuditWindow) -> Iterable[AuthzAuditRecord]:
722
+ """Read records back from the sink.
723
+
724
+ Args:
725
+ window: The filter.
726
+
727
+ Returns:
728
+ The matching records, oldest first. Empty when auditing is disabled.
729
+ """
730
+ if self._sink is None:
731
+ return ()
732
+ return self._sink.query(window)
733
+
734
+ @staticmethod
735
+ def _snapshot(
736
+ principal: Principal,
737
+ masker: Masker,
738
+ plan: Mapping[str, MaskStrategyName],
739
+ ) -> PrincipalSnapshot:
740
+ """Snapshot a principal with its attributes and its constraints masked.
741
+
742
+ :meth:`PrincipalSnapshot.of` deliberately does not mask, so that the masking step
743
+ lives on this side of the sink boundary with every other one.
744
+
745
+ Both halves run off the same plan and in the same copy. An attribute and the
746
+ allow-set that governs the argument of the same name are the same secret in two
747
+ shapes, and masking one without the other writes it in the clear two fields from its
748
+ own digest — into a sink where it cannot be withdrawn.
749
+ """
750
+ snapshot = PrincipalSnapshot.of(principal)
751
+ if not plan:
752
+ # Nothing was declared sensitive, so there is nothing either half could mask.
753
+ return snapshot
754
+ return snapshot.model_copy(
755
+ update={
756
+ "attributes": masker.mask_mapping(snapshot.attributes, plan),
757
+ "constraints": _mask_described_constraints(snapshot.constraints, masker, plan),
758
+ }
759
+ )
760
+
761
+ def _mask(self, facts: DecisionFacts) -> tuple[AuthzAuditRecord, dict[str, object]]:
762
+ """Build the record with every sensitive value already masked.
763
+
764
+ Private, and the only place raw values are touched. Kept as one method so the
765
+ masking step cannot be bypassed by a future overload of :meth:`record`.
766
+
767
+ Returns:
768
+ The unchained record, and the operational-log context describing it — correlation
769
+ id, action, outcome, and which fields were masked. The context carries no values,
770
+ only names, so it is safe for the ephemeral channel.
771
+ """
772
+ masker = self._require_masker()
773
+ plan = masker.plan(declared=facts.declared_sensitive, obligations=facts.obligations)
774
+ invocation = facts.invocation
775
+ decision = facts.decision
776
+ determining = tuple(decision.determining_policies)
777
+
778
+ # The proposed arguments are already carried in ``proposed``; leaving Cedar's copy of
779
+ # them in the environment would mean masking the same values twice, in two shapes,
780
+ # with two chances to miss one.
781
+ environment = {
782
+ name: value for name, value in invocation.environment.items() if name != "args"
783
+ }
784
+
785
+ provenance: tuple[dict[str, object], ...] = ()
786
+ if self._bundle is not None:
787
+ provenance = tuple(
788
+ entry.as_record() for entry in self._bundle.provenance_for(determining)
789
+ )
790
+
791
+ record = AuthzAuditRecord(
792
+ record_id=uuid4().hex,
793
+ # The decision's own ``now`` where the environment established one, so the record
794
+ # and the Cedar evaluation agree on when this happened.
795
+ timestamp=invocation.now or datetime.now(UTC),
796
+ invocation_id=invocation.id,
797
+ kind="decision",
798
+ principal=self._snapshot(invocation.principal, masker, plan),
799
+ action=str(invocation.action),
800
+ resource=str(invocation.resource),
801
+ callable_name=invocation.callable_name,
802
+ channel=invocation.channel,
803
+ environment=masker.mask_mapping(environment, plan),
804
+ decision="allow" if facts.allowed else "deny",
805
+ outcome=facts.outcome,
806
+ reason=facts.reason,
807
+ proposed=masker.mask_mapping(invocation.proposed, plan),
808
+ processed=masker.mask_mapping(facts.processed, plan),
809
+ rejected=masker.mask_rejections(facts.rejected, plan),
810
+ defaults_applied=masker.mask_mapping(facts.defaults_applied, plan),
811
+ # Names, not values, so there is nothing here for the masker to do.
812
+ signature_defaults=facts.signature_defaults,
813
+ obligations=facts.obligations,
814
+ determining_policies=determining,
815
+ provenance=provenance,
816
+ policy_set_hash=decision.policy_set_hash,
817
+ engine_errors=facts.engine_errors,
818
+ masked=dict(plan),
819
+ )
820
+ context: dict[str, object] = {
821
+ "invocation_id": invocation.id,
822
+ "action": str(invocation.action),
823
+ "outcome": facts.outcome,
824
+ "masked": sorted(plan),
825
+ }
826
+ return record, context
827
+
828
+ def _append(self, record: AuthzAuditRecord) -> AuthzAuditRecord:
829
+ """Chain ``record`` onto the trail and hand it to the sink.
830
+
831
+ The single ``sink.write`` call in sentrykit. Everything upstream of this line has
832
+ already masked; nothing downstream of it is allowed to unmask.
833
+
834
+ Serialized on one lock, because the chain is read-modify-write state and a host puts
835
+ concurrent requests on threads sharing one trail.
836
+
837
+ Also counts what the sink would not take. A refused record is lost, and because the
838
+ chain advances only on success the records either side of the loss link cleanly — so
839
+ the next record to land carries the count out in
840
+ :attr:`AuthzAuditRecord.dropped_since_last_write`, and the hole stops being invisible
841
+ to everyone but whoever was reading the operational log at the time.
842
+
843
+ Returns:
844
+ The chained record. It is returned even when a non-strict write failed, because
845
+ the record still describes the decision — but the chain does not advance, so the
846
+ next record links to the last one that actually landed.
847
+
848
+ Raises:
849
+ AuditUnavailable: If the sink refused the record and ``strict`` is set.
850
+ """
851
+ sink = self._sink
852
+ if sink is None: # pragma: no cover - callers check ``enabled`` first
853
+ return record
854
+ # Chaining and writing are one critical section, not two, and that choice has a price:
855
+ # audit writes are fully serialized, so a sink that forces a durability call per record
856
+ # — ``jsonl://path?fsync=true`` — puts every authorization decision in the process
857
+ # behind that call. It is still the right boundary. Holding the lock only long enough
858
+ # to reserve ``(prev_hash, sequence)`` would buy the concurrency back at the cost of
859
+ # both properties the chain has: records would land in an order their links do not
860
+ # describe, and a refused write could no longer leave the chain where it was, because
861
+ # the next record would already have linked past it.
862
+ with self._lock:
863
+ chained = record.chained(
864
+ prev_hash=self._prev_hash,
865
+ sequence=self._sequence,
866
+ dropped_since_last_write=self._dropped,
867
+ )
868
+ try:
869
+ sink.write(chained)
870
+ except AuditUnavailable as exc:
871
+ # Counted under both fail modes. Strict refuses the governed call as well, but
872
+ # a strict failure on a decision that was *already* being refused is swallowed
873
+ # by the caller rather than surfaced — it must not replace the precise refusal
874
+ # the caller needs — and that is exactly the loss this count exists to carry.
875
+ self._dropped += 1
876
+ self._report_failure(chained, exc)
877
+ if self._strict:
878
+ raise
879
+ return chained
880
+ except Exception as exc:
881
+ # A sink is third-party code. Whatever it raises, the decision path has to
882
+ # classify it as "this could not be proven" rather than let it surface as an
883
+ # unrelated failure of the protected call.
884
+ failure = AuditUnavailable(getattr(sink, "name", "unknown"), str(exc) or repr(exc))
885
+ self._dropped += 1
886
+ self._report_failure(chained, failure)
887
+ if self._strict:
888
+ raise failure from exc
889
+ return chained
890
+ # Advanced only on success: a record the sink refused is not in the log, so the
891
+ # next one must link to the last that actually landed rather than to a phantom.
892
+ # The count clears here and only here, so it reads "since the last record that
893
+ # landed" and no loss is reported by two records.
894
+ self._prev_hash = chained.record_hash
895
+ self._sequence += 1
896
+ self._dropped = 0
897
+ return chained
898
+
899
+ @staticmethod
900
+ def _report_failure(record: AuthzAuditRecord, failure: AuditUnavailable) -> None:
901
+ """Report a sink failure on the *operational* channel, naming no values.
902
+
903
+ Logged under both fail modes, per the logging standards: under ``strict`` the call is
904
+ refused as well, but the operator still needs to see why the sink is down.
905
+ """
906
+ _LOGGER.error(
907
+ "Audit sink could not accept the record",
908
+ extra={
909
+ "invocation_id": record.invocation_id,
910
+ "action": record.action,
911
+ "outcome": record.outcome,
912
+ "sink": failure.sink,
913
+ "error": failure.reason,
914
+ },
915
+ )
916
+
917
+
918
+ def build_facts(
919
+ *,
920
+ invocation: Invocation,
921
+ decision: CedarDecision,
922
+ outcome: AuditOutcome,
923
+ allowed: bool,
924
+ reason: str | None = None,
925
+ processed: Mapping[str, object] | None = None,
926
+ rejected: Sequence[RejectedArgument] = (),
927
+ defaults_applied: Mapping[str, object] | None = None,
928
+ signature_defaults: Sequence[str] = (),
929
+ obligations: Sequence[str] = (),
930
+ declared_sensitive: Mapping[str, MaskStrategyName | None] | None = None,
931
+ ) -> DecisionFacts:
932
+ """Assemble :class:`DecisionFacts`, filling the engine-error list from ``decision``.
933
+
934
+ A convenience so the governor's five exit points each read as one call rather than ten
935
+ keyword arguments, and so ``engine_errors`` cannot be forgotten at one of them.
936
+
937
+ Args:
938
+ invocation: The normalized invocation.
939
+ decision: What Cedar returned; its ``errors`` become ``engine_errors``.
940
+ outcome: What happened, finer-grained than allow/deny.
941
+ allowed: Whether the call proceeded.
942
+ reason: One-sentence explanation, matching the envelope's.
943
+ processed: The final argument set, when the call proceeded.
944
+ rejected: Arguments the policy refused.
945
+ defaults_applied: Arguments filled from the principal's grants.
946
+ signature_defaults: Governed parameters the callable's own default filled.
947
+ obligations: Obligations from the determining policies.
948
+ declared_sensitive: The callable's ``Sensitive`` markers.
949
+
950
+ Returns:
951
+ The assembled facts, with every collection copied so a later mutation of the
952
+ governor's working state cannot rewrite what was recorded.
953
+ """
954
+ return DecisionFacts(
955
+ invocation=invocation,
956
+ decision=decision,
957
+ outcome=outcome,
958
+ allowed=allowed,
959
+ reason=reason,
960
+ processed=dict(processed or {}),
961
+ rejected=tuple(rejected),
962
+ defaults_applied=dict(defaults_applied or {}),
963
+ signature_defaults=tuple(signature_defaults),
964
+ obligations=tuple(obligations),
965
+ declared_sensitive=dict(declared_sensitive or {}),
966
+ engine_errors=tuple(decision.errors),
967
+ )