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/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
|
+
)
|