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/cedar.py
ADDED
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
"""Thin wrapper over ``cedarpy`` — the only module in sentrykit that imports Cedar.
|
|
2
|
+
|
|
3
|
+
Everything else in the core talks to :class:`CedarDecision`, a normalized value object, so
|
|
4
|
+
the decision path stays unit-testable without a policy set and so a future engine binding —
|
|
5
|
+
partial evaluation, when Cedar exposes it — can be swapped in behind this seam.
|
|
6
|
+
|
|
7
|
+
Three things this module owns that callers should not re-implement:
|
|
8
|
+
|
|
9
|
+
1. **Pre-parsed handles.** ``cedarpy`` exposes reusable ``PolicySet`` / ``Entities`` /
|
|
10
|
+
``Schema`` handles. :class:`PolicyEngine` parses once at construction — so a bad policy
|
|
11
|
+
raises :class:`~sentrykit.errors.ConfigurationError` at wiring time rather than silently
|
|
12
|
+
denying every request at runtime — and reuses the handles per call.
|
|
13
|
+
2. **Determining-policy naming.** Cedar reports parser-generated ids (``policy0``) in its
|
|
14
|
+
diagnostics; the human-meaningful name is the policy's ``@id("...")`` annotation.
|
|
15
|
+
:attr:`CedarDecision.determining_policies` carries the annotation when one exists and the
|
|
16
|
+
generated id otherwise, so denial messages and audit records stay readable.
|
|
17
|
+
3. **Value coercion, and the boundary it defends.** Cedar's type system has no
|
|
18
|
+
floating-point and no null. A stray ``None`` in the request context turns the whole
|
|
19
|
+
decision into ``NoDecision``, and a ``None`` in an entity attribute makes the entity set
|
|
20
|
+
fail to parse outright. Every value handed to Cedar passes through :func:`coerce_value`
|
|
21
|
+
first. The same function owns the one boundary that matters for forgery: a supplied
|
|
22
|
+
*mapping* can never spell Cedar's reserved ``__`` escape keys, so it cannot promote itself
|
|
23
|
+
into a typed entity reference. An :class:`EntityRef` object still becomes one — which is
|
|
24
|
+
what that type is for, and what makes it the application's decision rather than the
|
|
25
|
+
library's. See :func:`coerce_value` for the consequence when an application annotates a
|
|
26
|
+
caller-facing parameter with it.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import datetime as dt
|
|
32
|
+
import hashlib
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
import re
|
|
36
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from decimal import Decimal
|
|
39
|
+
from enum import Enum
|
|
40
|
+
from typing import Final, Literal, NamedTuple
|
|
41
|
+
|
|
42
|
+
import cedarpy
|
|
43
|
+
|
|
44
|
+
from sentrykit.errors import ConfigurationError, ReservedKey
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"RESERVED_KEY_PREFIX",
|
|
48
|
+
"CedarDecision",
|
|
49
|
+
"CedarRequest",
|
|
50
|
+
"Entity",
|
|
51
|
+
"EntityRef",
|
|
52
|
+
"PolicyAnnotation",
|
|
53
|
+
"PolicyEngine",
|
|
54
|
+
"PolicyValidation",
|
|
55
|
+
"PolicyValidationError",
|
|
56
|
+
"coerce_value",
|
|
57
|
+
"format_policies",
|
|
58
|
+
"policy_annotations",
|
|
59
|
+
"validate_policies",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
_LOGGER: Final = logging.getLogger("sentrykit.cedar")
|
|
63
|
+
|
|
64
|
+
_UID_RE: Final = re.compile(
|
|
65
|
+
r'^(?P<type>[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)*)::"(?P<id>.*)"$'
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
RESERVED_KEY_PREFIX: Final = "__"
|
|
69
|
+
"""Prefix Cedar reserves for its own escape forms in request and entity JSON.
|
|
70
|
+
|
|
71
|
+
``__entity`` and ``__extn`` are the two Cedar defines today, and the prefix as a whole is
|
|
72
|
+
reserved — so the check is on the prefix, not on the two spellings, and stays correct if
|
|
73
|
+
Cedar adds a third.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
DecisionLiteral = Literal["allow", "deny", "no-decision"]
|
|
77
|
+
"""Normalized spelling of Cedar's ``Allow`` / ``Deny`` / ``NoDecision``."""
|
|
78
|
+
|
|
79
|
+
_DECISIONS: Final[dict[cedarpy.Decision, DecisionLiteral]] = {
|
|
80
|
+
cedarpy.Decision.Allow: "allow",
|
|
81
|
+
cedarpy.Decision.Deny: "deny",
|
|
82
|
+
cedarpy.Decision.NoDecision: "no-decision",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class EntityRef(NamedTuple):
|
|
87
|
+
"""A Cedar entity reference — an entity type plus an id.
|
|
88
|
+
|
|
89
|
+
Rendered as ``Type::"id"`` and sent to Cedar in its structured ``{"type", "id"}`` form,
|
|
90
|
+
which sidesteps the surface-syntax normalization rules that would otherwise reject ids
|
|
91
|
+
containing awkward characters. That matters because resource ids reach us from callers
|
|
92
|
+
and from language models.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
type: str
|
|
96
|
+
id: str
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def parse(cls, value: str | EntityRef, *, default_type: str | None = None) -> EntityRef:
|
|
100
|
+
"""Parse ``Type::"id"``, or a bare id when ``default_type`` is supplied.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
value: A Cedar uid string, a bare id, or an already-parsed reference.
|
|
104
|
+
default_type: Entity type to assume when ``value`` carries no ``Type::`` prefix.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The parsed reference.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
ConfigurationError: If ``value`` has no type prefix and no ``default_type`` was
|
|
111
|
+
given, or if it is malformed.
|
|
112
|
+
"""
|
|
113
|
+
if isinstance(value, EntityRef):
|
|
114
|
+
return value
|
|
115
|
+
match = _UID_RE.match(value)
|
|
116
|
+
if match is not None:
|
|
117
|
+
return cls(match.group("type"), match.group("id"))
|
|
118
|
+
if default_type is not None and "::" not in value:
|
|
119
|
+
return cls(default_type, value)
|
|
120
|
+
raise ConfigurationError(
|
|
121
|
+
f'cannot parse entity reference {value!r}; expected Type::"id"'
|
|
122
|
+
+ ("" if default_type is not None else " (no default entity type was supplied)")
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def __str__(self) -> str:
|
|
126
|
+
"""Render the Cedar surface form, ``Type::"id"``."""
|
|
127
|
+
return f'{self.type}::"{self.id}"'
|
|
128
|
+
|
|
129
|
+
def as_json(self) -> dict[str, str]:
|
|
130
|
+
"""Return the structured ``{"type": ..., "id": ...}`` form ``cedarpy`` accepts."""
|
|
131
|
+
return {"type": self.type, "id": self.id}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True, slots=True)
|
|
135
|
+
class Entity:
|
|
136
|
+
"""One node of the Cedar entity graph.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
uid: This entity's reference.
|
|
140
|
+
attributes: Cedar attributes. Values are coerced by :func:`coerce_value`, so
|
|
141
|
+
``None`` entries are dropped rather than making the entity set unparseable.
|
|
142
|
+
parents: Direct parent edges. Cedar computes the transitive closure, so a role, a
|
|
143
|
+
team and a delegation edge are all just parents — this is the mechanism that
|
|
144
|
+
lets multi-hop relationships be resolved by policy instead of by application
|
|
145
|
+
code.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
uid: EntityRef
|
|
149
|
+
attributes: Mapping[str, object] = field(default_factory=dict)
|
|
150
|
+
parents: tuple[EntityRef, ...] = ()
|
|
151
|
+
|
|
152
|
+
def as_json(self) -> dict[str, object]:
|
|
153
|
+
"""Return the Cedar entity-JSON form: ``{"uid", "attrs", "parents"}``."""
|
|
154
|
+
return {
|
|
155
|
+
"uid": self.uid.as_json(),
|
|
156
|
+
"attrs": _coerce_mapping(self.attributes),
|
|
157
|
+
"parents": [parent.as_json() for parent in self.parents],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True, slots=True)
|
|
162
|
+
class CedarRequest:
|
|
163
|
+
"""One authorization question, in sentrykit's own terms.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
principal: Who is acting.
|
|
167
|
+
action: What they propose to do.
|
|
168
|
+
resource: What they propose to do it to.
|
|
169
|
+
context: Cedar request context — ``now``, ``channel``, the proposed arguments, and
|
|
170
|
+
whatever else the provider established. Coerced before evaluation.
|
|
171
|
+
entities: Per-request entities merged on top of the engine's base graph, typically
|
|
172
|
+
the principal snapshot. A delta entity whose uid duplicates a *different* base
|
|
173
|
+
entity is an error, so the base graph should hold only stable, shared nodes.
|
|
174
|
+
correlation_id: Invocation id, echoed back on the decision so operational logs, the
|
|
175
|
+
Cedar result and the audit record share one key.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
principal: EntityRef
|
|
179
|
+
action: EntityRef
|
|
180
|
+
resource: EntityRef
|
|
181
|
+
context: Mapping[str, object] = field(default_factory=dict)
|
|
182
|
+
entities: tuple[Entity, ...] = ()
|
|
183
|
+
correlation_id: str | None = None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True, slots=True)
|
|
187
|
+
class CedarDecision:
|
|
188
|
+
"""A Cedar authorization result, normalized.
|
|
189
|
+
|
|
190
|
+
Attributes:
|
|
191
|
+
allowed: Whether Cedar returned ``Allow``. ``Deny`` and ``NoDecision`` are both
|
|
192
|
+
``False``; read :attr:`decision` to tell them apart.
|
|
193
|
+
decision: The raw verdict, lower-cased.
|
|
194
|
+
determining_policies: The policies that decided it, named by their ``@id``
|
|
195
|
+
annotation where they declare one. Empty on a default deny — which is itself
|
|
196
|
+
diagnostic: it means no policy matched at all.
|
|
197
|
+
errors: Cedar parse or evaluation errors. Cedar folds these into the result instead
|
|
198
|
+
of raising; a non-empty list is an *engine failure*, distinct from a policy
|
|
199
|
+
denial, and is what ``fail_mode`` arbitrates.
|
|
200
|
+
correlation_id: Echoed from the request.
|
|
201
|
+
policy_set_hash: Digest of the policy text the decision was evaluated against, so a
|
|
202
|
+
decision can be replayed later against the same logic.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
allowed: bool
|
|
206
|
+
decision: DecisionLiteral
|
|
207
|
+
determining_policies: tuple[str, ...] = ()
|
|
208
|
+
errors: tuple[str, ...] = ()
|
|
209
|
+
correlation_id: str | None = None
|
|
210
|
+
policy_set_hash: str = ""
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def failed(self) -> bool:
|
|
214
|
+
"""Whether the engine failed, as opposed to reaching a clean allow or deny."""
|
|
215
|
+
return bool(self.errors) or self.decision == "no-decision"
|
|
216
|
+
|
|
217
|
+
def as_trace(self) -> dict[str, object]:
|
|
218
|
+
"""Render the decision for the envelope's debug trace and the audit record."""
|
|
219
|
+
return {
|
|
220
|
+
"allowed": self.allowed,
|
|
221
|
+
"decision": self.decision,
|
|
222
|
+
"determining_policies": list(self.determining_policies),
|
|
223
|
+
"errors": list(self.errors),
|
|
224
|
+
"correlation_id": self.correlation_id,
|
|
225
|
+
"policy_set_hash": self.policy_set_hash,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class PolicyEngine:
|
|
230
|
+
"""A parsed, reusable Cedar policy set plus a base entity graph.
|
|
231
|
+
|
|
232
|
+
The in-memory policy source: hand it policy text and the stable entities, and it answers
|
|
233
|
+
questions. A ``PolicySource`` or a ``GrantStore`` changes *where the text comes from*
|
|
234
|
+
without changing this class.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
policies: Cedar policy text. Parsed eagerly.
|
|
238
|
+
entities: The stable part of the entity graph — roles, teams, resources. Per-request
|
|
239
|
+
entities such as the principal snapshot go on :class:`CedarRequest` instead.
|
|
240
|
+
schema: Optional Cedar schema text, used to validate entities and requests.
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
ConfigurationError: If the policies, entities or schema fail to parse.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
__slots__ = ("_entity_handle", "_hash", "_policies", "_policy_set", "_schema", "_schema_text")
|
|
247
|
+
|
|
248
|
+
_policies: str
|
|
249
|
+
_schema_text: str | None
|
|
250
|
+
_policy_set: cedarpy.PolicySet
|
|
251
|
+
_entity_handle: cedarpy.Entities
|
|
252
|
+
_schema: cedarpy.Schema | None
|
|
253
|
+
_hash: str
|
|
254
|
+
|
|
255
|
+
def __init__(
|
|
256
|
+
self,
|
|
257
|
+
policies: str,
|
|
258
|
+
entities: Sequence[Entity] = (),
|
|
259
|
+
*,
|
|
260
|
+
schema: str | None = None,
|
|
261
|
+
) -> None:
|
|
262
|
+
try:
|
|
263
|
+
policy_set = cedarpy.PolicySet.from_str(policies)
|
|
264
|
+
except ValueError as exc:
|
|
265
|
+
raise ConfigurationError(f"cedar policy set failed to parse: {exc}") from exc
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
entity_handle = cedarpy.Entities.from_json_str(_entities_json(entities))
|
|
269
|
+
except ValueError as exc:
|
|
270
|
+
raise ConfigurationError(f"cedar entities failed to parse: {exc}") from exc
|
|
271
|
+
|
|
272
|
+
parsed_schema: cedarpy.Schema | None = None
|
|
273
|
+
if schema is not None:
|
|
274
|
+
try:
|
|
275
|
+
parsed_schema = cedarpy.Schema.from_str(schema)
|
|
276
|
+
except ValueError as exc:
|
|
277
|
+
raise ConfigurationError(f"cedar schema failed to parse: {exc}") from exc
|
|
278
|
+
|
|
279
|
+
self._policies = policies
|
|
280
|
+
self._schema_text = schema
|
|
281
|
+
self._policy_set = policy_set
|
|
282
|
+
self._entity_handle = entity_handle
|
|
283
|
+
self._schema = parsed_schema
|
|
284
|
+
self._hash = _policy_set_hash(policies, schema)
|
|
285
|
+
|
|
286
|
+
@classmethod
|
|
287
|
+
def _rebound(cls, source: PolicyEngine, entity_handle: cedarpy.Entities) -> PolicyEngine:
|
|
288
|
+
"""Build an engine sharing ``source``'s parsed policy set and schema.
|
|
289
|
+
|
|
290
|
+
Bypasses ``__init__`` deliberately: re-running it would re-parse policy text that is
|
|
291
|
+
already known good, which is the cost :meth:`with_entities` exists to avoid.
|
|
292
|
+
"""
|
|
293
|
+
engine = cls.__new__(cls)
|
|
294
|
+
engine._policies = source._policies
|
|
295
|
+
engine._schema_text = source._schema_text
|
|
296
|
+
engine._policy_set = source._policy_set
|
|
297
|
+
engine._entity_handle = entity_handle
|
|
298
|
+
engine._schema = source._schema
|
|
299
|
+
engine._hash = source._hash
|
|
300
|
+
return engine
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def policy_set_hash(self) -> str:
|
|
304
|
+
"""Stable digest of the policy text and schema this engine evaluates against."""
|
|
305
|
+
return self._hash
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def policies(self) -> str:
|
|
309
|
+
"""The Cedar policy text this engine was built from."""
|
|
310
|
+
return self._policies
|
|
311
|
+
|
|
312
|
+
def with_entities(self, entities: Iterable[Entity]) -> PolicyEngine:
|
|
313
|
+
"""Return a new engine with ``entities`` added to the base graph.
|
|
314
|
+
|
|
315
|
+
The receiver is unchanged and its parsed handles are reused, so this is cheap enough
|
|
316
|
+
to call when a tenant's entities are loaded alongside the shared ones.
|
|
317
|
+
"""
|
|
318
|
+
added = tuple(entities)
|
|
319
|
+
if not added:
|
|
320
|
+
return self
|
|
321
|
+
try:
|
|
322
|
+
entity_handle = self._entity_handle.with_added_json_str(_entities_json(added))
|
|
323
|
+
except ValueError as exc:
|
|
324
|
+
raise ConfigurationError(f"cedar entities failed to merge: {exc}") from exc
|
|
325
|
+
return self._rebound(self, entity_handle)
|
|
326
|
+
|
|
327
|
+
def evaluate(self, request: CedarRequest) -> CedarDecision:
|
|
328
|
+
"""Ask Cedar whether ``request`` is authorized.
|
|
329
|
+
|
|
330
|
+
Never raises on a denial or on a Cedar evaluation error: both come back on the
|
|
331
|
+
returned decision, because deciding what an engine failure *means* is the
|
|
332
|
+
:class:`~sentrykit.engine.Governor`'s job, governed by ``fail_mode``.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
request: The question to answer.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
The normalized decision.
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
ConfigurationError: If the request's per-request entities cannot be merged onto
|
|
342
|
+
the base graph — for example a delta uid that duplicates a different base
|
|
343
|
+
entity. That is a wiring fault, not a decision.
|
|
344
|
+
"""
|
|
345
|
+
entity_handle = self._entity_handle
|
|
346
|
+
if request.entities:
|
|
347
|
+
try:
|
|
348
|
+
entity_handle = entity_handle.with_added_json_str(_entities_json(request.entities))
|
|
349
|
+
except ValueError as exc:
|
|
350
|
+
raise ConfigurationError(
|
|
351
|
+
f"per-request cedar entities failed to merge: {exc}"
|
|
352
|
+
) from exc
|
|
353
|
+
|
|
354
|
+
payload: dict[str, object] = {
|
|
355
|
+
"principal": request.principal.as_json(),
|
|
356
|
+
"action": request.action.as_json(),
|
|
357
|
+
"resource": request.resource.as_json(),
|
|
358
|
+
"context": _coerce_mapping(request.context),
|
|
359
|
+
}
|
|
360
|
+
if request.correlation_id is not None:
|
|
361
|
+
payload["correlation_id"] = request.correlation_id
|
|
362
|
+
|
|
363
|
+
result = cedarpy.is_authorized(payload, self._policy_set, entity_handle, self._schema)
|
|
364
|
+
|
|
365
|
+
decision = _DECISIONS.get(result.decision, "no-decision")
|
|
366
|
+
errors = tuple(result.diagnostics.errors)
|
|
367
|
+
determining = _determining_policies(result.diagnostics)
|
|
368
|
+
|
|
369
|
+
if errors:
|
|
370
|
+
# An engine failure, not a policy denial: the operator has a broken policy set or
|
|
371
|
+
# an entity missing an attribute a policy reads. Argument values never appear
|
|
372
|
+
# here -- only ids, the action, and Cedar's own messages.
|
|
373
|
+
_LOGGER.error(
|
|
374
|
+
"cedar evaluation reported errors",
|
|
375
|
+
extra={
|
|
376
|
+
"correlation_id": request.correlation_id,
|
|
377
|
+
"action": str(request.action),
|
|
378
|
+
"decision": decision,
|
|
379
|
+
"cedar_errors": list(errors),
|
|
380
|
+
"policy_set_hash": self._hash,
|
|
381
|
+
},
|
|
382
|
+
)
|
|
383
|
+
else:
|
|
384
|
+
_LOGGER.debug(
|
|
385
|
+
"cedar decision",
|
|
386
|
+
extra={
|
|
387
|
+
"correlation_id": request.correlation_id,
|
|
388
|
+
"action": str(request.action),
|
|
389
|
+
"decision": decision,
|
|
390
|
+
"determining_policies": list(determining),
|
|
391
|
+
"policy_set_hash": self._hash,
|
|
392
|
+
},
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
return CedarDecision(
|
|
396
|
+
allowed=result.allowed,
|
|
397
|
+
decision=decision,
|
|
398
|
+
determining_policies=determining,
|
|
399
|
+
errors=errors,
|
|
400
|
+
correlation_id=request.correlation_id,
|
|
401
|
+
policy_set_hash=self._hash,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def coerce_value(value: object) -> object | None:
|
|
406
|
+
"""Coerce a Python value into something Cedar's type system accepts.
|
|
407
|
+
|
|
408
|
+
Cedar has integers, booleans, strings, sets, records and entity references — and no
|
|
409
|
+
floating-point type and no null. Feeding it either produces a ``NoDecision`` with the
|
|
410
|
+
unhelpful message ``failed to parse schema from request``, or, in an entity attribute,
|
|
411
|
+
makes the whole entity set fail to parse. Coercing at this boundary is what keeps a
|
|
412
|
+
caller-supplied ``None`` from quietly denying every request.
|
|
413
|
+
|
|
414
|
+
The mapping: ``None`` becomes ``None`` and is dropped by the caller; ``bool``, ``int``
|
|
415
|
+
and ``str`` pass through; ``datetime`` and ``date`` become epoch seconds so policies can
|
|
416
|
+
compare them against ``context.now``; ``float`` and ``Decimal`` are rendered as strings,
|
|
417
|
+
since silently truncating them to integers would change what a policy decides;
|
|
418
|
+
:class:`EntityRef` becomes Cedar's ``{"__entity": ...}`` form; mappings and sequences
|
|
419
|
+
recurse, dropping any element or entry that coerces to ``None``. Anything else is
|
|
420
|
+
stringified.
|
|
421
|
+
|
|
422
|
+
**A supplied mapping is never an escape form.** A record key beginning with ``__`` is
|
|
423
|
+
reserved by Cedar — ``__entity`` promotes a record into a typed entity reference,
|
|
424
|
+
``__extn`` into an extension value — so a mapping that spells one would be
|
|
425
|
+
indistinguishable from a reference the application built itself. Any such key raises
|
|
426
|
+
:class:`~sentrykit.errors.ReservedKey` rather than being coerced, dropped or escaped:
|
|
427
|
+
dropping it would silently turn off an argument-aware ``forbid`` that tests
|
|
428
|
+
``context.args has ...``, and escaping it would leave a value that only fails downstream
|
|
429
|
+
as a Cedar *type* error, which ``fail_mode="open"`` would tolerate. Refusing here refuses
|
|
430
|
+
under either fail mode.
|
|
431
|
+
|
|
432
|
+
**An :class:`EntityRef` object, by contrast, is always promoted, and that is a decision
|
|
433
|
+
about types rather than about provenance.** ``isinstance`` cannot tell where an object
|
|
434
|
+
came from, and every web and agent integration validates a caller's JSON against the
|
|
435
|
+
protected callable's annotations *before* the decorator runs — so a parameter annotated
|
|
436
|
+
``EntityRef`` is a caller-chosen entity, **type included**. Cedar's ``in`` is reflexive,
|
|
437
|
+
which makes ``!(context.args.region in Group::"approved")`` satisfiable by passing the
|
|
438
|
+
approved group itself. A policy reading an entity-valued argument must pin its type
|
|
439
|
+
(``context.args.region is Region && ...``), bound it with a constraint or a declared
|
|
440
|
+
ceiling, or sit behind a Cedar schema that types ``context.args``. Annotate a parameter
|
|
441
|
+
``EntityRef`` only where the caller really is meant to name the entity.
|
|
442
|
+
|
|
443
|
+
Naive ``datetime`` values are read as UTC rather than as local time, so a decision does
|
|
444
|
+
not depend on the timezone of the host that happened to evaluate it.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
value: The value to coerce.
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
A Cedar-safe value, or ``None`` when the value has no Cedar representation and
|
|
451
|
+
should be omitted.
|
|
452
|
+
|
|
453
|
+
Raises:
|
|
454
|
+
ReservedKey: If a mapping anywhere inside ``value`` carries a key beginning with
|
|
455
|
+
``__``.
|
|
456
|
+
"""
|
|
457
|
+
return _coerce(value, "")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _coerce(value: object, path: str) -> object | None:
|
|
461
|
+
"""Coerce one value, carrying ``path`` so a rejection can say where it came from.
|
|
462
|
+
|
|
463
|
+
Split from :func:`coerce_value` only to keep the path out of the public signature: a
|
|
464
|
+
caller coercing a single value has no path to give, and the recursion needs one.
|
|
465
|
+
"""
|
|
466
|
+
if value is None:
|
|
467
|
+
return None
|
|
468
|
+
# bool before int: bool is a subclass of int, and Cedar distinguishes the two.
|
|
469
|
+
if isinstance(value, bool | int | str):
|
|
470
|
+
return value
|
|
471
|
+
if isinstance(value, EntityRef):
|
|
472
|
+
return {"__entity": value.as_json()}
|
|
473
|
+
if isinstance(value, Enum):
|
|
474
|
+
return _coerce(value.value, path)
|
|
475
|
+
# datetime before date: datetime is a subclass of date.
|
|
476
|
+
if isinstance(value, dt.datetime):
|
|
477
|
+
moment = value if value.tzinfo is not None else value.replace(tzinfo=dt.UTC)
|
|
478
|
+
return int(moment.timestamp())
|
|
479
|
+
if isinstance(value, dt.date):
|
|
480
|
+
return int(dt.datetime.combine(value, dt.time.min, tzinfo=dt.UTC).timestamp())
|
|
481
|
+
# Cedar has no floating-point type. Truncating to int would silently change what a
|
|
482
|
+
# policy decides (a 1.5 threshold becoming 1), so render the exact value as a string.
|
|
483
|
+
if isinstance(value, float | Decimal):
|
|
484
|
+
return str(value)
|
|
485
|
+
if isinstance(value, Mapping):
|
|
486
|
+
return _coerce_mapping({str(key): item for key, item in value.items()}, path)
|
|
487
|
+
if isinstance(value, set | frozenset):
|
|
488
|
+
return _coerce_sequence(sorted(value, key=repr), path)
|
|
489
|
+
if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
|
|
490
|
+
return _coerce_sequence(value, path)
|
|
491
|
+
return str(value)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _coerce_mapping(mapping: Mapping[str, object], path: str = "") -> dict[str, object]:
|
|
495
|
+
"""Coerce every value of ``mapping``, dropping the keys with no Cedar representation.
|
|
496
|
+
|
|
497
|
+
Dropping rather than substituting is deliberate: Cedar has no null, so a record with a
|
|
498
|
+
``None`` member is unrepresentable, while a record with the member *absent* is exactly
|
|
499
|
+
what ``has`` tests for in a policy.
|
|
500
|
+
|
|
501
|
+
Reserved keys are checked *before* anything is coerced, so a forged escape form never
|
|
502
|
+
reaches the evaluator and never reaches the record being built either.
|
|
503
|
+
|
|
504
|
+
Raises:
|
|
505
|
+
ReservedKey: If any key begins with ``__``.
|
|
506
|
+
"""
|
|
507
|
+
coerced: dict[str, object] = {}
|
|
508
|
+
for key, value in mapping.items():
|
|
509
|
+
name = str(key)
|
|
510
|
+
if name.startswith(RESERVED_KEY_PREFIX):
|
|
511
|
+
raise ReservedKey(name, path)
|
|
512
|
+
item = _coerce(value, _within(path, name))
|
|
513
|
+
if item is not None:
|
|
514
|
+
coerced[name] = item
|
|
515
|
+
return coerced
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _coerce_sequence(values: Iterable[object], path: str = "") -> list[object]:
|
|
519
|
+
"""Coerce every element of ``values``, dropping the ones with no Cedar representation."""
|
|
520
|
+
coerced = (_coerce(value, f"{path}[{index}]") for index, value in enumerate(values))
|
|
521
|
+
return [item for item in coerced if item is not None]
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _within(path: str, key: str) -> str:
|
|
525
|
+
"""Extend a dotted path with one record member, for a rejection's ``path``."""
|
|
526
|
+
return f"{path}.{key}" if path else key
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _entities_json(entities: Iterable[Entity]) -> str:
|
|
530
|
+
"""Render entities as the Cedar entity-JSON document ``cedarpy`` parses."""
|
|
531
|
+
return json.dumps([entity.as_json() for entity in entities])
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _policy_set_hash(policies: str, schema: str | None) -> str:
|
|
535
|
+
"""Digest the policy text and schema so a decision can be replayed against the same logic."""
|
|
536
|
+
digest = hashlib.sha256()
|
|
537
|
+
digest.update(policies.encode("utf-8"))
|
|
538
|
+
digest.update(b"\x00")
|
|
539
|
+
digest.update((schema or "").encode("utf-8"))
|
|
540
|
+
return digest.hexdigest()
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _determining_policies(diagnostics: cedarpy.Diagnostics) -> tuple[str, ...]:
|
|
544
|
+
"""Name the policies that decided a request, preferring their ``@id`` annotation.
|
|
545
|
+
|
|
546
|
+
Cedar's own reasons are parser-generated positional ids (``policy0``), which change when
|
|
547
|
+
an unrelated policy is inserted above. The ``@id("...")`` annotation is the stable,
|
|
548
|
+
human-meaningful name, so it wins wherever a policy declares a non-empty one.
|
|
549
|
+
"""
|
|
550
|
+
annotations = diagnostics.id_annotations_by_reason
|
|
551
|
+
named: list[str] = []
|
|
552
|
+
for policy_id in diagnostics.reasons:
|
|
553
|
+
annotation = annotations.get(policy_id)
|
|
554
|
+
named.append(annotation if isinstance(annotation, str) and annotation else policy_id)
|
|
555
|
+
return tuple(named)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@dataclass(frozen=True, slots=True)
|
|
559
|
+
class PolicyAnnotation:
|
|
560
|
+
"""One policy's identity and annotations, read out of the policy text.
|
|
561
|
+
|
|
562
|
+
Cedar's ``PolicySet`` handle is opaque — it cannot be walked — but
|
|
563
|
+
``cedarpy.policies_to_json_str`` renders the parsed set as JSON carrying *every*
|
|
564
|
+
annotation, not just ``@id``. That is the only way to see an ``@obligations(...)``
|
|
565
|
+
declaration, which is why :func:`policy_annotations` exists and why obligations are
|
|
566
|
+
declared as annotations at all.
|
|
567
|
+
|
|
568
|
+
Attributes:
|
|
569
|
+
cedar_id: Cedar's generated id (``policy0``). Positional in the policy text, and the
|
|
570
|
+
key that ``diagnostics.reasons`` reports.
|
|
571
|
+
effect: ``permit`` or ``forbid``.
|
|
572
|
+
annotations: Every annotation on the policy. A valueless annotation
|
|
573
|
+
(``@obligations`` with no parentheses) maps to ``None``.
|
|
574
|
+
"""
|
|
575
|
+
|
|
576
|
+
cedar_id: str
|
|
577
|
+
effect: Literal["permit", "forbid"]
|
|
578
|
+
annotations: Mapping[str, str | None] = field(default_factory=dict)
|
|
579
|
+
|
|
580
|
+
@property
|
|
581
|
+
def declared_id(self) -> str:
|
|
582
|
+
"""The ``@id`` annotation when the policy declares a non-empty one, else :attr:`cedar_id`.
|
|
583
|
+
|
|
584
|
+
Matches how :attr:`CedarDecision.determining_policies` names a policy, so the two can
|
|
585
|
+
be joined.
|
|
586
|
+
"""
|
|
587
|
+
declared = self.annotations.get("id")
|
|
588
|
+
return declared if isinstance(declared, str) and declared else self.cedar_id
|
|
589
|
+
|
|
590
|
+
def annotation(self, key: str) -> str | None:
|
|
591
|
+
"""Return one annotation's value, or ``None`` when it is absent or valueless."""
|
|
592
|
+
value = self.annotations.get(key)
|
|
593
|
+
return value if isinstance(value, str) and value else None
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def policy_annotations(policies: str) -> tuple[PolicyAnnotation, ...]:
|
|
597
|
+
"""Read the effect and annotations of every static policy in ``policies``.
|
|
598
|
+
|
|
599
|
+
The generated ids this returns are positional in ``policies``, so the result is only
|
|
600
|
+
joinable with a decision if it was produced from **exactly** the text the
|
|
601
|
+
:class:`PolicyEngine` was built from. Callers that concatenate policy fragments must
|
|
602
|
+
concatenate once, deterministically, and derive both the engine and this index from that
|
|
603
|
+
one string.
|
|
604
|
+
|
|
605
|
+
Templates and template links are ignored: only static policies can be determining.
|
|
606
|
+
|
|
607
|
+
Args:
|
|
608
|
+
policies: Cedar policy text.
|
|
609
|
+
|
|
610
|
+
Returns:
|
|
611
|
+
One entry per static policy, in the order Cedar numbered them.
|
|
612
|
+
|
|
613
|
+
Raises:
|
|
614
|
+
ConfigurationError: If the text does not parse.
|
|
615
|
+
"""
|
|
616
|
+
if not policies.strip():
|
|
617
|
+
return ()
|
|
618
|
+
try:
|
|
619
|
+
rendered = json.loads(cedarpy.policies_to_json_str(policies))
|
|
620
|
+
except ValueError as exc:
|
|
621
|
+
raise ConfigurationError(f"cedar policy set failed to parse: {exc}") from exc
|
|
622
|
+
|
|
623
|
+
read: list[PolicyAnnotation] = []
|
|
624
|
+
for cedar_id, policy in rendered.get("staticPolicies", {}).items():
|
|
625
|
+
# A policy with no annotations renders ``"annotations": null`` rather than ``{}``,
|
|
626
|
+
# and an annotation written without parentheses renders a null value — both mean
|
|
627
|
+
# "absent" and neither can be assumed to be a dict or a str.
|
|
628
|
+
annotations = policy.get("annotations") or {}
|
|
629
|
+
effect = policy.get("effect")
|
|
630
|
+
read.append(
|
|
631
|
+
PolicyAnnotation(
|
|
632
|
+
cedar_id=str(cedar_id),
|
|
633
|
+
effect="forbid" if effect == "forbid" else "permit",
|
|
634
|
+
annotations={str(key): value for key, value in annotations.items()},
|
|
635
|
+
)
|
|
636
|
+
)
|
|
637
|
+
return tuple(read)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
@dataclass(frozen=True, slots=True)
|
|
641
|
+
class PolicyValidationError:
|
|
642
|
+
"""One complaint Cedar's validator made about a policy set.
|
|
643
|
+
|
|
644
|
+
Attributes:
|
|
645
|
+
cedar_id: The generated id of the offending policy, or empty when the failure was a
|
|
646
|
+
parse error and no policy could be identified.
|
|
647
|
+
policy_id: That policy's ``@id`` annotation where it declares one, else
|
|
648
|
+
:attr:`cedar_id`, else ``"<parse>"`` — so a report names policies the way the
|
|
649
|
+
author does.
|
|
650
|
+
message: Cedar's own wording.
|
|
651
|
+
"""
|
|
652
|
+
|
|
653
|
+
cedar_id: str
|
|
654
|
+
policy_id: str
|
|
655
|
+
message: str
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@dataclass(frozen=True, slots=True)
|
|
659
|
+
class PolicyValidation:
|
|
660
|
+
"""The outcome of validating a policy set against a schema.
|
|
661
|
+
|
|
662
|
+
Attributes:
|
|
663
|
+
passed: Whether Cedar found nothing to complain about.
|
|
664
|
+
errors: Every complaint, in the order Cedar reported them.
|
|
665
|
+
"""
|
|
666
|
+
|
|
667
|
+
passed: bool
|
|
668
|
+
errors: tuple[PolicyValidationError, ...] = ()
|
|
669
|
+
|
|
670
|
+
def grouped(self) -> dict[str, list[str]]:
|
|
671
|
+
"""Group the messages by the policy they belong to.
|
|
672
|
+
|
|
673
|
+
One mistake routinely produces several errors — an unrecognized action yields both
|
|
674
|
+
"unrecognized action" and "unable to find an applicable action" — so error count is
|
|
675
|
+
not policy count, and a flat list reads as worse than the problem is.
|
|
676
|
+
"""
|
|
677
|
+
grouped: dict[str, list[str]] = {}
|
|
678
|
+
for error in self.errors:
|
|
679
|
+
grouped.setdefault(error.policy_id, []).append(error.message)
|
|
680
|
+
return grouped
|
|
681
|
+
|
|
682
|
+
def describe(self) -> str:
|
|
683
|
+
"""Render every complaint as one line, policies in sorted order."""
|
|
684
|
+
return "; ".join(
|
|
685
|
+
f"{policy_id}: {' / '.join(messages)}"
|
|
686
|
+
for policy_id, messages in sorted(self.grouped().items())
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def validate_policies(policies: str, schema: str) -> PolicyValidation:
|
|
691
|
+
"""Type-check ``policies`` against ``schema``, normalizing Cedar's result.
|
|
692
|
+
|
|
693
|
+
Cedar's validator **never raises**: an unparseable policy, an unparseable schema and a
|
|
694
|
+
genuine type error all come back as entries in ``errors`` with ``validation_passed``
|
|
695
|
+
false. Branching on the flag is the only way to notice, and getting that wrong means a
|
|
696
|
+
policy set that cannot possibly permit anything sails through wiring and denies every
|
|
697
|
+
request at runtime instead.
|
|
698
|
+
|
|
699
|
+
Args:
|
|
700
|
+
policies: Cedar policy text.
|
|
701
|
+
schema: Cedar schema text. The JSON schema form is not accepted — ``Schema.from_str``
|
|
702
|
+
rejects it — so this is the ``.cedarschema`` surface syntax.
|
|
703
|
+
|
|
704
|
+
Returns:
|
|
705
|
+
The normalized outcome. Callers decide what a failure means; this function does not
|
|
706
|
+
raise for one, because "the policy set is invalid" is a finding to report for
|
|
707
|
+
``sentrykit policy validate`` and a :class:`~sentrykit.errors.ConfigurationError` for
|
|
708
|
+
a policy source.
|
|
709
|
+
"""
|
|
710
|
+
result = cedarpy.validate_policies(policies, schema)
|
|
711
|
+
declared = dict(result.id_annotations_by_policy_id or {})
|
|
712
|
+
errors = tuple(
|
|
713
|
+
PolicyValidationError(
|
|
714
|
+
cedar_id=str(error.policy_id or ""),
|
|
715
|
+
policy_id=str(declared.get(str(error.policy_id or "")) or error.policy_id or "<parse>"),
|
|
716
|
+
message=str(error.error),
|
|
717
|
+
)
|
|
718
|
+
for error in result.errors
|
|
719
|
+
)
|
|
720
|
+
return PolicyValidation(passed=bool(result.validation_passed), errors=errors)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def format_policies(policies: str, *, line_width: int = 80, indent_width: int = 2) -> str:
|
|
724
|
+
"""Render ``policies`` the way Cedar's own formatter would write them.
|
|
725
|
+
|
|
726
|
+
The lint half of ``sentrykit policy validate``. A policy file that does not equal its own
|
|
727
|
+
formatting is reported as a warning and never rewritten, because reviewed policy text is
|
|
728
|
+
not something a validation command edits — so the only thing a caller needs is the
|
|
729
|
+
formatting Cedar *would* produce, to compare against.
|
|
730
|
+
|
|
731
|
+
It lives here for the reason the module docstring gives: ``cedarpy`` is imported by
|
|
732
|
+
exactly one module, and a second import in the CLI would turn that claim into a comment.
|
|
733
|
+
|
|
734
|
+
Args:
|
|
735
|
+
policies: Cedar policy text.
|
|
736
|
+
line_width: Column the formatter wraps at.
|
|
737
|
+
indent_width: Spaces per indent level.
|
|
738
|
+
|
|
739
|
+
Returns:
|
|
740
|
+
The formatted text, ending in a newline.
|
|
741
|
+
|
|
742
|
+
Raises:
|
|
743
|
+
ConfigurationError: If the text does not parse. Cedar's own message here is terse
|
|
744
|
+
(``cannot parse input policies``), so a caller holding a filename should name the
|
|
745
|
+
file it came from.
|
|
746
|
+
"""
|
|
747
|
+
try:
|
|
748
|
+
return str(cedarpy.format_policies(policies, line_width, indent_width))
|
|
749
|
+
except ValueError as exc:
|
|
750
|
+
raise ConfigurationError(f"cedar policy text failed to parse: {exc}") from exc
|