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/engine.py
ADDED
|
@@ -0,0 +1,1509 @@
|
|
|
1
|
+
"""The Governor — the decision pipeline every protected call runs through.
|
|
2
|
+
|
|
3
|
+
One pass, in a fixed order, described in architecture §2:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
gate -> filter args -> inject defaults -> validate -> invoke -> envelope
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The order is not arbitrary. The coarse Cedar gate runs first because there is no point
|
|
10
|
+
reasoning about arguments for a call that is not permitted at all. Filtering precedes
|
|
11
|
+
defaults so that a dropped argument can be *replaced* by the principal's default rather than
|
|
12
|
+
leaving a hole. Validation runs last, on the final argument set, so the callable never sees
|
|
13
|
+
a value that policy did not sanction — and so a validation failure is genuinely about types,
|
|
14
|
+
not about authorization.
|
|
15
|
+
|
|
16
|
+
The split between :meth:`Governor.plan` and :meth:`Governor.invoke` exists so the decision
|
|
17
|
+
can be made without performing the side effect. Agent-side ``list_tools`` filtering plans a
|
|
18
|
+
call it never invokes, to decide whether to show the tool to a model at all.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import inspect
|
|
24
|
+
import logging
|
|
25
|
+
from collections.abc import Awaitable, Callable, Generator, Mapping, Sequence
|
|
26
|
+
from contextlib import contextmanager
|
|
27
|
+
from dataclasses import dataclass, field, replace
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from typing import Any, Final, NoReturn, TypeVar, cast, get_type_hints
|
|
30
|
+
from uuid import uuid4
|
|
31
|
+
|
|
32
|
+
from pydantic import BaseModel, ConfigDict, ValidationError, create_model
|
|
33
|
+
|
|
34
|
+
from sentrykit.audit import AuditOutcome, AuditTrail, build_facts
|
|
35
|
+
from sentrykit.cedar import CedarDecision, CedarRequest, Entity, EntityRef, PolicyEngine
|
|
36
|
+
from sentrykit.config import Settings, apply_overrides, get_settings
|
|
37
|
+
from sentrykit.constraints import ConstraintSet, RejectedArgument
|
|
38
|
+
from sentrykit.context import Invocation, Principal, current_environment
|
|
39
|
+
from sentrykit.envelope import AuthzEnvelope, attach_envelope, build_envelope, set_last_envelope
|
|
40
|
+
from sentrykit.errors import (
|
|
41
|
+
ArgumentRejected,
|
|
42
|
+
AuditUnavailable,
|
|
43
|
+
AuthzDenied,
|
|
44
|
+
ConfigurationError,
|
|
45
|
+
DefaultUnavailable,
|
|
46
|
+
NoValidArguments,
|
|
47
|
+
ReservedKey,
|
|
48
|
+
SentrykitError,
|
|
49
|
+
SignatureDefaultRejected,
|
|
50
|
+
ValidationFailed,
|
|
51
|
+
)
|
|
52
|
+
from sentrykit.masking import Masker, MaskStrategyName, sensitive_fields
|
|
53
|
+
from sentrykit.registry import (
|
|
54
|
+
Configured,
|
|
55
|
+
build_audit_sink,
|
|
56
|
+
build_context_provider,
|
|
57
|
+
build_grant_store,
|
|
58
|
+
build_policy_source,
|
|
59
|
+
)
|
|
60
|
+
from sentrykit.stores.base import (
|
|
61
|
+
AuditSink,
|
|
62
|
+
ContextProvider,
|
|
63
|
+
GrantStore,
|
|
64
|
+
PolicyBundle,
|
|
65
|
+
PolicySource,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"ArgumentSpec",
|
|
70
|
+
"Governor",
|
|
71
|
+
"Plan",
|
|
72
|
+
"current_governor",
|
|
73
|
+
"get_governor",
|
|
74
|
+
"install",
|
|
75
|
+
"set_governor",
|
|
76
|
+
"use_governor",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
T = TypeVar("T")
|
|
80
|
+
_B = TypeVar("_B")
|
|
81
|
+
|
|
82
|
+
_LOGGER: Final = logging.getLogger("sentrykit.engine")
|
|
83
|
+
|
|
84
|
+
DEFAULT_RESOURCE_TYPE: str = "Resource"
|
|
85
|
+
"""Entity type used for the synthetic resource when a protected callable declares none."""
|
|
86
|
+
|
|
87
|
+
_GOVERNOR: Governor | None = None
|
|
88
|
+
"""The process-default governor. A plain global, not a ``ContextVar``: wiring is a
|
|
89
|
+
process-lifetime concern, and :func:`use_governor` covers the scoped case."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _same_value(left: object, right: object) -> bool:
|
|
93
|
+
"""Compare two argument values without letting ``True`` equal ``1``.
|
|
94
|
+
|
|
95
|
+
The same rule :func:`sentrykit.constraints._same_value` applies to set membership, and
|
|
96
|
+
for the same reason: Python's ``bool`` is an ``int``, so a plain ``==`` would report
|
|
97
|
+
"unchanged" for a proposed ``True`` that reached the callable as ``1``. Restated here
|
|
98
|
+
rather than imported because ``constraints`` is a pure function from constraints to a
|
|
99
|
+
verdict and owes the engine nothing — and because a *report* that quietly disagrees with
|
|
100
|
+
the *filter* about whether a value changed is worse than three duplicated lines.
|
|
101
|
+
"""
|
|
102
|
+
if isinstance(left, bool) is not isinstance(right, bool):
|
|
103
|
+
return False
|
|
104
|
+
return bool(left == right)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
_REFUSAL_OUTCOMES: Final[dict[type[BaseException], AuditOutcome]] = {
|
|
108
|
+
AuthzDenied: "denied",
|
|
109
|
+
ArgumentRejected: "arguments-rejected",
|
|
110
|
+
NoValidArguments: "no-valid-arguments",
|
|
111
|
+
DefaultUnavailable: "default-unavailable",
|
|
112
|
+
SignatureDefaultRejected: "signature-default-rejected",
|
|
113
|
+
ValidationFailed: "validation-failed",
|
|
114
|
+
}
|
|
115
|
+
"""How each typed refusal is classified in the audit trail.
|
|
116
|
+
|
|
117
|
+
Deliberately finer-grained than allow/deny: "Cedar permitted the call and then every argument
|
|
118
|
+
the caller proposed was refused" is a different event to an auditor than "no policy permits
|
|
119
|
+
this at all", even though the caller experiences both as a denial.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _qualified_name(fn: Callable[..., object]) -> str:
|
|
124
|
+
"""Render ``<module>.<qualname>`` for a callable, tolerating exotic ones.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
fn: Any callable, including one that carries no ``__qualname__`` (a
|
|
128
|
+
``functools.partial``, a callable instance).
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The qualified name used as the derived action id and in diagnostics.
|
|
132
|
+
"""
|
|
133
|
+
module = getattr(fn, "__module__", None) or "<unknown>"
|
|
134
|
+
qualname = getattr(fn, "__qualname__", None) or getattr(fn, "__name__", None) or repr(fn)
|
|
135
|
+
return f"{module}.{qualname}"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True, slots=True)
|
|
139
|
+
class ArgumentSpec:
|
|
140
|
+
"""Everything the engine needs to know about a protected callable's arguments.
|
|
141
|
+
|
|
142
|
+
Derived once per callable and cached on the decorator's wrapper, because building a
|
|
143
|
+
pydantic model from a signature is far more expensive than the authorization decision
|
|
144
|
+
it supports.
|
|
145
|
+
|
|
146
|
+
Attributes:
|
|
147
|
+
name: Qualified name, used as the derived action id and in diagnostics.
|
|
148
|
+
signature: The callable's signature, used to bind proposed positional arguments to
|
|
149
|
+
parameter names.
|
|
150
|
+
parameters: Every parameter name the callable accepts, in declaration order.
|
|
151
|
+
positional_only: The subset that must be passed positionally.
|
|
152
|
+
required: Parameters with no Python-level default, which the call cannot omit.
|
|
153
|
+
model: A pydantic model over the parameters, for validating the final argument set.
|
|
154
|
+
is_async: Whether the callable is a coroutine function.
|
|
155
|
+
accepts_var_keyword: Whether the callable declares ``**kwargs``, in which case a
|
|
156
|
+
policy default for an unlisted name is still deliverable.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
name: str
|
|
160
|
+
signature: inspect.Signature
|
|
161
|
+
parameters: tuple[str, ...]
|
|
162
|
+
positional_only: tuple[str, ...]
|
|
163
|
+
required: frozenset[str]
|
|
164
|
+
model: type[BaseModel]
|
|
165
|
+
is_async: bool
|
|
166
|
+
accepts_var_keyword: bool
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def from_callable(cls, fn: Callable[..., object]) -> ArgumentSpec:
|
|
170
|
+
"""Derive a spec by introspecting ``fn``.
|
|
171
|
+
|
|
172
|
+
Unannotated parameters are typed as ``object`` so that an untyped callable is still
|
|
173
|
+
protectable — sentrykit's job is authorization, not enforcing that its users annotate
|
|
174
|
+
their code. ``*args`` and ``**kwargs`` are not modelled as fields.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
fn: The callable being protected.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
The derived spec.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
ConfigurationError: If the signature cannot be introspected or its annotations
|
|
184
|
+
cannot be resolved into a model.
|
|
185
|
+
"""
|
|
186
|
+
name = _qualified_name(fn)
|
|
187
|
+
try:
|
|
188
|
+
signature = inspect.signature(fn)
|
|
189
|
+
except (TypeError, ValueError) as exc:
|
|
190
|
+
raise ConfigurationError(f"Cannot introspect the signature of {name}: {exc}") from exc
|
|
191
|
+
try:
|
|
192
|
+
hints = get_type_hints(fn, include_extras=True)
|
|
193
|
+
except (NameError, TypeError, AttributeError) as exc:
|
|
194
|
+
# An unresolvable forward reference is a wiring mistake, not a runtime condition:
|
|
195
|
+
# surface it at decoration time rather than on the first protected call.
|
|
196
|
+
raise ConfigurationError(f"Cannot resolve the annotations of {name}: {exc}") from exc
|
|
197
|
+
|
|
198
|
+
parameters: list[str] = []
|
|
199
|
+
positional_only: list[str] = []
|
|
200
|
+
required: set[str] = set()
|
|
201
|
+
# ``Any`` because ``create_model`` types its field definitions that way; the values
|
|
202
|
+
# here are always ``(annotation, default_or_Ellipsis)`` pairs.
|
|
203
|
+
fields: dict[str, Any] = {}
|
|
204
|
+
accepts_var_keyword = False
|
|
205
|
+
|
|
206
|
+
for parameter_name, parameter in signature.parameters.items():
|
|
207
|
+
if parameter.kind is inspect.Parameter.VAR_KEYWORD:
|
|
208
|
+
accepts_var_keyword = True
|
|
209
|
+
continue
|
|
210
|
+
if parameter.kind is inspect.Parameter.VAR_POSITIONAL:
|
|
211
|
+
continue
|
|
212
|
+
parameters.append(parameter_name)
|
|
213
|
+
if parameter.kind is inspect.Parameter.POSITIONAL_ONLY:
|
|
214
|
+
positional_only.append(parameter_name)
|
|
215
|
+
annotation = hints.get(parameter_name, object)
|
|
216
|
+
if parameter.default is inspect.Parameter.empty:
|
|
217
|
+
required.add(parameter_name)
|
|
218
|
+
fields[parameter_name] = (annotation, ...)
|
|
219
|
+
else:
|
|
220
|
+
fields[parameter_name] = (annotation, parameter.default)
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
model = create_model(
|
|
224
|
+
f"{getattr(fn, '__name__', 'Protected')}Arguments",
|
|
225
|
+
# ``extra="allow"`` rather than ``"forbid"``: the engine has already narrowed
|
|
226
|
+
# the mapping to this callable's own parameters, and forbidding extras would
|
|
227
|
+
# turn a policy default for a ``**kwargs`` callable into a hard failure.
|
|
228
|
+
__config__=ConfigDict(arbitrary_types_allowed=True, extra="allow"),
|
|
229
|
+
**fields,
|
|
230
|
+
)
|
|
231
|
+
except Exception as exc: # re-raised with context below, never swallowed
|
|
232
|
+
raise ConfigurationError(f"Cannot build an argument model for {name}: {exc}") from exc
|
|
233
|
+
|
|
234
|
+
return cls(
|
|
235
|
+
name=name,
|
|
236
|
+
signature=signature,
|
|
237
|
+
parameters=tuple(parameters),
|
|
238
|
+
positional_only=tuple(positional_only),
|
|
239
|
+
required=frozenset(required),
|
|
240
|
+
model=model,
|
|
241
|
+
is_async=inspect.iscoroutinefunction(fn),
|
|
242
|
+
accepts_var_keyword=accepts_var_keyword,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def bind(self, args: Sequence[object], kwargs: Mapping[str, object]) -> dict[str, object]:
|
|
246
|
+
"""Map a call's positional and keyword arguments onto parameter names.
|
|
247
|
+
|
|
248
|
+
Uses ``bind_partial``, so a call that omits arguments the policy will default is not
|
|
249
|
+
an error here.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
args: Positional arguments as passed.
|
|
253
|
+
kwargs: Keyword arguments as passed.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Parameter name to proposed value.
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
ConfigurationError: If the arguments cannot be bound to the signature at all.
|
|
260
|
+
"""
|
|
261
|
+
try:
|
|
262
|
+
bound = self.signature.bind_partial(*args, **kwargs)
|
|
263
|
+
except TypeError as exc:
|
|
264
|
+
raise ConfigurationError(f"Cannot bind arguments for {self.name}: {exc}") from exc
|
|
265
|
+
|
|
266
|
+
proposed: dict[str, object] = {}
|
|
267
|
+
for parameter_name, value in bound.arguments.items():
|
|
268
|
+
kind = self.signature.parameters[parameter_name].kind
|
|
269
|
+
if kind is inspect.Parameter.VAR_KEYWORD and isinstance(value, Mapping):
|
|
270
|
+
# ``bind_partial`` nests **kwargs under the parameter's own name. Flatten it,
|
|
271
|
+
# so policy sees the names the caller actually used and :meth:`split` can
|
|
272
|
+
# reconstruct the call.
|
|
273
|
+
proposed.update(value)
|
|
274
|
+
continue
|
|
275
|
+
proposed[parameter_name] = value
|
|
276
|
+
return proposed
|
|
277
|
+
|
|
278
|
+
def split(self, arguments: Mapping[str, object]) -> tuple[list[object], dict[str, object]]:
|
|
279
|
+
"""Split a final argument mapping back into positional and keyword form.
|
|
280
|
+
|
|
281
|
+
Necessary because positional-only parameters cannot be passed by name; calling
|
|
282
|
+
``fn(**arguments)`` would fail on them.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
arguments: The processed arguments.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Positional arguments in declaration order, and the keyword remainder.
|
|
289
|
+
"""
|
|
290
|
+
positional: list[object] = []
|
|
291
|
+
keyword: dict[str, object] = {}
|
|
292
|
+
# Values bound to ``*args`` force every preceding parameter to be passed
|
|
293
|
+
# positionally too, otherwise Python cannot reach the variadic slot.
|
|
294
|
+
variadic: tuple[object, ...] = ()
|
|
295
|
+
for parameter_name, parameter in self.signature.parameters.items():
|
|
296
|
+
if parameter.kind is inspect.Parameter.VAR_POSITIONAL:
|
|
297
|
+
value = arguments.get(parameter_name, ())
|
|
298
|
+
variadic = tuple(value) if isinstance(value, list | tuple) else (value,)
|
|
299
|
+
break
|
|
300
|
+
force_positional = bool(variadic)
|
|
301
|
+
|
|
302
|
+
# Positional slots the caller omitted are held back until a later slot turns out to
|
|
303
|
+
# be present; only then must their Python default be emitted to keep the order.
|
|
304
|
+
pending: list[object] = []
|
|
305
|
+
for parameter_name, parameter in self.signature.parameters.items():
|
|
306
|
+
if parameter.kind is inspect.Parameter.VAR_KEYWORD:
|
|
307
|
+
continue
|
|
308
|
+
if parameter.kind is inspect.Parameter.VAR_POSITIONAL:
|
|
309
|
+
positional.extend(variadic)
|
|
310
|
+
pending.clear()
|
|
311
|
+
continue
|
|
312
|
+
takes_slot = parameter.kind is inspect.Parameter.POSITIONAL_ONLY or (
|
|
313
|
+
force_positional and parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD
|
|
314
|
+
)
|
|
315
|
+
if not takes_slot:
|
|
316
|
+
if parameter_name in arguments:
|
|
317
|
+
keyword[parameter_name] = arguments[parameter_name]
|
|
318
|
+
continue
|
|
319
|
+
if parameter_name in arguments:
|
|
320
|
+
positional.extend(pending)
|
|
321
|
+
pending.clear()
|
|
322
|
+
positional.append(arguments[parameter_name])
|
|
323
|
+
elif parameter.default is not inspect.Parameter.empty:
|
|
324
|
+
pending.append(parameter.default)
|
|
325
|
+
|
|
326
|
+
# Names the signature does not declare reach here only for a ``**kwargs`` callable.
|
|
327
|
+
known = set(self.signature.parameters)
|
|
328
|
+
for parameter_name, value in arguments.items():
|
|
329
|
+
if parameter_name not in known:
|
|
330
|
+
keyword[parameter_name] = value
|
|
331
|
+
return positional, keyword
|
|
332
|
+
|
|
333
|
+
def validate(self, arguments: Mapping[str, object]) -> dict[str, object]:
|
|
334
|
+
"""Validate the final argument set against the callable's model.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
arguments: The processed arguments.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
The validated arguments, coerced by pydantic where the annotations call for it.
|
|
341
|
+
|
|
342
|
+
Raises:
|
|
343
|
+
ValidationFailed: Carrying pydantic's structured field errors.
|
|
344
|
+
"""
|
|
345
|
+
try:
|
|
346
|
+
validated = self.model(**arguments)
|
|
347
|
+
except ValidationError as exc:
|
|
348
|
+
raise ValidationFailed(
|
|
349
|
+
[dict(error) for error in exc.errors(include_url=False)]
|
|
350
|
+
) from exc
|
|
351
|
+
|
|
352
|
+
# Only the keys that were actually passed come back. Letting the model's own
|
|
353
|
+
# parameter defaults through would make the envelope report values Python would have
|
|
354
|
+
# supplied anyway, blurring the line between "policy defaulted this" and "the
|
|
355
|
+
# signature did".
|
|
356
|
+
fields = type(validated).model_fields
|
|
357
|
+
return {
|
|
358
|
+
key: (getattr(validated, key) if key in fields else value)
|
|
359
|
+
for key, value in arguments.items()
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@dataclass(frozen=True, slots=True)
|
|
364
|
+
class Plan:
|
|
365
|
+
"""A decided-but-not-yet-performed invocation.
|
|
366
|
+
|
|
367
|
+
Holding the decision separately from the side effect is what lets the same pipeline
|
|
368
|
+
answer "may this run?" for a tool manifest and "run this" for a real call.
|
|
369
|
+
|
|
370
|
+
Attributes:
|
|
371
|
+
invocation: The normalized invocation the decision was made about.
|
|
372
|
+
decision: What Cedar said.
|
|
373
|
+
settings: The effective settings for this action.
|
|
374
|
+
spec: The target callable's argument spec.
|
|
375
|
+
processed: The final arguments, filtered, defaulted and validated.
|
|
376
|
+
rejected: Arguments the policy refused.
|
|
377
|
+
defaults_applied: Arguments filled in from the principal's defaults.
|
|
378
|
+
signature_defaults: Governed parameters whose value came from the callable's own
|
|
379
|
+
signature, after the constraint that governs them checked it and accepted it.
|
|
380
|
+
Deliberately *not* merged into :attr:`processed`: that is the set sentrykit
|
|
381
|
+
decided and passed, and a signature-sourced value is one it declined to fill, so
|
|
382
|
+
folding them together would lose the distinction. Names only.
|
|
383
|
+
obligations: Obligations the policy attached.
|
|
384
|
+
trace: Debug detail, populated only at ``debug`` verbosity.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
invocation: Invocation
|
|
388
|
+
decision: CedarDecision
|
|
389
|
+
settings: Settings
|
|
390
|
+
spec: ArgumentSpec
|
|
391
|
+
processed: dict[str, object] = field(default_factory=dict)
|
|
392
|
+
rejected: tuple[RejectedArgument, ...] = ()
|
|
393
|
+
defaults_applied: dict[str, object] = field(default_factory=dict)
|
|
394
|
+
signature_defaults: tuple[str, ...] = ()
|
|
395
|
+
obligations: tuple[str, ...] = ()
|
|
396
|
+
trace: dict[str, object] = field(default_factory=dict)
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def allowed(self) -> bool:
|
|
400
|
+
"""Whether the call may proceed.
|
|
401
|
+
|
|
402
|
+
True for a clean Cedar allow, and for an engine failure that ``fail_mode="open"``
|
|
403
|
+
waved through. A clean policy deny never reaches here — :meth:`Governor.plan` raises
|
|
404
|
+
instead of returning a refused plan.
|
|
405
|
+
"""
|
|
406
|
+
if self.decision.allowed:
|
|
407
|
+
return True
|
|
408
|
+
return self.decision.failed and self.settings.fail_mode == "open"
|
|
409
|
+
|
|
410
|
+
def narrowing(self) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
411
|
+
"""Name the arguments whose proposed value did not survive, and which lost it entirely.
|
|
412
|
+
|
|
413
|
+
Narrowing is defined against the *proposal*, not against ``rejected``, because the
|
|
414
|
+
two differ in both directions. A rejection whose argument kept a surviving value of
|
|
415
|
+
the caller's own is not narrowing; a withheld default — a rejection for an argument
|
|
416
|
+
the caller never mentioned — is not narrowing either; and an argument dropped with no
|
|
417
|
+
replacement *is* narrowing even though nothing was injected to mark it.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
The narrowed parameter names, and the subset of them that reached the callable
|
|
421
|
+
with no sentrykit-supplied value — so the callable's own signature default
|
|
422
|
+
applied, which no constraint ever checked.
|
|
423
|
+
"""
|
|
424
|
+
narrowed: list[str] = []
|
|
425
|
+
unsupplied: list[str] = []
|
|
426
|
+
for name, proposed in self.invocation.proposed.items():
|
|
427
|
+
if name not in self.processed:
|
|
428
|
+
narrowed.append(name)
|
|
429
|
+
unsupplied.append(name)
|
|
430
|
+
elif not _same_value(self.processed[name], proposed):
|
|
431
|
+
narrowed.append(name)
|
|
432
|
+
return tuple(narrowed), tuple(unsupplied)
|
|
433
|
+
|
|
434
|
+
def envelope(self) -> AuthzEnvelope | None:
|
|
435
|
+
"""Build the caller-facing envelope for this plan, honoring its verbosity."""
|
|
436
|
+
narrowed, unsupplied = self.narrowing()
|
|
437
|
+
return build_envelope(
|
|
438
|
+
decision="allow" if self.allowed else "deny",
|
|
439
|
+
action=str(self.invocation.action),
|
|
440
|
+
resource=str(self.invocation.resource),
|
|
441
|
+
principal=str(self.invocation.principal.uid),
|
|
442
|
+
processed=self.processed,
|
|
443
|
+
rejected=self.rejected,
|
|
444
|
+
defaults_applied=self.defaults_applied,
|
|
445
|
+
signature_defaults=self.signature_defaults,
|
|
446
|
+
narrowed_arguments=narrowed,
|
|
447
|
+
narrowed_without_replacement=unsupplied,
|
|
448
|
+
obligations=self.obligations,
|
|
449
|
+
determining_policies=self.decision.determining_policies,
|
|
450
|
+
trace=self.trace or None,
|
|
451
|
+
verbosity=self.settings.verbosity,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class Governor:
|
|
456
|
+
"""Runs the authorization pipeline against one policy engine.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
policy: The Cedar policy set and entity graph to decide against.
|
|
460
|
+
settings: Base settings. Defaults to the process-wide configuration, resolved per
|
|
461
|
+
action at decision time so a per-action override still applies.
|
|
462
|
+
audit: Where decisions are recorded. ``None`` — the default — means no records are
|
|
463
|
+
built at all, so a governor wired without a sink costs exactly what it did before
|
|
464
|
+
the audit trail existed.
|
|
465
|
+
bundle: The loaded policy bundle, when the governor was built from a
|
|
466
|
+
:class:`~sentrykit.stores.base.PolicySource`. Supplies the per-policy annotation
|
|
467
|
+
index obligations are read from, and the provenance the audit record carries.
|
|
468
|
+
Without one, obligations stay empty and provenance is unavailable, which is
|
|
469
|
+
exactly how a governor wired from raw policy text behaves.
|
|
470
|
+
grants: The grant store principals were materialized from, when there is one. The
|
|
471
|
+
decision path never consults it — a principal arrives already materialized, by
|
|
472
|
+
way of :func:`~sentrykit.context.establish` — but the admin surface needs to
|
|
473
|
+
reach the store it is governing, and holding the wiring in one object beats a
|
|
474
|
+
second process-global.
|
|
475
|
+
"""
|
|
476
|
+
|
|
477
|
+
__slots__ = ("_audit", "_bundle", "_grants", "_policy", "_provider", "_settings")
|
|
478
|
+
|
|
479
|
+
def __init__(
|
|
480
|
+
self,
|
|
481
|
+
policy: PolicyEngine,
|
|
482
|
+
settings: Settings | None = None,
|
|
483
|
+
*,
|
|
484
|
+
audit: AuditTrail | None = None,
|
|
485
|
+
bundle: PolicyBundle | None = None,
|
|
486
|
+
grants: GrantStore | None = None,
|
|
487
|
+
context_provider: ContextProvider | None = None,
|
|
488
|
+
) -> None:
|
|
489
|
+
self._policy = policy
|
|
490
|
+
self._settings = settings
|
|
491
|
+
self._audit = audit
|
|
492
|
+
self._bundle = bundle
|
|
493
|
+
self._grants = grants
|
|
494
|
+
self._provider = context_provider
|
|
495
|
+
|
|
496
|
+
@property
|
|
497
|
+
def policy(self) -> PolicyEngine:
|
|
498
|
+
"""The policy engine this governor decides against."""
|
|
499
|
+
return self._policy
|
|
500
|
+
|
|
501
|
+
@property
|
|
502
|
+
def audit(self) -> AuditTrail | None:
|
|
503
|
+
"""The audit trail this governor records to, if any."""
|
|
504
|
+
return self._audit
|
|
505
|
+
|
|
506
|
+
@property
|
|
507
|
+
def bundle(self) -> PolicyBundle | None:
|
|
508
|
+
"""The policy bundle this governor's policies came from, if any."""
|
|
509
|
+
return self._bundle
|
|
510
|
+
|
|
511
|
+
@property
|
|
512
|
+
def grants(self) -> GrantStore | None:
|
|
513
|
+
"""The grant store backing this governor's principals, if one was wired."""
|
|
514
|
+
return self._grants
|
|
515
|
+
|
|
516
|
+
@property
|
|
517
|
+
def context_provider(self) -> ContextProvider | None:
|
|
518
|
+
"""The framework provider wired for this process, if one was.
|
|
519
|
+
|
|
520
|
+
Held here for the same reason :attr:`grants` is: the decision path never touches
|
|
521
|
+
it -- a principal reaches the governor already established -- but the boundary
|
|
522
|
+
adapter has to find the wiring somewhere, and one object beats a second
|
|
523
|
+
process-global. It is what lets ``SentrykitMiddleware(app)`` be spelled with no
|
|
524
|
+
arguments once ``install(context_provider=...)`` has run.
|
|
525
|
+
"""
|
|
526
|
+
return self._provider
|
|
527
|
+
|
|
528
|
+
def plan(
|
|
529
|
+
self,
|
|
530
|
+
spec: ArgumentSpec,
|
|
531
|
+
*,
|
|
532
|
+
principal: Principal,
|
|
533
|
+
action: EntityRef,
|
|
534
|
+
resource: EntityRef | None = None,
|
|
535
|
+
proposed: Mapping[str, object] | None = None,
|
|
536
|
+
environment: Mapping[str, object] | None = None,
|
|
537
|
+
invocation_id: str | None = None,
|
|
538
|
+
) -> Plan:
|
|
539
|
+
"""Decide an invocation without performing it.
|
|
540
|
+
|
|
541
|
+
Runs the gate, the argument filter, default injection and validation, in that order.
|
|
542
|
+
|
|
543
|
+
Args:
|
|
544
|
+
spec: The target callable's argument spec.
|
|
545
|
+
principal: Who is acting. Resolving the ambient principal — and refusing when
|
|
546
|
+
there is none — happens in the decorator, before this point.
|
|
547
|
+
action: What they propose to do.
|
|
548
|
+
resource: What they propose to do it to. Defaults to
|
|
549
|
+
``Resource::"<action id>"`` so that a callable which declares no resource is
|
|
550
|
+
still expressible in Cedar's three-part model.
|
|
551
|
+
proposed: The arguments as supplied.
|
|
552
|
+
environment: Request environment. Merged over the ambient environment, with
|
|
553
|
+
``now`` and ``channel`` filled in when absent.
|
|
554
|
+
invocation_id: Correlation id. Generated when omitted; pass one to keep an audit
|
|
555
|
+
trail deterministic in tests.
|
|
556
|
+
|
|
557
|
+
Returns:
|
|
558
|
+
The decided plan.
|
|
559
|
+
|
|
560
|
+
Raises:
|
|
561
|
+
AuthzDenied: If the gate denied, or if the engine failed under
|
|
562
|
+
``fail_mode="closed"``.
|
|
563
|
+
ArgumentRejected: If an argument violated its constraint under
|
|
564
|
+
``arg_handling="strict"``.
|
|
565
|
+
NoValidArguments: If the caller supplied arguments, every one was rejected, and
|
|
566
|
+
defaults cannot complete the call.
|
|
567
|
+
DefaultUnavailable: If a required argument has neither a supplied value nor a
|
|
568
|
+
default.
|
|
569
|
+
ValidationFailed: If the final argument set does not satisfy the callable's
|
|
570
|
+
model.
|
|
571
|
+
"""
|
|
572
|
+
settings = apply_overrides(self._settings or get_settings(), action.id)
|
|
573
|
+
resource = resource or EntityRef(DEFAULT_RESOURCE_TYPE, action.id)
|
|
574
|
+
invocation_id = invocation_id or uuid4().hex
|
|
575
|
+
proposed_arguments = dict(proposed or {})
|
|
576
|
+
env = merge_environment(current_environment(), environment, proposed=proposed_arguments)
|
|
577
|
+
|
|
578
|
+
context = {
|
|
579
|
+
"invocation_id": invocation_id,
|
|
580
|
+
"action": str(action),
|
|
581
|
+
"resource": str(resource),
|
|
582
|
+
"principal": str(principal.uid),
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
# Built before the gate rather than alongside the returned plan, because a refusal has
|
|
586
|
+
# to be as auditable as an allow and the record describes an *attempted* invocation
|
|
587
|
+
# either way.
|
|
588
|
+
invocation = Invocation(
|
|
589
|
+
id=invocation_id,
|
|
590
|
+
principal=principal,
|
|
591
|
+
action=action,
|
|
592
|
+
resource=resource,
|
|
593
|
+
proposed=proposed_arguments,
|
|
594
|
+
environment=env,
|
|
595
|
+
callable_name=spec.name,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
try:
|
|
599
|
+
decision = self._policy.evaluate(
|
|
600
|
+
CedarRequest(
|
|
601
|
+
principal=principal.uid,
|
|
602
|
+
action=action,
|
|
603
|
+
resource=resource,
|
|
604
|
+
context=env,
|
|
605
|
+
# The principal snapshot is a per-request delta so a concurrent decision
|
|
606
|
+
# for a different principal never sees it.
|
|
607
|
+
entities=(
|
|
608
|
+
Entity(principal.uid, principal.attributes, principal.all_parents()),
|
|
609
|
+
),
|
|
610
|
+
correlation_id=invocation_id,
|
|
611
|
+
)
|
|
612
|
+
)
|
|
613
|
+
except ReservedKey as exc:
|
|
614
|
+
# Refused before Cedar is consulted, so there is no Cedar verdict to report and
|
|
615
|
+
# nothing for ``fail_mode`` to arbitrate: the request is forged, not the policy
|
|
616
|
+
# set broken, and fail-open exists to tolerate operator error rather than caller
|
|
617
|
+
# input. The synthesized decision exists only so the refusal is as auditable as
|
|
618
|
+
# any other -- a clean deny with no determining policy, which is what a default
|
|
619
|
+
# deny already looks like.
|
|
620
|
+
_LOGGER.error(
|
|
621
|
+
"Supplied data spelled a reserved Cedar key; refusing the request",
|
|
622
|
+
extra={**context, "reserved_key": exc.key, "path": exc.path},
|
|
623
|
+
)
|
|
624
|
+
refused = CedarDecision(
|
|
625
|
+
allowed=False,
|
|
626
|
+
decision="deny",
|
|
627
|
+
correlation_id=invocation_id,
|
|
628
|
+
policy_set_hash=self._policy.policy_set_hash,
|
|
629
|
+
)
|
|
630
|
+
self._audit_refusal(invocation, refused, spec, exc)
|
|
631
|
+
raise
|
|
632
|
+
|
|
633
|
+
rejected: tuple[RejectedArgument, ...] = ()
|
|
634
|
+
try:
|
|
635
|
+
self._gate(decision, settings, action, resource, principal, context, env)
|
|
636
|
+
|
|
637
|
+
obligations = self._obligations(decision, allowed=True)
|
|
638
|
+
granted = principal.constraints_for(action)
|
|
639
|
+
ceilings = self._ceilings(decision)
|
|
640
|
+
# Two views of the same layering, each doing what it is good at. Filtering runs
|
|
641
|
+
# layer by layer so every rejection keeps the id of the grant or the reviewed
|
|
642
|
+
# policy that produced it. ``constraints`` is the intersection, and it is what
|
|
643
|
+
# decides defaults, required arguments and the "here is what you may pass" half
|
|
644
|
+
# of an error -- questions that need the combined answer rather than a trail.
|
|
645
|
+
constraints = granted
|
|
646
|
+
for ceiling in ceilings:
|
|
647
|
+
constraints = constraints.intersected_with(ceiling)
|
|
648
|
+
|
|
649
|
+
kept, rejected = granted.filter(proposed_arguments)
|
|
650
|
+
for ceiling in ceilings:
|
|
651
|
+
kept, narrowed = ceiling.filter(kept)
|
|
652
|
+
rejected += narrowed
|
|
653
|
+
if rejected:
|
|
654
|
+
self._report_rejections(rejected, settings, context)
|
|
655
|
+
|
|
656
|
+
if proposed_arguments and not kept:
|
|
657
|
+
# Every argument the caller asked for was refused. Completing the call from
|
|
658
|
+
# defaults would answer a question they did not ask, so refuse instead.
|
|
659
|
+
_LOGGER.info(
|
|
660
|
+
"Every proposed argument was rejected",
|
|
661
|
+
extra={**context, "arguments": sorted(proposed_arguments)},
|
|
662
|
+
)
|
|
663
|
+
raise NoValidArguments(
|
|
664
|
+
allowed=constraints.describe(),
|
|
665
|
+
rejected=[item.as_detail() for item in rejected],
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
# ``accept_unlisted`` is what makes ``accepts_var_keyword`` mean something. A
|
|
669
|
+
# callable declaring ``**kwargs`` accepts names its signature does not list, so a
|
|
670
|
+
# default for one of them is deliverable -- and without this the constraint on
|
|
671
|
+
# such a name was a pure reject-everything rule: the proposal was filtered, the
|
|
672
|
+
# default was skipped for not being a parameter, and the call died with nothing
|
|
673
|
+
# left to run on.
|
|
674
|
+
arguments, defaults_applied, withheld = constraints.inject_defaults(
|
|
675
|
+
kept, spec.parameters, accept_unlisted=spec.accepts_var_keyword
|
|
676
|
+
)
|
|
677
|
+
if withheld:
|
|
678
|
+
# Reported alongside the caller's own rejections so the envelope and the
|
|
679
|
+
# audit record show why an argument has no value -- but deliberately not
|
|
680
|
+
# routed through ``_report_rejections``: ``arg_handling`` arbitrates what
|
|
681
|
+
# happens to the *caller's proposal*, and an unusable default is not theirs.
|
|
682
|
+
rejected += withheld
|
|
683
|
+
|
|
684
|
+
missing = self._missing_arguments(spec, constraints, arguments)
|
|
685
|
+
if missing:
|
|
686
|
+
unknown = sorted(
|
|
687
|
+
name
|
|
688
|
+
for name in missing
|
|
689
|
+
if name not in spec.parameters and not spec.accepts_var_keyword
|
|
690
|
+
)
|
|
691
|
+
if unknown:
|
|
692
|
+
# The operator's actual mistake, which the refusal alone does not name: a
|
|
693
|
+
# grant or a reviewed ceiling requires an argument this callable has no
|
|
694
|
+
# parameter for. One transposed character -- ``limti`` for ``limit`` --
|
|
695
|
+
# refuses every call to the action, and the error names an argument that
|
|
696
|
+
# cannot exist. The refusal stands; the reason is now on the record.
|
|
697
|
+
_LOGGER.error(
|
|
698
|
+
"A required constraint names an argument the callable does not accept",
|
|
699
|
+
extra={**context, "arguments": unknown, "callable": spec.name},
|
|
700
|
+
)
|
|
701
|
+
_LOGGER.info(
|
|
702
|
+
"Required argument has no value and no default",
|
|
703
|
+
extra={**context, "arguments": list(missing)},
|
|
704
|
+
)
|
|
705
|
+
raise DefaultUnavailable(missing[0])
|
|
706
|
+
|
|
707
|
+
# The slot nobody filled. Everything above decides what sentrykit *passes*; what
|
|
708
|
+
# Python does with a parameter it was passed nothing for is fill it from the
|
|
709
|
+
# signature -- a value no constraint had ever seen. ADR-027's rule is that a
|
|
710
|
+
# default answers to the constraint that governs it; this applies the same rule
|
|
711
|
+
# to a default from the other source. Last, so a call that some other rule
|
|
712
|
+
# already refuses keeps the error it has always raised.
|
|
713
|
+
signature_defaults = self._check_signature_defaults(
|
|
714
|
+
spec, constraints, ceilings, arguments, context
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
processed = spec.validate(arguments)
|
|
718
|
+
except SentrykitError as exc:
|
|
719
|
+
self._audit_refusal(invocation, decision, spec, exc, rejected=rejected)
|
|
720
|
+
raise
|
|
721
|
+
|
|
722
|
+
trace: dict[str, object] = {}
|
|
723
|
+
if settings.verbosity == "debug":
|
|
724
|
+
trace = {
|
|
725
|
+
**decision.as_trace(),
|
|
726
|
+
"invocation_id": invocation_id,
|
|
727
|
+
"channel": env.get("channel", "internal"),
|
|
728
|
+
"settings": settings.model_dump(),
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
_LOGGER.debug(
|
|
732
|
+
"Authorized invocation",
|
|
733
|
+
extra={
|
|
734
|
+
**context,
|
|
735
|
+
"decision": decision.decision,
|
|
736
|
+
"arguments": sorted(processed),
|
|
737
|
+
"defaulted": sorted(defaults_applied),
|
|
738
|
+
"rejected": [item.name for item in rejected],
|
|
739
|
+
},
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
plan = Plan(
|
|
743
|
+
invocation=invocation,
|
|
744
|
+
decision=decision,
|
|
745
|
+
settings=settings,
|
|
746
|
+
spec=spec,
|
|
747
|
+
processed=processed,
|
|
748
|
+
rejected=rejected,
|
|
749
|
+
defaults_applied=defaults_applied,
|
|
750
|
+
signature_defaults=signature_defaults,
|
|
751
|
+
obligations=obligations,
|
|
752
|
+
trace=trace,
|
|
753
|
+
)
|
|
754
|
+
self._audit_decision(plan)
|
|
755
|
+
return plan
|
|
756
|
+
|
|
757
|
+
def _gate(
|
|
758
|
+
self,
|
|
759
|
+
decision: CedarDecision,
|
|
760
|
+
settings: Settings,
|
|
761
|
+
action: EntityRef,
|
|
762
|
+
resource: EntityRef,
|
|
763
|
+
principal: Principal,
|
|
764
|
+
context: Mapping[str, str],
|
|
765
|
+
environment: Mapping[str, object],
|
|
766
|
+
) -> None:
|
|
767
|
+
"""Apply the coarse Cedar gate, refusing unless the call may proceed.
|
|
768
|
+
|
|
769
|
+
Separates the two ways a call fails to be authorized. An *engine failure* — a Cedar
|
|
770
|
+
error, or ``no-decision`` — is a bug in the policy set and is logged at ``ERROR``;
|
|
771
|
+
``fail_mode`` decides whether it also refuses. A plain *policy denial* is normal
|
|
772
|
+
operation, logged at ``INFO``, and refuses under either fail mode.
|
|
773
|
+
|
|
774
|
+
``fail_mode="open"`` has one exception, and it lives here rather than at wiring time
|
|
775
|
+
because ``fail_mode`` is overridable per action on every call. A failed decision
|
|
776
|
+
reports *no* determining policies, so every argument ceiling the policy set declares
|
|
777
|
+
silently stops applying — the call would proceed unbounded at exactly the moment the
|
|
778
|
+
engine is least trustworthy. A governor over a bundle that declares any ceiling
|
|
779
|
+
therefore refuses an engine failure under either fail mode.
|
|
780
|
+
|
|
781
|
+
Args:
|
|
782
|
+
decision: What Cedar returned.
|
|
783
|
+
settings: The effective settings for this action.
|
|
784
|
+
action: The action being authorized.
|
|
785
|
+
resource: The resource it was authorized against.
|
|
786
|
+
principal: Who is acting.
|
|
787
|
+
context: Pre-built log context.
|
|
788
|
+
environment: The merged Cedar request context, for the debug trace.
|
|
789
|
+
|
|
790
|
+
Raises:
|
|
791
|
+
AuthzDenied: If the gate denied, or the engine failed under ``fail_mode="closed"``.
|
|
792
|
+
"""
|
|
793
|
+
if decision.failed:
|
|
794
|
+
_LOGGER.error(
|
|
795
|
+
"Cedar evaluation failed",
|
|
796
|
+
extra={
|
|
797
|
+
**context,
|
|
798
|
+
"decision": decision.decision,
|
|
799
|
+
"engine_errors": list(decision.errors),
|
|
800
|
+
"fail_mode": settings.fail_mode,
|
|
801
|
+
},
|
|
802
|
+
)
|
|
803
|
+
declares_ceilings = self._bundle is not None and self._bundle.declares_ceilings
|
|
804
|
+
if settings.fail_mode == "closed" or declares_ceilings:
|
|
805
|
+
self._refuse(
|
|
806
|
+
decision,
|
|
807
|
+
settings,
|
|
808
|
+
action,
|
|
809
|
+
resource,
|
|
810
|
+
principal,
|
|
811
|
+
environment,
|
|
812
|
+
invocation_id=context["invocation_id"],
|
|
813
|
+
reason=(
|
|
814
|
+
"Authorization engine failed and the policy set declares argument "
|
|
815
|
+
"ceilings, which a failed decision cannot resolve"
|
|
816
|
+
if declares_ceilings
|
|
817
|
+
else "Authorization engine failed; refused under fail_mode=closed"
|
|
818
|
+
),
|
|
819
|
+
engine_errors=list(decision.errors),
|
|
820
|
+
)
|
|
821
|
+
# fail_mode="open": proceed, with the errors preserved in the debug trace.
|
|
822
|
+
return
|
|
823
|
+
|
|
824
|
+
if decision.allowed:
|
|
825
|
+
return
|
|
826
|
+
|
|
827
|
+
_LOGGER.info(
|
|
828
|
+
"Authorization denied",
|
|
829
|
+
extra={
|
|
830
|
+
**context,
|
|
831
|
+
"decision": decision.decision,
|
|
832
|
+
"determining_policies": list(decision.determining_policies),
|
|
833
|
+
},
|
|
834
|
+
)
|
|
835
|
+
self._refuse(
|
|
836
|
+
decision,
|
|
837
|
+
settings,
|
|
838
|
+
action,
|
|
839
|
+
resource,
|
|
840
|
+
principal,
|
|
841
|
+
environment,
|
|
842
|
+
invocation_id=context["invocation_id"],
|
|
843
|
+
reason=(
|
|
844
|
+
"Denied by an explicit forbid policy"
|
|
845
|
+
if decision.determining_policies
|
|
846
|
+
else "No policy permits this action"
|
|
847
|
+
),
|
|
848
|
+
engine_errors=None,
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
@staticmethod
|
|
852
|
+
def _refuse(
|
|
853
|
+
decision: CedarDecision,
|
|
854
|
+
settings: Settings,
|
|
855
|
+
action: EntityRef,
|
|
856
|
+
resource: EntityRef,
|
|
857
|
+
principal: Principal,
|
|
858
|
+
environment: Mapping[str, object],
|
|
859
|
+
*,
|
|
860
|
+
invocation_id: str,
|
|
861
|
+
reason: str,
|
|
862
|
+
engine_errors: list[str] | None,
|
|
863
|
+
) -> NoReturn:
|
|
864
|
+
"""Record the deny envelope and raise.
|
|
865
|
+
|
|
866
|
+
The envelope is recorded even though nothing is returned to the caller: a framework
|
|
867
|
+
provider rendering the 403 reads it from :func:`~sentrykit.envelope.last_envelope`,
|
|
868
|
+
so a denial is as reportable as an allow.
|
|
869
|
+
|
|
870
|
+
Args:
|
|
871
|
+
decision: What Cedar returned.
|
|
872
|
+
settings: The effective settings for this action.
|
|
873
|
+
action: The action that was refused.
|
|
874
|
+
resource: The resource it was refused against.
|
|
875
|
+
principal: Who was acting.
|
|
876
|
+
environment: The merged Cedar request context, for the debug trace.
|
|
877
|
+
invocation_id: Correlation id, so the deny trace joins the operational logs.
|
|
878
|
+
reason: One-sentence explanation for the envelope.
|
|
879
|
+
engine_errors: Cedar errors when this refusal came from an engine failure, and
|
|
880
|
+
``None`` when it is an ordinary policy denial.
|
|
881
|
+
|
|
882
|
+
Raises:
|
|
883
|
+
AuthzDenied: Always.
|
|
884
|
+
"""
|
|
885
|
+
trace: dict[str, object] | None = None
|
|
886
|
+
if settings.verbosity == "debug":
|
|
887
|
+
trace = {
|
|
888
|
+
**decision.as_trace(),
|
|
889
|
+
"invocation_id": invocation_id,
|
|
890
|
+
"channel": environment.get("channel", "internal"),
|
|
891
|
+
"settings": settings.model_dump(),
|
|
892
|
+
}
|
|
893
|
+
set_last_envelope(
|
|
894
|
+
build_envelope(
|
|
895
|
+
decision="deny",
|
|
896
|
+
action=str(action),
|
|
897
|
+
resource=str(resource),
|
|
898
|
+
principal=str(principal.uid),
|
|
899
|
+
processed={},
|
|
900
|
+
rejected=(),
|
|
901
|
+
defaults_applied={},
|
|
902
|
+
reason=reason,
|
|
903
|
+
determining_policies=decision.determining_policies,
|
|
904
|
+
trace=trace,
|
|
905
|
+
verbosity=settings.verbosity,
|
|
906
|
+
)
|
|
907
|
+
)
|
|
908
|
+
raise AuthzDenied(
|
|
909
|
+
str(action),
|
|
910
|
+
str(resource),
|
|
911
|
+
list(decision.determining_policies),
|
|
912
|
+
engine_errors=engine_errors,
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
@staticmethod
|
|
916
|
+
def _report_rejections(
|
|
917
|
+
rejected: tuple[RejectedArgument, ...],
|
|
918
|
+
settings: Settings,
|
|
919
|
+
context: Mapping[str, str],
|
|
920
|
+
) -> None:
|
|
921
|
+
"""Log, and under ``strict`` handling raise on, the arguments policy refused.
|
|
922
|
+
|
|
923
|
+
Only names, rules and reasons are logged. The offending *value* is exactly the kind
|
|
924
|
+
of caller-supplied data the logging standards keep out of the operational channel.
|
|
925
|
+
|
|
926
|
+
Args:
|
|
927
|
+
rejected: The rejections the constraint set produced. Never empty.
|
|
928
|
+
settings: The effective settings for this action.
|
|
929
|
+
context: Pre-built log context.
|
|
930
|
+
|
|
931
|
+
Raises:
|
|
932
|
+
ArgumentRejected: If ``arg_handling`` is ``strict``.
|
|
933
|
+
"""
|
|
934
|
+
if settings.arg_handling == "strict":
|
|
935
|
+
first = rejected[0]
|
|
936
|
+
_LOGGER.info(
|
|
937
|
+
"Argument rejected under strict handling",
|
|
938
|
+
extra={**context, "argument": first.name, "rule": first.rule},
|
|
939
|
+
)
|
|
940
|
+
raise ArgumentRejected(first.name, first.reason, first.policy)
|
|
941
|
+
for item in rejected:
|
|
942
|
+
_LOGGER.warning(
|
|
943
|
+
"Argument dropped by policy",
|
|
944
|
+
extra={
|
|
945
|
+
**context,
|
|
946
|
+
"argument": item.name,
|
|
947
|
+
"rule": item.rule,
|
|
948
|
+
"reason": item.reason,
|
|
949
|
+
"policy": item.policy,
|
|
950
|
+
},
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
@staticmethod
|
|
954
|
+
def _check_signature_defaults(
|
|
955
|
+
spec: ArgumentSpec,
|
|
956
|
+
constraints: ConstraintSet,
|
|
957
|
+
ceilings: Sequence[ConstraintSet],
|
|
958
|
+
arguments: Mapping[str, object],
|
|
959
|
+
context: Mapping[str, object],
|
|
960
|
+
) -> tuple[str, ...]:
|
|
961
|
+
"""Hold the callable's own default to the constraint that governs its parameter.
|
|
962
|
+
|
|
963
|
+
Runs on the gap the rest of the pipeline leaves: a parameter with a constraint, no
|
|
964
|
+
surviving caller value and no policy default. ``arguments`` has no entry for it, so
|
|
965
|
+
:meth:`ArgumentSpec.validate` and :meth:`ArgumentSpec.split` both omit it and Python
|
|
966
|
+
fills the slot from the signature. That value had never been checked, which is how a
|
|
967
|
+
declared bound could be exceeded — and exceeded *upward*, since a caller asking for
|
|
968
|
+
more than a ceiling allows got the signature's number instead of the ceiling's.
|
|
969
|
+
|
|
970
|
+
Nothing here changes ``arguments``. A default that satisfies its constraint is
|
|
971
|
+
reported and left to Python exactly as before, so a correct deployment runs
|
|
972
|
+
identically; one that does not is refused.
|
|
973
|
+
|
|
974
|
+
Ceilings are checked first and separately, because the two sources mean different
|
|
975
|
+
things to whoever has to act on the refusal. A ceiling lives in reviewed,
|
|
976
|
+
version-controlled policy text, so a conflict with the signature is a disagreement
|
|
977
|
+
between an operator's policy file and their own code, it affects every caller of the
|
|
978
|
+
action equally, and it is worth saying so in those terms. A grant is per-principal
|
|
979
|
+
mutable data, so the same conflict there is ordinary authorization.
|
|
980
|
+
|
|
981
|
+
Args:
|
|
982
|
+
spec: The target callable's argument spec, read for its signature defaults.
|
|
983
|
+
constraints: The resolved grant-intersect-ceiling set.
|
|
984
|
+
ceilings: The ceilings on their own, for the provenance distinction above.
|
|
985
|
+
arguments: The completed argument mapping, after defaults were injected.
|
|
986
|
+
context: Log context for the operational channel.
|
|
987
|
+
|
|
988
|
+
Returns:
|
|
989
|
+
The governed parameters whose signature default was checked and accepted.
|
|
990
|
+
|
|
991
|
+
Raises:
|
|
992
|
+
SignatureDefaultRejected: If a governed parameter's only available value is one
|
|
993
|
+
the constraint refuses.
|
|
994
|
+
"""
|
|
995
|
+
gaps = {
|
|
996
|
+
name: parameter.default
|
|
997
|
+
for name, constraint in constraints.arguments.items()
|
|
998
|
+
if name not in arguments
|
|
999
|
+
# ``bool(constraint)`` is not the test: an empty constraint governs nothing, but
|
|
1000
|
+
# ``arguments`` is keyed by name and a name present here with no rules cannot
|
|
1001
|
+
# refuse anything either, so ``check`` returning ``None`` covers both.
|
|
1002
|
+
and (parameter := spec.signature.parameters.get(name)) is not None
|
|
1003
|
+
and parameter.default is not inspect.Parameter.empty
|
|
1004
|
+
}
|
|
1005
|
+
if not gaps:
|
|
1006
|
+
return ()
|
|
1007
|
+
|
|
1008
|
+
for ceiling in ceilings:
|
|
1009
|
+
for name, default in gaps.items():
|
|
1010
|
+
declared = ceiling.arguments.get(name)
|
|
1011
|
+
if declared is None:
|
|
1012
|
+
continue
|
|
1013
|
+
rejection = declared.check(name, default)
|
|
1014
|
+
if rejection is not None:
|
|
1015
|
+
_LOGGER.error(
|
|
1016
|
+
"A declared ceiling and the callable's own default disagree",
|
|
1017
|
+
extra={**context, "argument": name, "rule": rejection.rule},
|
|
1018
|
+
)
|
|
1019
|
+
raise SignatureDefaultRejected(
|
|
1020
|
+
name, rejection.reason, declared.policy, declared=True
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
accepted: list[str] = []
|
|
1024
|
+
for name, default in gaps.items():
|
|
1025
|
+
constraint = constraints.arguments[name]
|
|
1026
|
+
rejection = constraint.check(name, default)
|
|
1027
|
+
if rejection is None:
|
|
1028
|
+
accepted.append(name)
|
|
1029
|
+
continue
|
|
1030
|
+
_LOGGER.info(
|
|
1031
|
+
"No value remains for a governed argument",
|
|
1032
|
+
extra={**context, "argument": name, "rule": rejection.rule},
|
|
1033
|
+
)
|
|
1034
|
+
raise SignatureDefaultRejected(name, rejection.reason, constraint.policy)
|
|
1035
|
+
return tuple(accepted)
|
|
1036
|
+
|
|
1037
|
+
@staticmethod
|
|
1038
|
+
def _missing_arguments(
|
|
1039
|
+
spec: ArgumentSpec,
|
|
1040
|
+
constraints: ConstraintSet,
|
|
1041
|
+
arguments: Mapping[str, object],
|
|
1042
|
+
) -> tuple[str, ...]:
|
|
1043
|
+
"""Name the arguments that still have no value once defaults have been injected.
|
|
1044
|
+
|
|
1045
|
+
Two sources, because they mean different things: ``spec.required`` is what *Python*
|
|
1046
|
+
cannot call without, and ``ConstraintSet.missing_required`` is what *policy* insists
|
|
1047
|
+
on. Either one unsatisfied is a :class:`~sentrykit.errors.DefaultUnavailable`.
|
|
1048
|
+
|
|
1049
|
+
Args:
|
|
1050
|
+
spec: The target callable's argument spec.
|
|
1051
|
+
constraints: The principal's constraints for this action.
|
|
1052
|
+
arguments: The completed argument mapping.
|
|
1053
|
+
|
|
1054
|
+
Returns:
|
|
1055
|
+
The missing names, ordered by the callable's declaration order so the error
|
|
1056
|
+
names the first hole a reader would notice.
|
|
1057
|
+
"""
|
|
1058
|
+
missing = {name for name in spec.required if name not in arguments}
|
|
1059
|
+
# Deliberately NOT filtered by the signature, unlike ``inject_defaults``. That filter
|
|
1060
|
+
# exists so a stale grant cannot make a call fail with an unexpected keyword
|
|
1061
|
+
# argument, which is a different concern: a ``required`` rule naming a parameter the
|
|
1062
|
+
# callable does not accept can never be satisfied, and refusing is the fail-closed
|
|
1063
|
+
# answer. Dropping it would mean an operator's rule silently did not apply -- the
|
|
1064
|
+
# same class of fault as a governed argument falling through to its signature
|
|
1065
|
+
# default. What the operator needs is a diagnosis, which is below, not leniency.
|
|
1066
|
+
missing.update(constraints.missing_required(arguments))
|
|
1067
|
+
if not missing:
|
|
1068
|
+
return ()
|
|
1069
|
+
ordered = [name for name in spec.parameters if name in missing]
|
|
1070
|
+
ordered.extend(sorted(name for name in missing if name not in spec.parameters))
|
|
1071
|
+
return tuple(ordered)
|
|
1072
|
+
|
|
1073
|
+
# --- audit ---------------------------------------------------------------------------
|
|
1074
|
+
#
|
|
1075
|
+
# The whole reach of the audit trail into the decision pipeline is these three methods
|
|
1076
|
+
# plus the two calls that reach them. They are no-ops unless a trail was wired, so a bare
|
|
1077
|
+
# ``Governor(policy_engine)`` takes exactly the path it would without auditing at all,
|
|
1078
|
+
# allocating nothing and touching no masking code.
|
|
1079
|
+
|
|
1080
|
+
def _ceilings(self, decision: CedarDecision) -> tuple[ConstraintSet, ...]:
|
|
1081
|
+
"""The argument ceilings declared by the policies that permitted this call.
|
|
1082
|
+
|
|
1083
|
+
Empty for a governor wired without a bundle — a bare
|
|
1084
|
+
:class:`~sentrykit.cedar.PolicyEngine` carries policy text but not the annotation
|
|
1085
|
+
index a ceiling is read out of.
|
|
1086
|
+
|
|
1087
|
+
Args:
|
|
1088
|
+
decision: The Cedar decision, whose determining policies name the declarants.
|
|
1089
|
+
|
|
1090
|
+
Returns:
|
|
1091
|
+
One set per contributing policy, in the order Cedar reported them.
|
|
1092
|
+
"""
|
|
1093
|
+
if self._bundle is None:
|
|
1094
|
+
return ()
|
|
1095
|
+
return self._bundle.ceilings_for(decision.determining_policies)
|
|
1096
|
+
|
|
1097
|
+
def _obligations(self, decision: CedarDecision, *, allowed: bool) -> tuple[str, ...]:
|
|
1098
|
+
"""Read the obligations the determining policies attached to this decision.
|
|
1099
|
+
|
|
1100
|
+
Obligations live in an ``@obligations`` annotation, which Cedar exposes only through
|
|
1101
|
+
the policy *text*. The bundle owns that index, so a governor wired from raw policy
|
|
1102
|
+
text — with no bundle — reports no obligations.
|
|
1103
|
+
|
|
1104
|
+
Args:
|
|
1105
|
+
decision: What Cedar returned.
|
|
1106
|
+
allowed: Whether the call is proceeding, which selects the determining permit
|
|
1107
|
+
policies rather than the determining forbids.
|
|
1108
|
+
|
|
1109
|
+
Returns:
|
|
1110
|
+
The obligations, or empty when no bundle is wired.
|
|
1111
|
+
"""
|
|
1112
|
+
if self._bundle is None:
|
|
1113
|
+
return ()
|
|
1114
|
+
return self._bundle.obligations_for(decision.determining_policies, allowed=allowed)
|
|
1115
|
+
|
|
1116
|
+
def _declared_sensitive(self, spec: ArgumentSpec) -> dict[str, MaskStrategyName | None]:
|
|
1117
|
+
"""Read the ``Sensitive`` markers off the protected callable's signature.
|
|
1118
|
+
|
|
1119
|
+
Computed only when a trail is wired: introspecting the argument model costs more than
|
|
1120
|
+
the decision does, and an unaudited governor has no use for the answer.
|
|
1121
|
+
"""
|
|
1122
|
+
return sensitive_fields(spec.model)
|
|
1123
|
+
|
|
1124
|
+
def _audit_decision(self, plan: Plan) -> None:
|
|
1125
|
+
"""Record a decision that permitted the call.
|
|
1126
|
+
|
|
1127
|
+
A sink failure propagates here. That is the point: under ``fail_mode="closed"`` an
|
|
1128
|
+
action whose authorization cannot be proven must not happen, so the
|
|
1129
|
+
:class:`~sentrykit.errors.AuditUnavailable` refuses the call before the side effect
|
|
1130
|
+
rather than after it.
|
|
1131
|
+
"""
|
|
1132
|
+
trail = self._audit
|
|
1133
|
+
if trail is None or not trail.enabled:
|
|
1134
|
+
return
|
|
1135
|
+
trail.record(
|
|
1136
|
+
build_facts(
|
|
1137
|
+
invocation=plan.invocation,
|
|
1138
|
+
decision=plan.decision,
|
|
1139
|
+
outcome="allowed",
|
|
1140
|
+
allowed=True,
|
|
1141
|
+
processed=plan.processed,
|
|
1142
|
+
rejected=plan.rejected,
|
|
1143
|
+
defaults_applied=plan.defaults_applied,
|
|
1144
|
+
signature_defaults=plan.signature_defaults,
|
|
1145
|
+
obligations=plan.obligations,
|
|
1146
|
+
declared_sensitive=self._declared_sensitive(plan.spec),
|
|
1147
|
+
)
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1150
|
+
def _audit_refusal(
|
|
1151
|
+
self,
|
|
1152
|
+
invocation: Invocation,
|
|
1153
|
+
decision: CedarDecision,
|
|
1154
|
+
spec: ArgumentSpec,
|
|
1155
|
+
error: SentrykitError,
|
|
1156
|
+
*,
|
|
1157
|
+
rejected: tuple[RejectedArgument, ...] = (),
|
|
1158
|
+
) -> None:
|
|
1159
|
+
"""Record a decision that refused the call.
|
|
1160
|
+
|
|
1161
|
+
Unlike :meth:`_audit_decision`, a sink failure here is logged and swallowed. The call
|
|
1162
|
+
is already being refused, so raising
|
|
1163
|
+
:class:`~sentrykit.errors.AuditUnavailable` would replace a precise
|
|
1164
|
+
:class:`~sentrykit.errors.AuthzDenied` — the answer the caller actually needs — with
|
|
1165
|
+
an operational error. Failing closed is already the outcome; the only thing left to
|
|
1166
|
+
protect is the diagnosis.
|
|
1167
|
+
"""
|
|
1168
|
+
trail = self._audit
|
|
1169
|
+
if trail is None or not trail.enabled:
|
|
1170
|
+
return
|
|
1171
|
+
outcome: AuditOutcome = _REFUSAL_OUTCOMES.get(type(error), "denied")
|
|
1172
|
+
try:
|
|
1173
|
+
trail.record(
|
|
1174
|
+
build_facts(
|
|
1175
|
+
invocation=invocation,
|
|
1176
|
+
decision=decision,
|
|
1177
|
+
outcome=outcome,
|
|
1178
|
+
allowed=False,
|
|
1179
|
+
reason=str(error),
|
|
1180
|
+
rejected=rejected,
|
|
1181
|
+
obligations=self._obligations(decision, allowed=False),
|
|
1182
|
+
declared_sensitive=self._declared_sensitive(spec),
|
|
1183
|
+
)
|
|
1184
|
+
)
|
|
1185
|
+
except AuditUnavailable as audit_failure:
|
|
1186
|
+
# Attached to the error as well as logged, because the operational channel is
|
|
1187
|
+
# deliberately not the audit trail: a refusal whose only trace is an ERROR line
|
|
1188
|
+
# is a refusal nobody can prove happened. Carrying it on the exception puts the
|
|
1189
|
+
# fact where the refusal is rendered, so whoever answers the caller can say the
|
|
1190
|
+
# boundary was tested and the test went unrecorded.
|
|
1191
|
+
error.audit_failure = audit_failure
|
|
1192
|
+
_LOGGER.error(
|
|
1193
|
+
"Audit sink rejected a refusal record; the refusal itself still stands",
|
|
1194
|
+
extra={
|
|
1195
|
+
"invocation_id": invocation.id,
|
|
1196
|
+
"action": str(invocation.action),
|
|
1197
|
+
"outcome": outcome,
|
|
1198
|
+
},
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
def invoke(self, fn: Callable[..., T], plan: Plan) -> T:
|
|
1202
|
+
"""Perform a planned call and attach its envelope.
|
|
1203
|
+
|
|
1204
|
+
Args:
|
|
1205
|
+
fn: The protected callable.
|
|
1206
|
+
plan: The :class:`Plan` produced by :meth:`plan` for this call.
|
|
1207
|
+
|
|
1208
|
+
Returns:
|
|
1209
|
+
Whatever ``fn`` returned, with the ``_authz`` envelope attached when the result
|
|
1210
|
+
is a mapping and ``envelope_on_success`` is set.
|
|
1211
|
+
"""
|
|
1212
|
+
positional, keyword = plan.spec.split(plan.processed)
|
|
1213
|
+
result = fn(*positional, **keyword)
|
|
1214
|
+
return attach_envelope(result, self._success_envelope(plan))
|
|
1215
|
+
|
|
1216
|
+
async def ainvoke(self, fn: Callable[..., Awaitable[T]], plan: Plan) -> T:
|
|
1217
|
+
"""Await a planned call and attach its envelope.
|
|
1218
|
+
|
|
1219
|
+
The decision itself is synchronous — Cedar evaluation is in-process and cheap — so
|
|
1220
|
+
this differs from :meth:`invoke` only in awaiting the callable. Hosts that need the
|
|
1221
|
+
decision off the event loop wrap it with
|
|
1222
|
+
:func:`~sentrykit.context.bind_context`.
|
|
1223
|
+
|
|
1224
|
+
Args:
|
|
1225
|
+
fn: The protected coroutine function.
|
|
1226
|
+
plan: The :class:`Plan` produced by :meth:`plan` for this call.
|
|
1227
|
+
|
|
1228
|
+
Returns:
|
|
1229
|
+
Whatever ``fn`` resolved to, with the envelope attached where applicable.
|
|
1230
|
+
"""
|
|
1231
|
+
positional, keyword = plan.spec.split(plan.processed)
|
|
1232
|
+
result = await fn(*positional, **keyword)
|
|
1233
|
+
return attach_envelope(result, self._success_envelope(plan))
|
|
1234
|
+
|
|
1235
|
+
@staticmethod
|
|
1236
|
+
def _success_envelope(plan: Plan) -> AuthzEnvelope | None:
|
|
1237
|
+
"""Build and record the envelope for a call that ran.
|
|
1238
|
+
|
|
1239
|
+
Recorded here rather than only inside
|
|
1240
|
+
:func:`~sentrykit.envelope.attach_envelope` so that a non-mapping return value —
|
|
1241
|
+
which cannot carry the envelope itself — still leaves it on the context for a
|
|
1242
|
+
framework provider to render.
|
|
1243
|
+
|
|
1244
|
+
Args:
|
|
1245
|
+
plan: The :class:`Plan` that was invoked.
|
|
1246
|
+
|
|
1247
|
+
Returns:
|
|
1248
|
+
The envelope, or ``None`` when ``envelope_on_success`` is off or verbosity is
|
|
1249
|
+
``off``.
|
|
1250
|
+
"""
|
|
1251
|
+
envelope = plan.envelope() if plan.settings.envelope_on_success else None
|
|
1252
|
+
set_last_envelope(envelope)
|
|
1253
|
+
return envelope
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def install(
|
|
1257
|
+
policies: str | None = None,
|
|
1258
|
+
*,
|
|
1259
|
+
entities: Sequence[Entity] = (),
|
|
1260
|
+
schema: str | None = None,
|
|
1261
|
+
settings: Settings | None = None,
|
|
1262
|
+
policy_source: str | PolicySource | Sequence[str | PolicySource] | None = None,
|
|
1263
|
+
grant_store: str | GrantStore | Sequence[str | GrantStore] | None = None,
|
|
1264
|
+
audit_sink: str | AuditSink | Sequence[str | AuditSink] | None = None,
|
|
1265
|
+
context_provider: str | ContextProvider | None = None,
|
|
1266
|
+
masker: Masker | None = None,
|
|
1267
|
+
) -> Governor:
|
|
1268
|
+
"""Wire a governor and make it the process default.
|
|
1269
|
+
|
|
1270
|
+
One call covers both ends of the range. Hand it a policy string and it wires an in-memory
|
|
1271
|
+
governor — the shape for tests and for a service whose policy fits in a literal.
|
|
1272
|
+
Hand it DSNs instead and the same call assembles the pluggable substrate: policy sources
|
|
1273
|
+
unioned, grant stores merged, audit sinks fanned out, each resolved through the registry
|
|
1274
|
+
so a third-party backend is reachable by name alone.
|
|
1275
|
+
|
|
1276
|
+
Either way the result is one :class:`Governor`, because multiplicity is a composite that
|
|
1277
|
+
satisfies the same Protocol — the decision path never learns how many sources were
|
|
1278
|
+
configured.
|
|
1279
|
+
|
|
1280
|
+
Args:
|
|
1281
|
+
policies: Cedar policy text. Mutually exclusive with ``policy_source``.
|
|
1282
|
+
entities: Extra stable entities — roles, teams, resources — merged on top of whatever
|
|
1283
|
+
the sources and the grant store contribute.
|
|
1284
|
+
schema: Optional Cedar schema text, for inline policies.
|
|
1285
|
+
settings: Base settings, defaulting to the process configuration.
|
|
1286
|
+
policy_source: One DSN (``"file://./policies"``), several, an already-built source,
|
|
1287
|
+
or a sequence mixing the two — ``["pack://", LinkedPolicySource(links)]`` unions
|
|
1288
|
+
the shipped pack with something the host constructed itself.
|
|
1289
|
+
grant_store: One DSN (``"memory://"``), several, or an already-built store. Its stable
|
|
1290
|
+
entities join the graph; its principals do not, since those are per-request.
|
|
1291
|
+
audit_sink: One DSN (``"jsonl://./audit.jsonl"``), several, or an already-built sink.
|
|
1292
|
+
Defaults to ``SENTRYKIT_AUDIT_SINK``. Nothing configured means no audit trail —
|
|
1293
|
+
a library must not begin writing files merely because it was imported.
|
|
1294
|
+
context_provider: One DSN (``"fastapi://"``) or an already-built provider, wired so a
|
|
1295
|
+
framework adapter can find it with no second configuration step. Exactly one, and
|
|
1296
|
+
never a sequence: a process serves one boundary, and there is no composite that
|
|
1297
|
+
could guess which framework an incoming request belongs to.
|
|
1298
|
+
masker: How sensitive values are masked before an audit write. Defaults to one built
|
|
1299
|
+
from the settings.
|
|
1300
|
+
|
|
1301
|
+
Returns:
|
|
1302
|
+
The installed governor.
|
|
1303
|
+
|
|
1304
|
+
Raises:
|
|
1305
|
+
ConfigurationError: If neither or both of ``policies``/``policy_source`` were given,
|
|
1306
|
+
if a DSN names an unknown backend, or if the policies, entities or schema fail to
|
|
1307
|
+
parse.
|
|
1308
|
+
"""
|
|
1309
|
+
if (policies is None) == (policy_source is None):
|
|
1310
|
+
raise ConfigurationError(
|
|
1311
|
+
"install() needs exactly one of policies= (inline Cedar text) or "
|
|
1312
|
+
"policy_source= (a DSN or a built PolicySource)"
|
|
1313
|
+
)
|
|
1314
|
+
|
|
1315
|
+
effective = settings or get_settings()
|
|
1316
|
+
source = _as_backend(policy_source, build_policy_source, PolicySource, "policy source")
|
|
1317
|
+
store = _as_backend(grant_store, build_grant_store, GrantStore, "grant store")
|
|
1318
|
+
|
|
1319
|
+
if source is not None:
|
|
1320
|
+
bundle = source.load()
|
|
1321
|
+
else:
|
|
1322
|
+
# Inline policies get a bundle too, rather than a bare engine. It costs one extra
|
|
1323
|
+
# parse at wiring time and buys the annotation index, which is what makes obligations
|
|
1324
|
+
# work for a policy literal exactly as they do for a policy directory.
|
|
1325
|
+
bundle = PolicyBundle.from_text(policies or "", schema=schema, source="inline")
|
|
1326
|
+
|
|
1327
|
+
graph: list[Entity] = list(bundle.entities)
|
|
1328
|
+
if store is not None:
|
|
1329
|
+
graph.extend(store.entities())
|
|
1330
|
+
graph.extend(entities)
|
|
1331
|
+
bundle = replace(bundle, entities=tuple(graph))
|
|
1332
|
+
|
|
1333
|
+
sink = _as_backend(audit_sink, build_audit_sink, AuditSink, "audit sink")
|
|
1334
|
+
if sink is None and effective.audit_sink:
|
|
1335
|
+
sink = build_audit_sink(effective.audit_sink)
|
|
1336
|
+
trail = AuditTrail(
|
|
1337
|
+
sink,
|
|
1338
|
+
masker=masker or Masker.from_settings(effective),
|
|
1339
|
+
bundle=bundle,
|
|
1340
|
+
strict=effective.fail_mode == "closed",
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
provider = _as_backend(
|
|
1344
|
+
context_provider, build_context_provider, ContextProvider, "context provider"
|
|
1345
|
+
)
|
|
1346
|
+
governor = Governor(
|
|
1347
|
+
bundle.engine(),
|
|
1348
|
+
settings,
|
|
1349
|
+
audit=trail,
|
|
1350
|
+
bundle=bundle,
|
|
1351
|
+
grants=store,
|
|
1352
|
+
context_provider=provider,
|
|
1353
|
+
)
|
|
1354
|
+
set_governor(governor)
|
|
1355
|
+
_LOGGER.debug(
|
|
1356
|
+
"Installed the process-default governor",
|
|
1357
|
+
extra={
|
|
1358
|
+
"entities": len(bundle.entities),
|
|
1359
|
+
"policies": len(bundle.provenance),
|
|
1360
|
+
"policy_set_hash": bundle.bundle_hash,
|
|
1361
|
+
"audited": trail.enabled,
|
|
1362
|
+
"context_provider": provider.name if provider is not None else None,
|
|
1363
|
+
},
|
|
1364
|
+
)
|
|
1365
|
+
return governor
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _as_backend(
|
|
1369
|
+
configured: Configured | _B | None,
|
|
1370
|
+
build: Callable[[Configured | None], _B | None],
|
|
1371
|
+
protocol: type,
|
|
1372
|
+
label: str,
|
|
1373
|
+
) -> _B | None:
|
|
1374
|
+
"""Accept DSNs, already-built backends, or a mix of the two, and return one backend.
|
|
1375
|
+
|
|
1376
|
+
Letting a caller pass an instance matters more than it looks: a test wants an
|
|
1377
|
+
:class:`~sentrykit.stores.memory.InMemoryAuditSink` it still holds a reference to, and a
|
|
1378
|
+
host with its own credentials wants to construct its backend itself. A DSN is the
|
|
1379
|
+
convenience, not the only door — and that has to hold *inside* a sequence too, or a
|
|
1380
|
+
union is closed to any backend that cannot be spelled as a DSN.
|
|
1381
|
+
|
|
1382
|
+
Args:
|
|
1383
|
+
configured: A DSN, a built backend, a sequence mixing the two, or ``None``.
|
|
1384
|
+
build: The registry factory for this kind.
|
|
1385
|
+
protocol: The Protocol an instance must satisfy.
|
|
1386
|
+
label: Human name for the error message.
|
|
1387
|
+
|
|
1388
|
+
Returns:
|
|
1389
|
+
The backend, or ``None`` when nothing was configured.
|
|
1390
|
+
|
|
1391
|
+
Raises:
|
|
1392
|
+
ConfigurationError: If an instance was given that does not satisfy ``protocol``.
|
|
1393
|
+
"""
|
|
1394
|
+
if configured is None:
|
|
1395
|
+
return None
|
|
1396
|
+
if isinstance(configured, str):
|
|
1397
|
+
return build(configured)
|
|
1398
|
+
if isinstance(configured, Sequence):
|
|
1399
|
+
return build(list(configured))
|
|
1400
|
+
if not isinstance(configured, protocol):
|
|
1401
|
+
raise ConfigurationError(
|
|
1402
|
+
f"the {label} passed to install() does not satisfy the {protocol.__name__} protocol"
|
|
1403
|
+
)
|
|
1404
|
+
# ``Configured`` widens to ``object`` so that one normalization path serves all four
|
|
1405
|
+
# extension points; the isinstance above is the check the cast stands on.
|
|
1406
|
+
return cast("_B", configured)
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
def set_governor(governor: Governor | None) -> None:
|
|
1410
|
+
"""Install ``governor`` as the process default, or clear it with ``None``."""
|
|
1411
|
+
global _GOVERNOR
|
|
1412
|
+
_GOVERNOR = governor
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def current_governor() -> Governor | None:
|
|
1416
|
+
"""Return the process-default governor, or ``None`` if none is installed."""
|
|
1417
|
+
return _GOVERNOR
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def get_governor() -> Governor:
|
|
1421
|
+
"""Return the process-default governor.
|
|
1422
|
+
|
|
1423
|
+
Returns:
|
|
1424
|
+
The installed governor.
|
|
1425
|
+
|
|
1426
|
+
Raises:
|
|
1427
|
+
ConfigurationError: If none is installed. Protecting a callable without wiring a
|
|
1428
|
+
policy is a configuration mistake, and it fails loudly at call time rather than
|
|
1429
|
+
quietly allowing.
|
|
1430
|
+
"""
|
|
1431
|
+
if _GOVERNOR is None:
|
|
1432
|
+
raise ConfigurationError(
|
|
1433
|
+
"No governor is installed; call sentrykit.install(...) or set_governor(...) "
|
|
1434
|
+
"before invoking a protected callable"
|
|
1435
|
+
)
|
|
1436
|
+
return _GOVERNOR
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
@contextmanager
|
|
1440
|
+
def use_governor(governor: Governor) -> Generator[Governor, None, None]:
|
|
1441
|
+
"""Install ``governor`` for the duration of a block, then restore the previous one.
|
|
1442
|
+
|
|
1443
|
+
Args:
|
|
1444
|
+
governor: The governor to use.
|
|
1445
|
+
|
|
1446
|
+
Yields:
|
|
1447
|
+
The governor, for convenience.
|
|
1448
|
+
"""
|
|
1449
|
+
previous = current_governor()
|
|
1450
|
+
set_governor(governor)
|
|
1451
|
+
try:
|
|
1452
|
+
yield governor
|
|
1453
|
+
finally:
|
|
1454
|
+
set_governor(previous)
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def derive_action(fn: Callable[..., object]) -> EntityRef:
|
|
1458
|
+
"""Derive an action id for a callable that declares none.
|
|
1459
|
+
|
|
1460
|
+
Uses ``<module>.<qualname>``, which is stable across refactors that do not move the
|
|
1461
|
+
function and unique within a process — so a policy written against it keeps meaning what
|
|
1462
|
+
it meant.
|
|
1463
|
+
|
|
1464
|
+
Args:
|
|
1465
|
+
fn: The protected callable.
|
|
1466
|
+
|
|
1467
|
+
Returns:
|
|
1468
|
+
``Action::"<module>.<qualname>"``.
|
|
1469
|
+
"""
|
|
1470
|
+
return EntityRef("Action", _qualified_name(fn))
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def merge_environment(
|
|
1474
|
+
ambient: Mapping[str, object],
|
|
1475
|
+
explicit: Mapping[str, object] | None,
|
|
1476
|
+
*,
|
|
1477
|
+
proposed: Mapping[str, object] | None = None,
|
|
1478
|
+
) -> dict[str, object]:
|
|
1479
|
+
"""Build the Cedar request context for one decision.
|
|
1480
|
+
|
|
1481
|
+
Layers the explicit environment over the ambient one, fills in ``now`` and ``channel``
|
|
1482
|
+
when neither supplied them, and exposes the proposed arguments under ``args`` so a Cedar
|
|
1483
|
+
``forbid`` can inspect them directly. That last part is the defense in depth: the
|
|
1484
|
+
Python-side filter is the enforcement, and an argument-aware ``forbid`` is the backstop
|
|
1485
|
+
that catches a filter that was never configured.
|
|
1486
|
+
|
|
1487
|
+
Everything under ``args`` is **caller data**, including its Cedar type where the callable
|
|
1488
|
+
annotates a parameter as an entity reference. A policy that tests such an argument has to
|
|
1489
|
+
pin the type it expects, because Cedar's ``in`` is reflexive and a caller who names the
|
|
1490
|
+
group is trivially in it; :func:`~sentrykit.cedar.coerce_value` states the rule and the
|
|
1491
|
+
three ways to hold it.
|
|
1492
|
+
|
|
1493
|
+
Args:
|
|
1494
|
+
ambient: The environment established at the boundary.
|
|
1495
|
+
explicit: Per-call overrides.
|
|
1496
|
+
proposed: The proposed arguments, exposed to policy under ``args``.
|
|
1497
|
+
|
|
1498
|
+
Returns:
|
|
1499
|
+
The merged environment, ready for coercion into a Cedar context.
|
|
1500
|
+
"""
|
|
1501
|
+
environment = dict(ambient)
|
|
1502
|
+
if explicit:
|
|
1503
|
+
environment.update(explicit)
|
|
1504
|
+
environment.setdefault("channel", "internal")
|
|
1505
|
+
environment.setdefault("now", datetime.now(UTC))
|
|
1506
|
+
# Always the *current* proposal: an ``args`` key left over from an enclosing scope would
|
|
1507
|
+
# let a policy decide about the wrong call.
|
|
1508
|
+
environment["args"] = dict(proposed or {})
|
|
1509
|
+
return environment
|