sentrykit 0.1.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. sentrykit/__init__.py +200 -0
  2. sentrykit/audit.py +967 -0
  3. sentrykit/cedar.py +750 -0
  4. sentrykit/cli.py +1405 -0
  5. sentrykit/config.py +300 -0
  6. sentrykit/constraints.py +928 -0
  7. sentrykit/context.py +393 -0
  8. sentrykit/decorator.py +252 -0
  9. sentrykit/engine.py +1509 -0
  10. sentrykit/envelope.py +513 -0
  11. sentrykit/errors.py +252 -0
  12. sentrykit/integrations/__init__.py +1 -0
  13. sentrykit/integrations/base.py +767 -0
  14. sentrykit/integrations/fastapi.py +361 -0
  15. sentrykit/integrations/flask.py +342 -0
  16. sentrykit/integrations/mcp.py +929 -0
  17. sentrykit/integrations/pydantic_ai.py +728 -0
  18. sentrykit/integrations/tools.py +479 -0
  19. sentrykit/masking.py +459 -0
  20. sentrykit/policy/__init__.py +40 -0
  21. sentrykit/policy/loader.py +764 -0
  22. sentrykit/policy/pack/10-rbac.cedar +9 -0
  23. sentrykit/policy/pack/20-groups.cedar +9 -0
  24. sentrykit/policy/pack/30-ownership.cedar +8 -0
  25. sentrykit/policy/pack/40-delegation.cedar +40 -0
  26. sentrykit/policy/pack/50-expiry.cedar +24 -0
  27. sentrykit/policy/pack/60-break-glass.cedar +34 -0
  28. sentrykit/policy/pack/70-separation-of-duties.cedar +22 -0
  29. sentrykit/policy/pack/80-jit-elevation.cedar +32 -0
  30. sentrykit/policy/pack/entities.json +3 -0
  31. sentrykit/policy/pack/pack.cedarschema +45 -0
  32. sentrykit/policy/pack/templates/time-boxed-grant.cedartemplate +5 -0
  33. sentrykit/py.typed +0 -0
  34. sentrykit/registry.py +666 -0
  35. sentrykit/stores/__init__.py +116 -0
  36. sentrykit/stores/base.py +870 -0
  37. sentrykit/stores/composite.py +635 -0
  38. sentrykit/stores/filesystem.py +400 -0
  39. sentrykit/stores/jsonfile.py +701 -0
  40. sentrykit/stores/jsonl.py +357 -0
  41. sentrykit/stores/memory.py +500 -0
  42. sentrykit-0.1.0rc1.dist-info/METADATA +541 -0
  43. sentrykit-0.1.0rc1.dist-info/RECORD +46 -0
  44. sentrykit-0.1.0rc1.dist-info/WHEEL +4 -0
  45. sentrykit-0.1.0rc1.dist-info/entry_points.txt +20 -0
  46. sentrykit-0.1.0rc1.dist-info/licenses/LICENSE +201 -0
sentrykit/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