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
@@ -0,0 +1,928 @@
1
+ """Per-(principal, action) argument constraints: the layer that filters what a caller may pass.
2
+
3
+ Cedar's gate is boolean — it answers "may this principal perform this action on this
4
+ resource", not "with which arguments". So argument-level enforcement stays in Python, driven
5
+ by attribute data resolved from the grant store, with a Cedar ``forbid`` backstop as defense
6
+ in depth. This module is that enforcement, and nothing in it knows about Cedar, the
7
+ engine, or the decorator: it is a pure function from constraints plus proposed arguments to
8
+ kept, rejected and defaulted arguments.
9
+
10
+ That purity is the point. The caller — a web client or a language model — *proposes*
11
+ arguments; the constraint set *decides* which of them survive. A rejection is data, not an
12
+ exception, so the engine can drop it (``arg_handling="lenient"``) or raise on it
13
+ (``"strict"``) without this module knowing which mode is in force.
14
+
15
+ A constraint is *data*, which means one can be written that no value satisfies, or whose own
16
+ default it would reject. Both are faults of the moment a constraint is **written**, not of
17
+ the call that happens to run into them, so :meth:`ArgumentConstraint.inconsistencies` states
18
+ them as sentences an operator can act on and a grant store runs it at the write. The decision
19
+ path stays fail-closed regardless: :meth:`ConstraintSet.inject_defaults` re-checks every
20
+ default against its own constraint and withholds one that fails, because an argument nobody
21
+ proposed must not be a way around the rule that governs it.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import logging
28
+ import numbers
29
+ import re
30
+ from collections.abc import Callable, Collection, Iterator, Mapping
31
+ from decimal import Decimal
32
+ from itertools import chain
33
+ from typing import Final, Literal, NamedTuple, TypeAlias
34
+
35
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
36
+
37
+ from sentrykit.errors import ConfigurationError
38
+
39
+ __all__ = [
40
+ "CEILING_RULES",
41
+ "ArgumentConstraint",
42
+ "ConstraintSet",
43
+ "DefaultsResult",
44
+ "FilterResult",
45
+ "RejectedArgument",
46
+ "Rule",
47
+ "parse_ceiling",
48
+ ]
49
+
50
+ _LOGGER: Final = logging.getLogger("sentrykit.constraints")
51
+
52
+ _PATTERN_CACHE: Final[dict[str, re.Pattern[str] | None]] = {}
53
+
54
+ _PROBE: Final = "value"
55
+ """Stand-in argument name for a self-consistency probe.
56
+
57
+ :meth:`ArgumentConstraint.inconsistencies` reads only a rejection's ``reason``, which
58
+ never spells the argument name -- and a constraint does not know what it is attached to.
59
+ """
60
+
61
+
62
+ def _compiled(pattern: str) -> re.Pattern[str] | None:
63
+ """Compile and memoize ``pattern``, returning ``None`` if it is not a valid regex.
64
+
65
+ Constraint checking runs on every protected call, so the compile is cached by pattern
66
+ text. An uncompilable pattern is cached too, and reported as ``None`` rather than raised:
67
+ :meth:`ConstraintSet.filter` promises not to raise, so a malformed grant fails *closed*
68
+ (the value is rejected) instead of taking the call path down with it.
69
+ """
70
+ if pattern in _PATTERN_CACHE:
71
+ return _PATTERN_CACHE[pattern]
72
+ try:
73
+ compiled: re.Pattern[str] | None = re.compile(pattern)
74
+ except re.error as exc:
75
+ _LOGGER.error(
76
+ "Argument constraint pattern is not a valid regular expression",
77
+ extra={"pattern": pattern, "error": str(exc)},
78
+ )
79
+ compiled = None
80
+ _PATTERN_CACHE[pattern] = compiled
81
+ return compiled
82
+
83
+
84
+ def _same_value(candidate: object, member: object) -> bool:
85
+ """Compare a proposed value with a set member without letting ``True`` equal ``1``.
86
+
87
+ Python's ``bool`` is an ``int``, so a plain ``==`` would let a caller slip ``True`` past
88
+ an allow-set of ``(1,)``. Membership decides authorization here, so the types must match
89
+ as well as the values.
90
+ """
91
+ if isinstance(candidate, bool) is not isinstance(member, bool):
92
+ return False
93
+ return bool(candidate == member)
94
+
95
+
96
+ _Number: TypeAlias = int | float | Decimal | numbers.Real
97
+ """A value a numeric bound can be tested against.
98
+
99
+ Wider than the ``int | float`` a bound is *written* as, because ``Decimal`` is the type money
100
+ is held in and a bound on a refund amount is this library's own canonical example. ``Decimal``
101
+ compares exactly against both ``int`` and ``float``, so the bound stays ``int | float`` and
102
+ only the value side widens -- a ``Decimal`` bound would reach
103
+ :meth:`ArgumentConstraint.describe` and from there a JSON error body, which ``Decimal`` cannot
104
+ be serialized into.
105
+ """
106
+
107
+
108
+ def _is_nan(value: _Number) -> bool:
109
+ """Whether ``value`` is a NaN, whichever numeric type spells it.
110
+
111
+ It matters that a NaN never reaches the bound, and the two spellings of one have to be
112
+ stopped for opposite reasons. A ``Decimal`` NaN *raises* ``InvalidOperation`` from an
113
+ ordering comparison -- and a *signalling* one raises from ``!=`` as well, which is why this
114
+ asks it rather than comparing it -- while :meth:`ConstraintSet.filter` promises not to
115
+ raise. A ``float`` NaN answers ``False`` to every ordering comparison instead, which would
116
+ quietly satisfy a lone ``maximum``. Neither is a value a bound has anything to say about,
117
+ so both are refused as not comparable.
118
+
119
+ Everything else is tested by self-inequality, the one property a NaN has whatever type
120
+ holds it, and the only one that needs no conversion -- ``math.isnan`` would overflow on a
121
+ large ``int``.
122
+ """
123
+ if isinstance(value, Decimal):
124
+ return value.is_nan()
125
+ return bool(value != value)
126
+
127
+
128
+ def _as_number(value: object) -> _Number | None:
129
+ """Return ``value`` as a number for range comparison, or ``None`` if it is not one.
130
+
131
+ ``bool`` is deliberately not a number: comparing ``True >= 1`` would make a boolean
132
+ satisfy a numeric bound by accident. The value is returned as it arrived rather than
133
+ coerced to ``float``, so an exact type keeps its precision in the comparison that follows.
134
+ """
135
+ if isinstance(value, bool):
136
+ return None
137
+ if not isinstance(value, int | float | Decimal | numbers.Real):
138
+ # ``complex`` lands here: it is a number and it is not ordered, so a bound cannot be
139
+ # tested against it -- and asking would raise rather than answer.
140
+ return None
141
+ return None if _is_nan(value) else value
142
+
143
+
144
+ def _exceeds(number: _Number, maximum: int | float) -> bool:
145
+ """Whether ``number`` is above ``maximum``.
146
+
147
+ Written as a negated ``<=`` rather than a ``>`` because the value side of a bound is wider
148
+ than the bound itself, and ``numbers.Real`` guarantees its ordering in the ``<`` direction
149
+ only. The two spellings agree for everything that reaches here: a NaN is the one value
150
+ they would differ over, and :func:`_as_number` has already refused it.
151
+ """
152
+ return not number <= maximum
153
+
154
+
155
+ _DENY_DEPTH_LIMIT: Final = 16
156
+ """How far into a proposed container a deny-set is read.
157
+
158
+ A proposal is untrusted input -- a request body, or a model's tool call -- so it can be
159
+ self-referential or nested past any plausible depth. The walk stops here and the value is
160
+ rejected *unsearched*, which is the fail-closed direction and the reason this is a limit
161
+ rather than a recursion error: :meth:`ConstraintSet.filter` promises not to raise.
162
+ """
163
+
164
+ _DenyHit = Literal["value", "element", "depth"]
165
+ """Where a deny-set matched, or why the walk could not finish looking."""
166
+
167
+
168
+ def _elements(value: object) -> Iterator[object] | None:
169
+ """Return the sub-values a deny-set should be read against, or ``None`` for a leaf.
170
+
171
+ A mapping contributes its keys as well as its values, because a denied name used as a key
172
+ is as much a reference to it as one used as a value. ``str`` and ``bytes`` are leaves:
173
+ reading a string element-wise would turn a deny-set of whole values into a substring rule.
174
+ So is anything that is not a sized, iterable container -- a generator among them, which
175
+ also keeps the walk from consuming an iterator the callable is about to receive.
176
+
177
+ Lazily, because the container is the caller's: copying it to walk it would double the cost
178
+ of a large proposal before deciding anything about it.
179
+ """
180
+ if isinstance(value, str | bytes):
181
+ return None
182
+ if isinstance(value, Mapping):
183
+ return chain(value.values(), value.keys())
184
+ if isinstance(value, Collection):
185
+ return iter(value)
186
+ return None
187
+
188
+
189
+ def _deny_hit(value: object, deny: Collection[object], *, depth: int = 0) -> _DenyHit | None:
190
+ """Find a denied value in ``value`` itself or anywhere inside it.
191
+
192
+ Denial is the one rule read element-wise, because it is the fail-closed direction. A
193
+ scalar never equals a container, so a deny-set that compared whole values only would be
194
+ defeated by wrapping the forbidden value in a one-element list -- which is the shape a
195
+ language model proposing ``targets=["dev", "prod"]`` sends. Permission is deliberately not
196
+ read this way; :meth:`ArgumentConstraint.check` says why.
197
+
198
+ Args:
199
+ value: The proposed value, or a sub-value of one.
200
+ deny: The members no value may be.
201
+ depth: How far inside the original proposal ``value`` sits.
202
+
203
+ Returns:
204
+ Where the match was, ``"depth"`` if a container was left unsearched at
205
+ :data:`_DENY_DEPTH_LIMIT`, or ``None`` if nothing in ``value`` is denied.
206
+ """
207
+ if any(_same_value(value, member) for member in deny):
208
+ return "value" if depth == 0 else "element"
209
+ elements = _elements(value)
210
+ if elements is None:
211
+ return None
212
+ if depth >= _DENY_DEPTH_LIMIT:
213
+ return "depth"
214
+ for element in elements:
215
+ hit = _deny_hit(element, deny, depth=depth + 1)
216
+ if hit is not None:
217
+ return hit
218
+ return None
219
+
220
+
221
+ def _union(
222
+ first: tuple[object, ...] | None, second: tuple[object, ...] | None
223
+ ) -> tuple[object, ...] | None:
224
+ """Combine two deny-sets, preserving first-seen order and dropping repeats."""
225
+ if first is None or second is None:
226
+ return first if first is not None else second
227
+ combined = list(first)
228
+ combined += [
229
+ member for member in second if not any(_same_value(member, seen) for seen in combined)
230
+ ]
231
+ return tuple(combined)
232
+
233
+
234
+ def _tighter(
235
+ first: int | float | None,
236
+ second: int | float | None,
237
+ *,
238
+ loosen: Callable[[int | float, int | float], int | float],
239
+ ) -> int | float | None:
240
+ """Return whichever bound is tighter, or the only one that is set.
241
+
242
+ ``loosen`` is the function that would *widen* the bound -- ``min`` for a lower bound and
243
+ ``max`` for an upper one -- so whichever value it does not pick is the tighter one.
244
+ """
245
+ if first is None or second is None:
246
+ return first if first is not None else second
247
+ return second if loosen(first, second) == first else first
248
+
249
+
250
+ def _both_patterns(first: str | None, second: str | None) -> str | None:
251
+ """Return a pattern matching exactly the strings that both of these match in full.
252
+
253
+ Each side is asserted as a zero-width lookahead anchored at the end of the string, which
254
+ is what :meth:`ArgumentConstraint.check`'s ``fullmatch`` means, and a permissive
255
+ consuming tail satisfies the match itself. Composing this way rather than concatenating
256
+ keeps each pattern's own anchors and alternation inside its own group.
257
+
258
+ A capturing group in either pattern is renumbered by the composition, so a backreference
259
+ would resolve to the wrong group. :func:`parse_ceiling` refuses a declared pattern
260
+ carrying one, which is the layer that can: a pattern in a grant is the operator's own.
261
+ """
262
+ if first is None or second is None:
263
+ return first if first is not None else second
264
+ if first == second:
265
+ return first
266
+ return f"(?=(?:{first})\\Z)(?=(?:{second})\\Z)(?s:.*)"
267
+
268
+
269
+ Rule = Literal["allow-set", "deny-set", "pattern", "minimum", "maximum", "not-comparable"]
270
+ """Which kind of constraint a value violated."""
271
+
272
+
273
+ class RejectedArgument(BaseModel):
274
+ """One argument the policy would not accept, and why.
275
+
276
+ Carried on the ``_authz`` envelope, on
277
+ :class:`~sentrykit.errors.NoValidArguments`, and into the audit record, so the reason a
278
+ value was dropped is reportable rather than guessable.
279
+
280
+ Attributes:
281
+ name: The argument's parameter name.
282
+ rule: Which constraint it violated.
283
+ reason: A sentence-case explanation suitable for showing to the caller.
284
+ policy: Id of the policy or grant that carried the constraint, when known.
285
+ value: The offending value. Emitted only at ``verbose`` verbosity or above, and
286
+ masked before any audit write — never assume this is safe to log.
287
+ """
288
+
289
+ model_config = ConfigDict(frozen=True, extra="forbid")
290
+
291
+ name: str
292
+ rule: Rule
293
+ reason: str
294
+ policy: str | None = None
295
+ value: object | None = None
296
+
297
+ def as_detail(self) -> dict[str, object]:
298
+ """Render without the value, for ``summary`` verbosity and for error payloads."""
299
+ return self.model_dump(exclude={"value"}, exclude_none=True)
300
+
301
+
302
+ class FilterResult(NamedTuple):
303
+ """What :meth:`ConstraintSet.filter` decided.
304
+
305
+ Attributes:
306
+ kept: The proposed arguments that satisfy their constraints.
307
+ rejected: One entry per argument that does not.
308
+ """
309
+
310
+ kept: dict[str, object]
311
+ rejected: tuple[RejectedArgument, ...]
312
+
313
+
314
+ class DefaultsResult(NamedTuple):
315
+ """What :meth:`ConstraintSet.inject_defaults` produced.
316
+
317
+ Attributes:
318
+ arguments: The kept arguments plus every injected default.
319
+ applied: Only the defaults that were injected, for the envelope's
320
+ ``defaults_applied``.
321
+ withheld: One entry per default that was *not* injected because it does not satisfy
322
+ its own constraint. A grant store refuses such a constraint at the write, so
323
+ reaching this is an operator fault in a constraint set built some other way --
324
+ and the fault is reported rather than resolved in the caller's favour.
325
+ """
326
+
327
+ arguments: dict[str, object]
328
+ applied: dict[str, object]
329
+ withheld: tuple[RejectedArgument, ...] = ()
330
+
331
+
332
+ class ArgumentConstraint(BaseModel):
333
+ """The rules that govern one argument for one principal and action.
334
+
335
+ Every rule is optional and they compose: a value must satisfy all of the ones that are
336
+ present. An unconstrained argument is one with no entry in the
337
+ :class:`ConstraintSet` at all — an empty constraint here permits everything, which is
338
+ deliberately different from *forbidding* everything, since the coarse Cedar gate has
339
+ already decided the call itself is permitted.
340
+
341
+ Attributes:
342
+ allow: If set, the value must be one of these. The value is compared as a whole, so a
343
+ container is rejected outright rather than read element-wise: admitting a list
344
+ because each of its elements is permitted would *widen* a rule written about one
345
+ value.
346
+ deny: If set, neither the value nor anything inside it may be one of these. This is
347
+ the one rule read element-wise, because denial is the fail-closed direction — a
348
+ scalar never equals a container, so comparing whole values only would let a caller
349
+ defeat the rule by wrapping the forbidden value in a one-element list.
350
+ pattern: If set, a regular expression the value must match in full. Non-string
351
+ values are rejected as not comparable rather than coerced.
352
+ minimum: If set, the lower bound (inclusive) for a numeric value. Written as an
353
+ ``int`` or a ``float``; a ``Decimal`` or any other real number is compared against
354
+ it at its own precision.
355
+ maximum: If set, the upper bound (inclusive) for a numeric value, on the same terms as
356
+ :attr:`minimum`.
357
+ default: Value to inject when the caller supplies none, or when theirs was dropped.
358
+ Distinguished from "no default configured" by :attr:`has_default`, so a default
359
+ of ``None`` is expressible.
360
+ required: Whether the call cannot proceed without this argument. A required argument
361
+ with no supplied value and no default raises
362
+ :class:`~sentrykit.errors.DefaultUnavailable`.
363
+ policy: Id of the policy or grant this constraint came from, recorded on rejections
364
+ for provenance.
365
+ """
366
+
367
+ model_config = ConfigDict(frozen=True, extra="forbid")
368
+
369
+ allow: tuple[object, ...] | None = None
370
+ deny: tuple[object, ...] | None = None
371
+ pattern: str | None = None
372
+ minimum: int | float | None = None
373
+ maximum: int | float | None = None
374
+ default: object | None = None
375
+ required: bool = False
376
+ policy: str | None = None
377
+
378
+ @property
379
+ def has_default(self) -> bool:
380
+ """Whether a default was configured, including an explicit default of ``None``."""
381
+ return "default" in self.model_fields_set
382
+
383
+ def _reject(self, name: str, rule: Rule, reason: str, value: object) -> RejectedArgument:
384
+ """Build a rejection carrying this constraint's provenance."""
385
+ return RejectedArgument(
386
+ name=name, rule=rule, reason=reason, policy=self.policy, value=value
387
+ )
388
+
389
+ def check(self, name: str, value: object) -> RejectedArgument | None:
390
+ """Test one value against every rule in this constraint.
391
+
392
+ Rules are evaluated allow-set, deny-set, pattern, minimum, maximum, and the first
393
+ violation is returned: reporting one concrete reason is more actionable than a list,
394
+ and the order runs from the coarsest rule to the finest.
395
+
396
+ The vocabulary is scalar-only but for one asymmetry: ``deny`` is read element-wise and
397
+ nothing else is. It walks a proposed container — a mapping's values and its keys, any
398
+ other collection that is not a ``str`` or ``bytes``, and on through nesting as far as
399
+ :data:`_DENY_DEPTH_LIMIT` — and rejects the value if anything inside it is denied, or
400
+ if the container was too deep to finish reading. Every other rule compares the value
401
+ as a whole and so rejects a container outright, ``allow`` as ``allow-set`` and the
402
+ pattern and the bounds as ``not-comparable``. That is deliberate and not an oversight
403
+ to tidy up: reading denial element-wise only ever *narrows* what a principal may pass,
404
+ while reading permission the same way would widen it.
405
+
406
+ Args:
407
+ name: The parameter name, used to label a rejection.
408
+ value: The proposed value.
409
+
410
+ Returns:
411
+ ``None`` if the value is acceptable, otherwise the rejection describing the first
412
+ rule it violated. Never raises, whatever the proposal holds.
413
+ """
414
+ if self.allow is not None and not any(_same_value(value, m) for m in self.allow):
415
+ return self._reject(
416
+ name, "allow-set", "Value is not one of the permitted values", value
417
+ )
418
+
419
+ if self.deny is not None:
420
+ match _deny_hit(value, self.deny):
421
+ case "value":
422
+ return self._reject(name, "deny-set", "Value is explicitly denied", value)
423
+ case "element":
424
+ return self._reject(
425
+ name, "deny-set", "Value contains an explicitly denied value", value
426
+ )
427
+ case "depth":
428
+ # Fail closed: part of the proposal was never read, so nothing in it can
429
+ # be shown to be permitted.
430
+ return self._reject(
431
+ name,
432
+ "deny-set",
433
+ "Value is nested too deeply to be read against the denied values",
434
+ value,
435
+ )
436
+
437
+ if self.pattern is not None:
438
+ if not isinstance(value, str):
439
+ return self._reject(
440
+ name,
441
+ "not-comparable",
442
+ "Value is not a string and cannot be matched against a pattern",
443
+ value,
444
+ )
445
+ compiled = _compiled(self.pattern)
446
+ if compiled is None:
447
+ # Fail closed: the grant is malformed, so nothing can be shown to satisfy it.
448
+ return self._reject(
449
+ name,
450
+ "pattern",
451
+ "Value cannot be checked because the configured pattern is invalid",
452
+ value,
453
+ )
454
+ if compiled.fullmatch(value) is None:
455
+ return self._reject(
456
+ name,
457
+ "pattern",
458
+ f"Value does not match the required pattern {self.pattern!r}",
459
+ value,
460
+ )
461
+
462
+ if self.minimum is not None:
463
+ number = _as_number(value)
464
+ if number is None:
465
+ return self._reject(
466
+ name,
467
+ "not-comparable",
468
+ "Value is not a number and cannot be compared against a minimum",
469
+ value,
470
+ )
471
+ if number < self.minimum:
472
+ return self._reject(
473
+ name, "minimum", f"Value is below the minimum of {self.minimum}", value
474
+ )
475
+
476
+ if self.maximum is not None:
477
+ number = _as_number(value)
478
+ if number is None:
479
+ return self._reject(
480
+ name,
481
+ "not-comparable",
482
+ "Value is not a number and cannot be compared against a maximum",
483
+ value,
484
+ )
485
+ if _exceeds(number, self.maximum):
486
+ return self._reject(
487
+ name, "maximum", f"Value is above the maximum of {self.maximum}", value
488
+ )
489
+
490
+ return None
491
+
492
+ def intersected_with(self, other: ArgumentConstraint) -> ArgumentConstraint:
493
+ """Return the constraint satisfying both this one and ``other``.
494
+
495
+ The counterpart to :meth:`ConstraintSet.merged_with`, which *overrides*. Layering a
496
+ ceiling declared in reviewed policy text over a principal's grant must never widen
497
+ what they may pass, so every rule composes in the tightening direction: allow-sets
498
+ intersect, deny-sets union, bounds move inward, ``required`` is sticky, and two
499
+ patterns must both match in full.
500
+
501
+ ``default`` and ``policy`` come from the receiver where it has them. A ceiling
502
+ declares no default -- a per-principal default is grant data, not reviewed logic --
503
+ and the receiver's ``policy`` names the specific, per-principal layer, while the
504
+ layer being intersected in is already named by the decision's determining policies,
505
+ which the envelope and the audit record carry.
506
+
507
+ Args:
508
+ other: The constraint to narrow by.
509
+
510
+ Returns:
511
+ A constraint no value satisfies unless it satisfies both. It may well be
512
+ unsatisfiable -- two disjoint allow-sets intersect to none, and nothing then
513
+ passes, which is the fail-closed direction.
514
+ """
515
+ fields: dict[str, object] = {
516
+ "deny": _union(self.deny, other.deny),
517
+ "pattern": _both_patterns(self.pattern, other.pattern),
518
+ "minimum": _tighter(self.minimum, other.minimum, loosen=min),
519
+ "maximum": _tighter(self.maximum, other.maximum, loosen=max),
520
+ "required": self.required or other.required,
521
+ "policy": self.policy or other.policy,
522
+ }
523
+ if self.allow is None or other.allow is None:
524
+ fields["allow"] = self.allow if self.allow is not None else other.allow
525
+ else:
526
+ fields["allow"] = tuple(
527
+ member
528
+ for member in self.allow
529
+ if any(_same_value(member, permitted) for permitted in other.allow)
530
+ )
531
+ # Named only when one side configured one, because ``has_default`` is read off
532
+ # ``model_fields_set``: naming the field unconditionally would turn "no default" into
533
+ # "a default of None" and start injecting it.
534
+ if self.has_default:
535
+ fields["default"] = self.default
536
+ elif other.has_default:
537
+ fields["default"] = other.default
538
+ return ArgumentConstraint.model_validate(fields)
539
+
540
+ def inconsistencies(self) -> tuple[str, ...]:
541
+ """Every way this constraint contradicts itself, as sentences an operator can act on.
542
+
543
+ A constraint is *data*, so nothing stops one being written that no value can satisfy
544
+ -- ``minimum=100, maximum=10`` constructs without complaint -- or one whose own
545
+ default it would reject. Left alone, both are discovered at call time as a refusal of
546
+ everything, by whoever happens to call next. This is the check that moves that
547
+ discovery to the moment the grant is written, and
548
+ :meth:`~sentrykit.stores.base.GrantStore.grant` runs it there.
549
+
550
+ Four things are checked:
551
+
552
+ 1. The ``pattern`` compiles.
553
+ 2. ``minimum`` is not above ``maximum``.
554
+ 3. Every ``allow`` member satisfies this constraint's other rules. A permitted value
555
+ the pattern or the bounds reject is dead configuration: a reviewer reading the
556
+ allow-set believes it is reachable.
557
+ 4. A configured ``default`` satisfies this constraint. Without this the way to defeat
558
+ a constraint is to *stop proposing a value*, since an omitted argument is filled
559
+ from the default rather than filtered.
560
+
561
+ The last two delegate to :meth:`check`, so what a grant promises at the write and
562
+ what the filter enforces at the call cannot drift apart.
563
+
564
+ Returns:
565
+ One sentence per problem, or an empty tuple when the constraint is coherent. An
566
+ uncompilable pattern or inverted bounds short-circuit the value-level checks:
567
+ :meth:`check` rejects *everything* under either, so probing the allow-set and the
568
+ default would restate the same fault once per value.
569
+ """
570
+ structural: list[str] = []
571
+ if self.pattern is not None and _compiled(self.pattern) is None:
572
+ structural.append(f"pattern {self.pattern!r} is not a valid regular expression")
573
+ if self.minimum is not None and self.maximum is not None and self.minimum > self.maximum:
574
+ structural.append(
575
+ f"minimum {self.minimum} is above maximum {self.maximum}, "
576
+ "so no value can satisfy both"
577
+ )
578
+ if self.allow is not None and not self.allow:
579
+ # The one rule that can never be satisfied, and the quietest: ``allow: []``
580
+ # constructs and stores without complaint, then rejects every value. It is
581
+ # reported here rather than left to :meth:`check` for the same reason inverted
582
+ # bounds are -- the fault is in the written constraint, not in whoever calls next.
583
+ #
584
+ # Only a *written* constraint reaches this method (a grant write and
585
+ # :func:`parse_ceiling`); an allow-set emptied by
586
+ # :meth:`intersected_with` is never re-checked, so narrowing two disjoint
587
+ # allow-sets to none stays the fail-closed outcome it is meant to be.
588
+ structural.append("an empty allow-set permits no value, so no value can satisfy it")
589
+ if structural:
590
+ return tuple(structural)
591
+
592
+ problems: list[str] = []
593
+ for member in self.allow or ():
594
+ rejection = self.check(_PROBE, member)
595
+ if rejection is not None:
596
+ problems.append(
597
+ f"permitted value {member!r} does not satisfy the constraint's own "
598
+ f"rules: {rejection.reason}"
599
+ )
600
+ if self.has_default:
601
+ rejection = self.check(_PROBE, self.default)
602
+ if rejection is not None:
603
+ # The default *value* is deliberately absent from the message, for the same
604
+ # reason :meth:`describe` withholds it: a per-principal default can itself be
605
+ # sensitive, and this sentence travels into logs and error payloads.
606
+ problems.append(
607
+ f"the configured default does not satisfy the constraint's own rules: "
608
+ f"{rejection.reason}"
609
+ )
610
+ return tuple(problems)
611
+
612
+ def describe(self) -> dict[str, object]:
613
+ """Summarize the rules, for the "here is what you *may* pass" half of an error.
614
+
615
+ Only the rules that were actually configured appear. The default *value* never does —
616
+ a caller is told that one exists, not what it is, since a per-principal default can
617
+ itself be sensitive (someone else's tenant id, say).
618
+ """
619
+ described: dict[str, object] = {}
620
+ if self.allow is not None:
621
+ described["allow"] = list(self.allow)
622
+ if self.deny is not None:
623
+ described["deny"] = list(self.deny)
624
+ if self.pattern is not None:
625
+ described["pattern"] = self.pattern
626
+ if self.minimum is not None:
627
+ described["minimum"] = self.minimum
628
+ if self.maximum is not None:
629
+ described["maximum"] = self.maximum
630
+ if self.policy is not None:
631
+ described["policy"] = self.policy
632
+ described["required"] = self.required
633
+ described["has_default"] = self.has_default
634
+ return described
635
+
636
+
637
+ class ConstraintSet(BaseModel):
638
+ """Every argument constraint that applies to one principal for one action.
639
+
640
+ Attributes:
641
+ arguments: Parameter name to its constraint. Parameters absent from this mapping are
642
+ unconstrained.
643
+ """
644
+
645
+ model_config = ConfigDict(frozen=True)
646
+
647
+ arguments: dict[str, ArgumentConstraint] = Field(default_factory=dict)
648
+
649
+ def __bool__(self) -> bool:
650
+ """Whether this set constrains anything at all."""
651
+ return bool(self.arguments)
652
+
653
+ def merged_with(self, other: ConstraintSet) -> ConstraintSet:
654
+ """Return this set with ``other`` layered on top, per argument.
655
+
656
+ Used to let a wildcard (``"*"``) constraint set supply the baseline that an
657
+ action-specific set refines. Overriding is whole-constraint, not field-by-field: an
658
+ action-specific entry for an argument replaces the wildcard entry for it outright,
659
+ which keeps "what applies here" answerable by reading one object.
660
+ """
661
+ return ConstraintSet(arguments={**self.arguments, **other.arguments})
662
+
663
+ def intersected_with(self, other: ConstraintSet) -> ConstraintSet:
664
+ """Return the set satisfying both, argument by argument.
665
+
666
+ Where both sets constrain an argument, the two constraints intersect; where only one
667
+ does, that one applies unchanged. Unlike :meth:`merged_with` this can only ever
668
+ narrow, which is what makes it the safe direction for layering a ceiling declared in
669
+ reviewed policy text over a principal's grants.
670
+
671
+ Args:
672
+ other: The set to narrow by.
673
+
674
+ Returns:
675
+ The intersected set.
676
+ """
677
+ arguments = dict(self.arguments)
678
+ for name, constraint in other.arguments.items():
679
+ existing = arguments.get(name)
680
+ arguments[name] = (
681
+ constraint if existing is None else existing.intersected_with(constraint)
682
+ )
683
+ return ConstraintSet(arguments=arguments)
684
+
685
+ def filter(self, proposed: Mapping[str, object]) -> FilterResult:
686
+ """Split proposed arguments into those the policy accepts and those it does not.
687
+
688
+ Args:
689
+ proposed: What the caller asked for.
690
+
691
+ Returns:
692
+ The kept arguments and the rejections. Never raises — turning a rejection into an
693
+ error is ``arg_handling``'s decision, made by the engine.
694
+ """
695
+ kept: dict[str, object] = {}
696
+ rejected: list[RejectedArgument] = []
697
+ for name, value in proposed.items():
698
+ constraint = self.arguments.get(name)
699
+ if constraint is None:
700
+ # Unconstrained: the coarse Cedar gate already permitted this call, and an
701
+ # empty constraint set must not mean "forbid everything".
702
+ kept[name] = value
703
+ continue
704
+ rejection = constraint.check(name, value)
705
+ if rejection is None:
706
+ kept[name] = value
707
+ else:
708
+ rejected.append(rejection)
709
+ return FilterResult(kept=kept, rejected=tuple(rejected))
710
+
711
+ def inject_defaults(
712
+ self,
713
+ kept: Mapping[str, object],
714
+ parameters: Collection[str],
715
+ *,
716
+ accept_unlisted: bool = False,
717
+ ) -> DefaultsResult:
718
+ """Fill in per-principal defaults for arguments the caller did not supply.
719
+
720
+ A default never overrides a value that survived filtering — the caller's own valid
721
+ input wins. Defaults are injected for dropped arguments too, which is what makes
722
+ ``lenient`` handling useful: an over-broad request is narrowed rather than refused.
723
+ Nothing is trimmed to a bound — the value the principal may not have was already
724
+ rejected by :meth:`filter`, and what lands here is the gap it left.
725
+
726
+ Every default is checked against its own constraint before it is injected, and one
727
+ that fails is withheld rather than substituted. Without that check the way to defeat
728
+ a constraint would be to *stop proposing a value*: a supplied value is filtered, an
729
+ omitted one is filled in, and only the first path ran a check. The write-time check
730
+ in :meth:`ArgumentConstraint.inconsistencies` is what a grant store enforces so this
731
+ situation cannot be stored at all; this is the backstop for a constraint set that
732
+ reached the decision path some other way, and like :meth:`filter` it reports rather
733
+ than raises.
734
+
735
+ Args:
736
+ kept: Arguments that survived :meth:`filter`.
737
+ parameters: Parameter names the target callable actually accepts. Defaults for
738
+ anything else are skipped, so a stale grant cannot make a call fail with an
739
+ unexpected keyword argument.
740
+ accept_unlisted: Whether a default may be injected for a name ``parameters`` does
741
+ not list. A callable declaring ``**kwargs`` accepts names its signature never
742
+ spells, so a default for one of them is deliverable and withholding it would
743
+ turn every constraint on a kwargs-only argument into a rule that rejects and
744
+ refills nothing. It defaults to ``False`` because the caller is the only side
745
+ that knows: for an ordinary signature the name filter above is what keeps a
746
+ stale grant from making a call fail with an unexpected keyword argument, and
747
+ that protection has to stay on unless it is explicitly stood down.
748
+
749
+ Returns:
750
+ The completed argument mapping, the subset that was injected, and the defaults
751
+ that were withheld.
752
+ """
753
+ arguments: dict[str, object] = dict(kept)
754
+ applied: dict[str, object] = {}
755
+ withheld: list[RejectedArgument] = []
756
+ for name, constraint in self.arguments.items():
757
+ if name in arguments or not constraint.has_default:
758
+ continue
759
+ if name not in parameters and not accept_unlisted:
760
+ continue
761
+ rejection = constraint.check(name, constraint.default)
762
+ if rejection is not None:
763
+ # Withheld, not substituted and not coerced: the constraint says this value
764
+ # may not be used, and it does not stop saying so because the policy is the
765
+ # one proposing it. The argument then falls to ``missing_required`` if the
766
+ # constraint requires it, and to the callable's own default if it does not.
767
+ _LOGGER.error(
768
+ "Configured default does not satisfy its own constraint; not injecting",
769
+ extra={"argument": name, "rule": rejection.rule, "policy": constraint.policy},
770
+ )
771
+ withheld.append(
772
+ RejectedArgument(
773
+ name=name,
774
+ rule=rejection.rule,
775
+ reason=(
776
+ "The configured default does not satisfy its own constraint "
777
+ "and was not applied"
778
+ ),
779
+ policy=constraint.policy,
780
+ )
781
+ )
782
+ continue
783
+ arguments[name] = constraint.default
784
+ applied[name] = constraint.default
785
+ return DefaultsResult(arguments=arguments, applied=applied, withheld=tuple(withheld))
786
+
787
+ def missing_required(self, present: Collection[str]) -> tuple[str, ...]:
788
+ """Name the required arguments that are still absent after defaults were injected.
789
+
790
+ Args:
791
+ present: Argument names that now have a value.
792
+
793
+ Returns:
794
+ The names of required arguments with neither a supplied value nor a default —
795
+ each one a :class:`~sentrykit.errors.DefaultUnavailable`.
796
+ """
797
+ return tuple(
798
+ name
799
+ for name, constraint in self.arguments.items()
800
+ if constraint.required and name not in present
801
+ )
802
+
803
+ def inconsistencies(self) -> tuple[str, ...]:
804
+ """Every self-contradiction across the whole set, each labelled with its argument.
805
+
806
+ The set-level entry point for :meth:`ArgumentConstraint.inconsistencies`, used where
807
+ a whole set is written or loaded at once rather than one constraint at a time.
808
+
809
+ Returns:
810
+ One sentence per problem, or an empty tuple when every constraint is coherent.
811
+ """
812
+ return tuple(
813
+ f"argument {name!r}: {problem}"
814
+ for name, constraint in self.arguments.items()
815
+ for problem in constraint.inconsistencies()
816
+ )
817
+
818
+ def describe(self) -> dict[str, object]:
819
+ """Summarize every constraint, for the allowed-set half of an error payload."""
820
+ return {name: constraint.describe() for name, constraint in self.arguments.items()}
821
+
822
+
823
+ CEILING_RULES: Final = ("allow", "deny", "pattern", "minimum", "maximum", "required")
824
+ """The rules a policy-declared ceiling may set.
825
+
826
+ ``default`` is absent on purpose. :meth:`ArgumentConstraint.describe` already withholds a
827
+ default *value* from callers because a per-principal default can itself be sensitive -- it is
828
+ someone's tenant id, not a rule -- so it is grant data, and reviewed policy text is where
829
+ rules live. ``policy`` is absent because sentrykit fills it in with the id of the policy that
830
+ carried the declaration; a self-reported provenance would be unverifiable.
831
+ """
832
+
833
+ _BACKREFERENCE: Final = re.compile(r"\\[1-9]")
834
+ """A numbered backreference, which cannot survive being composed with another pattern."""
835
+
836
+
837
+ def parse_ceiling(declaration: str | None, *, policy: str) -> ConstraintSet:
838
+ """Parse an argument ceiling declared in reviewed policy text.
839
+
840
+ A ceiling is a compact JSON object mapping an argument name to the rules that bound it::
841
+
842
+ {"limit": {"maximum": 100}, "region": {"allow": ["eu", "us"]}}
843
+
844
+ It reaches sentrykit as a Cedar annotation value, and Cedar type-checks annotation values
845
+ not at all -- a policy carrying a nonsense one validates clean. Every check is therefore
846
+ ours, and every one of them happens **here**, at load, so a malformed declaration fails
847
+ at wiring rather than silently enforcing nothing at decision time.
848
+
849
+ Args:
850
+ declaration: The annotation's value. A valueless annotation arrives as ``None``.
851
+ policy: Id of the policy that carried it, recorded on every constraint so a rejection
852
+ names the reviewed file it came from.
853
+
854
+ Returns:
855
+ The declared ceiling.
856
+
857
+ Raises:
858
+ ConfigurationError: If the declaration is absent, is not a JSON object of objects,
859
+ names a rule a ceiling may not set, carries a value of the wrong type, declares a
860
+ pattern that will not compile or that carries a backreference, or describes a
861
+ constraint no value could satisfy.
862
+ """
863
+ if declaration is None or not declaration.strip():
864
+ # An annotation that declares nothing enforces nothing, and a reviewer reading the
865
+ # file would believe a ceiling exists. That is the silent failure this whole function
866
+ # is arranged to prevent, so it is an error rather than an empty set.
867
+ raise ConfigurationError(f"policy {policy!r} declares an empty argument ceiling")
868
+
869
+ try:
870
+ parsed = json.loads(declaration)
871
+ except json.JSONDecodeError as exc:
872
+ raise ConfigurationError(
873
+ f"policy {policy!r} declares an argument ceiling that is not valid JSON: {exc}"
874
+ ) from exc
875
+ if not isinstance(parsed, dict):
876
+ raise ConfigurationError(
877
+ f"policy {policy!r} declares an argument ceiling that is not a JSON object; "
878
+ 'expected {"<argument>": {"<rule>": <value>}}'
879
+ )
880
+
881
+ arguments: dict[str, ArgumentConstraint] = {}
882
+ for name, rules in parsed.items():
883
+ if not isinstance(rules, dict):
884
+ raise ConfigurationError(
885
+ f"policy {policy!r} declares a ceiling for argument {name!r} that is not a "
886
+ "JSON object of rules"
887
+ )
888
+ for rule in rules:
889
+ if rule not in CEILING_RULES:
890
+ raise ConfigurationError(
891
+ f"policy {policy!r} declares {rule!r} for argument {name!r}; a ceiling "
892
+ f"may set only {', '.join(CEILING_RULES)}"
893
+ + (
894
+ " -- a default is per-principal grant data, not reviewed logic"
895
+ if rule == "default"
896
+ else ""
897
+ )
898
+ )
899
+ # Sequences arrive from JSON as lists; the model wants tuples, and coercing here
900
+ # keeps the error below about the *rule* rather than about a container type.
901
+ fields: dict[str, object] = {
902
+ rule: tuple(value) if isinstance(value, list) else value
903
+ for rule, value in rules.items()
904
+ }
905
+ try:
906
+ constraint = ArgumentConstraint.model_validate({**fields, "policy": policy})
907
+ except ValidationError as exc:
908
+ raise ConfigurationError(
909
+ f"policy {policy!r} declares a ceiling for argument {name!r} that does not "
910
+ f"type-check: {exc.errors()[0]['msg']}"
911
+ ) from exc
912
+ if constraint.pattern is not None and _BACKREFERENCE.search(constraint.pattern):
913
+ raise ConfigurationError(
914
+ f"policy {policy!r} declares a pattern carrying a backreference for argument "
915
+ f"{name!r}; a ceiling's pattern is composed with the principal's own, which "
916
+ "renumbers its groups"
917
+ )
918
+ problems = constraint.inconsistencies()
919
+ if problems:
920
+ raise ConfigurationError(
921
+ f"policy {policy!r} declares a ceiling for argument {name!r} that cannot be "
922
+ "satisfied: " + "; ".join(problems)
923
+ )
924
+ arguments[name] = constraint
925
+
926
+ if not arguments:
927
+ raise ConfigurationError(f"policy {policy!r} declares an empty argument ceiling")
928
+ return ConstraintSet(arguments=arguments)