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/envelope.py
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"""The ``_authz`` envelope — what sentrykit tells the caller about its own decision.
|
|
2
|
+
|
|
3
|
+
An authorization layer that silently rewrites a caller's arguments is worse than one that
|
|
4
|
+
refuses them: the caller gets a plausible answer to a question they did not ask. The
|
|
5
|
+
envelope is the fix. It reports what was processed, what was rejected and why, and what was
|
|
6
|
+
defaulted, so a narrowed call is visibly narrowed — to a human reading an HTTP response, and
|
|
7
|
+
to a language model deciding what to do next.
|
|
8
|
+
|
|
9
|
+
Reporting the rejection is not enough on its own, and that is a measured result rather than a
|
|
10
|
+
design opinion. A narrowed call *succeeded*: the policy decided, the arguments were refilled
|
|
11
|
+
from the principal's own entitlement, and the callable ran and returned real data. But an
|
|
12
|
+
envelope whose only narrowing fields are ``rejected`` and ``defaults_applied`` describes that
|
|
13
|
+
success in the vocabulary of a failure, and a reader acting on it — most sharply a language
|
|
14
|
+
model — concludes the call failed and withholds the answer it is holding. So the envelope
|
|
15
|
+
states the outcome as well as the exceptions: :attr:`AuthzEnvelope.narrowed` says a proposed
|
|
16
|
+
value did not survive, :attr:`AuthzEnvelope.narrowed_arguments` names which, and
|
|
17
|
+
:attr:`AuthzEnvelope.guidance` says in one sentence that the result is valid and what to do
|
|
18
|
+
with it. Those three carry no argument values and no policy ids, so unlike ``processed`` they
|
|
19
|
+
are available at every verbosity above ``off`` — a caller who may not be told *what* was
|
|
20
|
+
narrowed can still be told *that* the result is good.
|
|
21
|
+
|
|
22
|
+
Verbosity trades that transparency against leaking policy structure and argument values to
|
|
23
|
+
callers who should not see either:
|
|
24
|
+
|
|
25
|
+
``off``
|
|
26
|
+
No envelope.
|
|
27
|
+
``summary``
|
|
28
|
+
The decision, the action and resource, and the *names* and reasons of rejected and
|
|
29
|
+
defaulted arguments — enough to explain the outcome without disclosing values.
|
|
30
|
+
``verbose``
|
|
31
|
+
Adds the processed argument values and the offending values on rejections.
|
|
32
|
+
``debug``
|
|
33
|
+
Adds the trace: determining policies, the policy-set hash, timings, and the effective
|
|
34
|
+
settings. For development and incident response.
|
|
35
|
+
|
|
36
|
+
The envelope is a report, not the audit record. The audit record is written append-only,
|
|
37
|
+
masked, and hash-chained regardless of what verbosity shows the caller.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from collections.abc import Mapping, MutableMapping, Sequence
|
|
43
|
+
from contextvars import ContextVar
|
|
44
|
+
from typing import Literal, TypeVar, cast
|
|
45
|
+
|
|
46
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
47
|
+
|
|
48
|
+
from sentrykit.config import Verbosity
|
|
49
|
+
from sentrykit.constraints import RejectedArgument
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"AuthzEnvelope",
|
|
53
|
+
"EnvelopeRecorder",
|
|
54
|
+
"attach_envelope",
|
|
55
|
+
"build_envelope",
|
|
56
|
+
"current_recorder",
|
|
57
|
+
"last_envelope",
|
|
58
|
+
"narrowing_guidance",
|
|
59
|
+
"record_decisions",
|
|
60
|
+
"set_last_envelope",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
T = TypeVar("T")
|
|
64
|
+
|
|
65
|
+
ENVELOPE_KEY: str = "_authz"
|
|
66
|
+
"""Key under which the envelope is attached to a mapping result."""
|
|
67
|
+
|
|
68
|
+
_LAST_ENVELOPE: ContextVar[AuthzEnvelope | None] = ContextVar(
|
|
69
|
+
"sentrykit_last_envelope", default=None
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
_RECORDER: ContextVar[EnvelopeRecorder | None] = ContextVar(
|
|
73
|
+
"sentrykit_envelope_recorder", default=None
|
|
74
|
+
)
|
|
75
|
+
"""The boundary's recorder, reached through the context rather than stored in it.
|
|
76
|
+
|
|
77
|
+
A ``ContextVar`` holding a *value* is write-down-only: a sync route's write is discarded when
|
|
78
|
+
its worker thread's context copy is dropped. A ``ContextVar`` holding a *mutable object* is
|
|
79
|
+
not, because the copy points at the same instance. That distinction is the whole mechanism;
|
|
80
|
+
see :class:`EnvelopeRecorder`."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AuthzEnvelope(BaseModel):
|
|
84
|
+
"""A caller-facing report of one authorization decision.
|
|
85
|
+
|
|
86
|
+
Attributes:
|
|
87
|
+
decision: ``allow`` or ``deny``.
|
|
88
|
+
action: The action that was authorized.
|
|
89
|
+
resource: The resource it was authorized against.
|
|
90
|
+
principal: The acting principal's uid. Omitted below ``verbose``.
|
|
91
|
+
processed: The argument set the policy decided and sentrykit passed. A parameter
|
|
92
|
+
left to the callable's own default is absent here and named in
|
|
93
|
+
``signature_defaults`` instead. Omitted below ``verbose``.
|
|
94
|
+
rejected: Arguments the policy refused, with reasons. Values are included only at
|
|
95
|
+
``verbose`` and above.
|
|
96
|
+
defaults_applied: Arguments filled in from the principal's defaults. Below
|
|
97
|
+
``verbose`` the keys are still the argument names but every value is ``None``,
|
|
98
|
+
so the field keeps one shape at every verbosity and a caller can always read
|
|
99
|
+
``defaults_applied.keys()``.
|
|
100
|
+
signature_defaults: Governed parameters whose value came from the callable's own
|
|
101
|
+
signature — the caller proposed none that survived, policy supplied none, and the
|
|
102
|
+
constraint was checked against the application's own default and accepted it.
|
|
103
|
+
Names only, never values. Present at every verbosity, because the point of the
|
|
104
|
+
field is that this case used to be invisible: from the argument layer's point of
|
|
105
|
+
view nothing happened, so a clean allow was reported over a value no policy chose.
|
|
106
|
+
A call where that default was *refused* does not appear here; it is a refusal.
|
|
107
|
+
narrowed: Whether the call ran with a value the caller did not propose — the
|
|
108
|
+
reject-and-refill outcome. Distinct from ``rejected`` being non-empty: a
|
|
109
|
+
rejection that left the argument with the caller's own surviving value is not
|
|
110
|
+
narrowing, and a default injected for an argument the caller never mentioned is
|
|
111
|
+
not either. Omitted from :meth:`as_dict` when false, so a clean allow stays
|
|
112
|
+
terse.
|
|
113
|
+
narrowed_arguments: The parameter names whose proposed value did not survive.
|
|
114
|
+
Present at every verbosity, because ``rejected`` already carries these names.
|
|
115
|
+
guidance: One sentence on what the outcome means and what to do with it, set only
|
|
116
|
+
when :attr:`narrowed` is true. Carries no argument values and no policy ids, so
|
|
117
|
+
it is safe at every verbosity. It exists because a reader given only
|
|
118
|
+
``rejected`` reads a successful narrowed call as a failed one.
|
|
119
|
+
obligations: Obligations the policy attached to the decision — break-the-glass
|
|
120
|
+
justification, review requirements. Carried through from the determining
|
|
121
|
+
policies; the shipped policy pack is what attaches them.
|
|
122
|
+
reason: Why a denial happened, in one sentence.
|
|
123
|
+
determining_policies: The policies that decided it. ``debug`` only, since policy ids
|
|
124
|
+
describe the shape of the rule set.
|
|
125
|
+
trace: Decision trace — policy-set hash, engine errors, timings, effective settings.
|
|
126
|
+
``debug`` only.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
130
|
+
|
|
131
|
+
decision: Literal["allow", "deny"]
|
|
132
|
+
action: str
|
|
133
|
+
resource: str | None = None
|
|
134
|
+
principal: str | None = None
|
|
135
|
+
processed: dict[str, object] = Field(default_factory=dict)
|
|
136
|
+
rejected: tuple[RejectedArgument, ...] = ()
|
|
137
|
+
defaults_applied: dict[str, object] = Field(default_factory=dict)
|
|
138
|
+
signature_defaults: tuple[str, ...] = ()
|
|
139
|
+
narrowed: bool = False
|
|
140
|
+
narrowed_arguments: tuple[str, ...] = ()
|
|
141
|
+
guidance: str | None = None
|
|
142
|
+
obligations: tuple[str, ...] = ()
|
|
143
|
+
reason: str | None = None
|
|
144
|
+
determining_policies: tuple[str, ...] = ()
|
|
145
|
+
trace: dict[str, object] | None = None
|
|
146
|
+
|
|
147
|
+
def as_dict(self) -> dict[str, object]:
|
|
148
|
+
"""Render as a JSON-ready mapping, dropping empty sections.
|
|
149
|
+
|
|
150
|
+
Argument values are whatever the protected callable was called with, so they are not
|
|
151
|
+
guaranteed to be JSON-serializable. Anything pydantic cannot render is replaced by
|
|
152
|
+
its ``repr`` rather than raising: a report about a decision must not be able to fail
|
|
153
|
+
the call the decision permitted.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The envelope with unset and empty fields omitted, so a clean allow stays terse.
|
|
157
|
+
``narrowed`` is dropped when false for the same reason: it is the *exceptional*
|
|
158
|
+
outcome, and emitting ``"narrowed": false`` on every ordinary response would
|
|
159
|
+
train a reader to skip the key.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
data = self.model_dump(mode="json", exclude_none=True, warnings=False, fallback=repr)
|
|
163
|
+
except TypeError: # pydantic < 2.11 has no ``fallback``
|
|
164
|
+
data = self.model_dump(mode="json", exclude_none=True, warnings=False)
|
|
165
|
+
if not data.get("narrowed"):
|
|
166
|
+
data.pop("narrowed", None)
|
|
167
|
+
return {
|
|
168
|
+
key: value
|
|
169
|
+
for key, value in data.items()
|
|
170
|
+
if not (isinstance(value, dict | list) and len(value) == 0)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def narrowing_guidance(narrowed_arguments: Sequence[str], *, unsupplied: Sequence[str] = ()) -> str:
|
|
175
|
+
"""Compose the sentence :attr:`AuthzEnvelope.guidance` carries.
|
|
176
|
+
|
|
177
|
+
The wording has one job, and it is not decoration: a reader handed only ``rejected``
|
|
178
|
+
treats a narrowed call as a failed one and withholds the result. Measured across language
|
|
179
|
+
models, that happened in roughly half of narrowed calls. So the sentence leads
|
|
180
|
+
with the outcome rather than the exceptions, and it closes by saying not to retry the
|
|
181
|
+
original values, because a reader that reads "rejected" as "try again" will loop.
|
|
182
|
+
|
|
183
|
+
The lead is **conditional**, and that is the point of the ``unsupplied`` argument. When
|
|
184
|
+
every narrowed argument was refilled from the principal's own entitlement, the call ran
|
|
185
|
+
on values policy approves and the result can be vouched for. When one was not, the
|
|
186
|
+
callable's own signature default landed in the slot, no constraint ever saw it, and
|
|
187
|
+
claiming the result is "valid" would be precisely the overclaim this envelope exists to
|
|
188
|
+
prevent — so that case says "served" and marks the affected part unverified instead.
|
|
189
|
+
|
|
190
|
+
Only names appear, never values and never policy ids, which is what makes the sentence
|
|
191
|
+
safe to emit at ``summary`` verbosity alongside the trimmed fields.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
narrowed_arguments: Parameter names whose proposed value did not survive.
|
|
195
|
+
unsupplied: The subset of those for which sentrykit supplied no replacement, so the
|
|
196
|
+
callable's own signature default applied. Called out separately because
|
|
197
|
+
sentrykit never saw that value and must not imply it is policy-approved.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
One sentence, suitable for a human reading an HTTP response or a model reading a
|
|
201
|
+
tool result.
|
|
202
|
+
"""
|
|
203
|
+
names = ", ".join(narrowed_arguments)
|
|
204
|
+
refilled = [name for name in narrowed_arguments if name not in set(unsupplied)]
|
|
205
|
+
|
|
206
|
+
if not unsupplied:
|
|
207
|
+
# Every narrowed argument was refilled from this principal's own entitlement, so the
|
|
208
|
+
# call ran entirely on values policy approves and the result can be vouched for.
|
|
209
|
+
return (
|
|
210
|
+
f"This result is valid and was served. The authorization policy adjusted {names} "
|
|
211
|
+
f"before the call ran, because the proposed value is not one this principal may "
|
|
212
|
+
f"use. Report the result as delivered and say which parameters were adjusted; do "
|
|
213
|
+
f"not retry with the original values."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# At least one argument reached the callable with no policy-supplied value, so the lead
|
|
217
|
+
# cannot claim the result is policy-approved: sentrykit never saw what landed there.
|
|
218
|
+
# Saying "valid" here would be the exact overclaim the envelope exists to prevent.
|
|
219
|
+
sentence = (
|
|
220
|
+
f"This result was served, with {names} adjusted by the authorization policy because "
|
|
221
|
+
f"the proposed value is not one this principal may use. Report the result as "
|
|
222
|
+
f"delivered and say which parameters were adjusted; do not retry with the original "
|
|
223
|
+
f"values. Note that for {', '.join(unsupplied)} the policy supplied no replacement, "
|
|
224
|
+
f"so the callable's own default applied and no constraint checked it — treat that "
|
|
225
|
+
f"part of the result as unverified."
|
|
226
|
+
)
|
|
227
|
+
if refilled:
|
|
228
|
+
sentence += f" {', '.join(refilled)} did come from this principal's own entitlement."
|
|
229
|
+
return sentence
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def build_envelope(
|
|
233
|
+
*,
|
|
234
|
+
decision: Literal["allow", "deny"],
|
|
235
|
+
action: str,
|
|
236
|
+
resource: str | None,
|
|
237
|
+
principal: str | None,
|
|
238
|
+
processed: Mapping[str, object],
|
|
239
|
+
rejected: tuple[RejectedArgument, ...],
|
|
240
|
+
defaults_applied: Mapping[str, object],
|
|
241
|
+
signature_defaults: Sequence[str] = (),
|
|
242
|
+
narrowed_arguments: Sequence[str] = (),
|
|
243
|
+
narrowed_without_replacement: Sequence[str] = (),
|
|
244
|
+
obligations: tuple[str, ...] = (),
|
|
245
|
+
reason: str | None = None,
|
|
246
|
+
determining_policies: tuple[str, ...] = (),
|
|
247
|
+
trace: Mapping[str, object] | None = None,
|
|
248
|
+
verbosity: Verbosity,
|
|
249
|
+
) -> AuthzEnvelope | None:
|
|
250
|
+
"""Build the envelope for one decision, trimmed to ``verbosity``.
|
|
251
|
+
|
|
252
|
+
Trimming happens here rather than at render time so that a value excluded by verbosity is
|
|
253
|
+
never carried on the object at all — a report cannot leak a field it does not hold.
|
|
254
|
+
|
|
255
|
+
Below ``verbose`` the trimming is: no ``principal``, no ``processed``, no
|
|
256
|
+
``determining_policies`` and no ``trace``; each rejection is rebuilt without its
|
|
257
|
+
offending ``value``; and ``defaults_applied`` keeps only the *names*, each mapped to
|
|
258
|
+
``None``. Mapping to ``None`` rather than dropping the section keeps one shape for the
|
|
259
|
+
field across every verbosity, so a caller can always read
|
|
260
|
+
``envelope.defaults_applied.keys()`` to learn which arguments policy filled in.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
decision: The outcome.
|
|
264
|
+
action: Action uid.
|
|
265
|
+
resource: Resource uid, if any.
|
|
266
|
+
principal: Principal uid.
|
|
267
|
+
processed: Final arguments the call ran with.
|
|
268
|
+
rejected: Arguments the policy refused.
|
|
269
|
+
defaults_applied: Arguments filled in from policy defaults.
|
|
270
|
+
signature_defaults: Governed parameters the callable's own signature default filled,
|
|
271
|
+
after the constraint checked and accepted it. Names only, and not trimmed by
|
|
272
|
+
verbosity for the same reason ``narrowed_arguments`` is not.
|
|
273
|
+
narrowed_arguments: Parameter names whose proposed value did not survive. Sets
|
|
274
|
+
:attr:`AuthzEnvelope.narrowed` and drives the guidance sentence. Not trimmed by
|
|
275
|
+
verbosity: these names already appear on ``rejected`` at every verbosity.
|
|
276
|
+
narrowed_without_replacement: The subset of ``narrowed_arguments`` that sentrykit
|
|
277
|
+
supplied no value for, so the callable's signature default applied.
|
|
278
|
+
obligations: Obligations attached by the policy.
|
|
279
|
+
reason: One-sentence explanation, for denials.
|
|
280
|
+
determining_policies: Policies that decided the outcome.
|
|
281
|
+
trace: Debug trace.
|
|
282
|
+
verbosity: How much to include.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
The envelope, or ``None`` when ``verbosity`` is ``off``.
|
|
286
|
+
"""
|
|
287
|
+
if verbosity == "off":
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
with_values = verbosity in ("verbose", "debug")
|
|
291
|
+
with_trace = verbosity == "debug"
|
|
292
|
+
|
|
293
|
+
principal_out: str | None
|
|
294
|
+
processed_out: dict[str, object]
|
|
295
|
+
rejected_out: tuple[RejectedArgument, ...]
|
|
296
|
+
defaults_out: dict[str, object]
|
|
297
|
+
|
|
298
|
+
if with_values:
|
|
299
|
+
principal_out = principal
|
|
300
|
+
processed_out = dict(processed)
|
|
301
|
+
rejected_out = rejected
|
|
302
|
+
defaults_out = dict(defaults_applied)
|
|
303
|
+
else:
|
|
304
|
+
principal_out = None
|
|
305
|
+
processed_out = {}
|
|
306
|
+
# Rebuilt rather than copied so the offending value is absent from the object,
|
|
307
|
+
# not merely hidden from one rendering of it.
|
|
308
|
+
rejected_out = tuple(
|
|
309
|
+
RejectedArgument(name=item.name, rule=item.rule, reason=item.reason, policy=item.policy)
|
|
310
|
+
for item in rejected
|
|
311
|
+
)
|
|
312
|
+
defaults_out = dict.fromkeys(defaults_applied)
|
|
313
|
+
|
|
314
|
+
return AuthzEnvelope(
|
|
315
|
+
decision=decision,
|
|
316
|
+
action=action,
|
|
317
|
+
resource=resource,
|
|
318
|
+
principal=principal_out,
|
|
319
|
+
processed=processed_out,
|
|
320
|
+
rejected=rejected_out,
|
|
321
|
+
defaults_applied=defaults_out,
|
|
322
|
+
# Not trimmed by verbosity: these are parameter names, which `rejected` and
|
|
323
|
+
# `defaults_applied` already carry at every verbosity, and the whole point of the
|
|
324
|
+
# field is to stop this case being silent.
|
|
325
|
+
signature_defaults=tuple(signature_defaults),
|
|
326
|
+
narrowed=bool(narrowed_arguments),
|
|
327
|
+
narrowed_arguments=tuple(narrowed_arguments),
|
|
328
|
+
guidance=(
|
|
329
|
+
narrowing_guidance(narrowed_arguments, unsupplied=narrowed_without_replacement)
|
|
330
|
+
if narrowed_arguments
|
|
331
|
+
else None
|
|
332
|
+
),
|
|
333
|
+
obligations=obligations,
|
|
334
|
+
reason=reason,
|
|
335
|
+
determining_policies=determining_policies if with_trace else (),
|
|
336
|
+
trace=dict(trace) if with_trace and trace is not None else None,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def attach_envelope(result: T, envelope: AuthzEnvelope | None) -> T:
|
|
341
|
+
"""Attach ``envelope`` to a result and record it as the context's last envelope.
|
|
342
|
+
|
|
343
|
+
A mapping result gets the envelope under ``_authz`` in place — which is what an agent
|
|
344
|
+
tool result or a JSON handler return usually is, and the reason the README can promise
|
|
345
|
+
the report travels with the answer. Any other result is returned untouched: rewrapping
|
|
346
|
+
it would change the protected callable's return type and break both static typing and
|
|
347
|
+
every caller. Those callers read :func:`last_envelope` instead, which is how the
|
|
348
|
+
web framework providers attach it to an HTTP response.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
result: Whatever the protected callable returned.
|
|
352
|
+
envelope: The envelope, or ``None`` to attach nothing.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
``result`` — the same object, mutated in place when it is a mapping.
|
|
356
|
+
"""
|
|
357
|
+
set_last_envelope(envelope)
|
|
358
|
+
if envelope is None:
|
|
359
|
+
return result
|
|
360
|
+
if isinstance(result, MutableMapping):
|
|
361
|
+
# ``T`` is preserved: the same object comes back, only with one key added. The cast
|
|
362
|
+
# is needed because a narrowing on ``T`` does not tell mypy the keys are strings.
|
|
363
|
+
cast("MutableMapping[str, object]", result)[ENVELOPE_KEY] = envelope.as_dict()
|
|
364
|
+
recorder = _RECORDER.get()
|
|
365
|
+
if recorder is not None:
|
|
366
|
+
# The boundary now knows this result speaks for itself, so an adapter does not
|
|
367
|
+
# have to annotate it a second time out of band.
|
|
368
|
+
recorder.last_was_in_band = True
|
|
369
|
+
return result
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def last_envelope() -> AuthzEnvelope | None:
|
|
373
|
+
"""Return the envelope from the most recent decision in this context.
|
|
374
|
+
|
|
375
|
+
Scoped to the current ``ContextVar`` context, so concurrent tasks and threads each see
|
|
376
|
+
their own.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
The most recent envelope, or ``None`` if there has been no decision or verbosity is
|
|
380
|
+
``off``.
|
|
381
|
+
"""
|
|
382
|
+
return _LAST_ENVELOPE.get()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def set_last_envelope(envelope: AuthzEnvelope | None) -> None:
|
|
386
|
+
"""Record ``envelope`` as this context's most recent decision.
|
|
387
|
+
|
|
388
|
+
Called by the engine on both allow and deny, so a provider rendering an error response
|
|
389
|
+
has the same detail available as one rendering a success. It also feeds the boundary's
|
|
390
|
+
:class:`EnvelopeRecorder` when one is installed, which is the only path that survives a
|
|
391
|
+
decision made inside a copied context.
|
|
392
|
+
"""
|
|
393
|
+
_LAST_ENVELOPE.set(envelope)
|
|
394
|
+
recorder = _RECORDER.get()
|
|
395
|
+
if recorder is not None and envelope is not None:
|
|
396
|
+
recorder.record(envelope)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class EnvelopeRecorder:
|
|
400
|
+
"""A boundary's running account of the decisions made while serving one request.
|
|
401
|
+
|
|
402
|
+
This exists because :func:`last_envelope` cannot answer the boundary's question. A
|
|
403
|
+
``ContextVar`` write propagates *downward* into a copied context and never back *up*: a
|
|
404
|
+
sync framework route runs in a worker thread holding a copy, so nothing the engine sets
|
|
405
|
+
while that route executes is visible to the middleware that has to write the response
|
|
406
|
+
headers. Measured, not assumed — see ADR-029.
|
|
407
|
+
|
|
408
|
+
A recorder closes that gap by being a **mutable object reached through** the context
|
|
409
|
+
variable rather than a value stored in it. The copy a worker thread receives points at
|
|
410
|
+
the same instance, so :meth:`record` mutates what the boundary is still holding.
|
|
411
|
+
|
|
412
|
+
It is also the right shape for the question a boundary actually asks. A request may make
|
|
413
|
+
several protected calls, and "was anything narrowed while serving this response" is not
|
|
414
|
+
answerable from the most recent envelope alone, so :attr:`narrowed_arguments` accumulates
|
|
415
|
+
the union across every decision in the scope, in first-seen order.
|
|
416
|
+
|
|
417
|
+
A boundary installs one with :func:`record_decisions` and drops it with :meth:`close`;
|
|
418
|
+
:class:`~sentrykit.integrations.base.BaseContextProvider` does both, so every shipped
|
|
419
|
+
framework adapter has one without asking.
|
|
420
|
+
|
|
421
|
+
Attributes:
|
|
422
|
+
decisions: How many decisions have been recorded.
|
|
423
|
+
envelope: The most recent one, matching what :func:`last_envelope` would return had
|
|
424
|
+
the decision been made in this context.
|
|
425
|
+
narrowed_arguments: Every argument narrowed anywhere in the scope, first-seen order.
|
|
426
|
+
last_was_in_band: Whether the **most recent** decision's envelope was written into
|
|
427
|
+
the result itself under ``_authz``. Per-decision rather than cumulative on
|
|
428
|
+
purpose: it answers "does this result already say so?", which is a question about
|
|
429
|
+
one return value, and a boundary asks it immediately after the call it describes.
|
|
430
|
+
A non-mapping return leaves it ``False``, and that is the signal the agent
|
|
431
|
+
adapters act on.
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
__slots__ = (
|
|
435
|
+
"_closed",
|
|
436
|
+
"_seen",
|
|
437
|
+
"decisions",
|
|
438
|
+
"envelope",
|
|
439
|
+
"last_was_in_band",
|
|
440
|
+
"narrowed_arguments",
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
def __init__(self) -> None:
|
|
444
|
+
self.decisions = 0
|
|
445
|
+
self.envelope: AuthzEnvelope | None = None
|
|
446
|
+
self.narrowed_arguments: tuple[str, ...] = ()
|
|
447
|
+
self.last_was_in_band = False
|
|
448
|
+
self._seen: set[str] = set()
|
|
449
|
+
self._closed = False
|
|
450
|
+
|
|
451
|
+
@property
|
|
452
|
+
def narrowed(self) -> bool:
|
|
453
|
+
"""Whether any decision in this scope ran with a value its caller did not propose."""
|
|
454
|
+
return bool(self.narrowed_arguments)
|
|
455
|
+
|
|
456
|
+
def record(self, envelope: AuthzEnvelope) -> None:
|
|
457
|
+
"""Fold one decision into the account.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
envelope: The decision's envelope. A ``None`` envelope — verbosity ``off`` — is
|
|
461
|
+
filtered out by :func:`set_last_envelope` before it reaches here, so a host
|
|
462
|
+
that turned reporting off gets no headers rather than empty ones.
|
|
463
|
+
"""
|
|
464
|
+
self.decisions += 1
|
|
465
|
+
self.envelope = envelope
|
|
466
|
+
# Cleared per decision, and set again only if ``attach_envelope`` manages to write
|
|
467
|
+
# into the result. The engine records the envelope before it invokes, so this is the
|
|
468
|
+
# earliest point at which the flag can be made to describe *this* call.
|
|
469
|
+
self.last_was_in_band = False
|
|
470
|
+
fresh = [name for name in envelope.narrowed_arguments if name not in self._seen]
|
|
471
|
+
if fresh:
|
|
472
|
+
self._seen.update(fresh)
|
|
473
|
+
self.narrowed_arguments = (*self.narrowed_arguments, *fresh)
|
|
474
|
+
|
|
475
|
+
def close(self) -> None:
|
|
476
|
+
"""Uninstall this recorder. Idempotent, and safe to call on an already-closed one.
|
|
477
|
+
|
|
478
|
+
Shaped like :meth:`~sentrykit.context.ContextScope.reset` on purpose: a boundary
|
|
479
|
+
tears down what it set up, in a ``finally``, and a pooled worker must not start the
|
|
480
|
+
next request holding the previous caller's account.
|
|
481
|
+
"""
|
|
482
|
+
if self._closed:
|
|
483
|
+
return
|
|
484
|
+
self._closed = True
|
|
485
|
+
_RECORDER.set(None)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def record_decisions() -> EnvelopeRecorder:
|
|
489
|
+
"""Install an :class:`EnvelopeRecorder` for the current context and return it.
|
|
490
|
+
|
|
491
|
+
Call at a request boundary, before anything protected runs, and
|
|
492
|
+
:meth:`EnvelopeRecorder.close` it at teardown. The shipped providers do this inside
|
|
493
|
+
:meth:`~sentrykit.integrations.base.BaseContextProvider.establish` and
|
|
494
|
+
:meth:`~sentrykit.integrations.base.BaseContextProvider.release`; a hand-written provider
|
|
495
|
+
that wants narrowing to reach its response needs the same pair.
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
The recorder. Also reachable from the same context with :func:`current_recorder`,
|
|
499
|
+
which is what lets a boundary read it without threading it through the call stack.
|
|
500
|
+
"""
|
|
501
|
+
recorder = EnvelopeRecorder()
|
|
502
|
+
_RECORDER.set(recorder)
|
|
503
|
+
return recorder
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def current_recorder() -> EnvelopeRecorder | None:
|
|
507
|
+
"""Return the recorder installed for this context, if any.
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
The recorder, or ``None`` outside a boundary that installed one — which is the normal
|
|
511
|
+
case for a plain ``with establish(...)`` block in application code.
|
|
512
|
+
"""
|
|
513
|
+
return _RECORDER.get()
|