sentrykit 0.1.0rc1__py3-none-any.whl

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