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