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/stores/base.py
ADDED
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
"""The three store contracts, and the value objects that cross them.
|
|
2
|
+
|
|
3
|
+
Architecture §4 insists on *three* stores rather than one, because they have three different
|
|
4
|
+
change profiles and three different guarantees:
|
|
5
|
+
|
|
6
|
+
* :class:`PolicySource` — Cedar policy **logic**. Read-mostly, authored in git, distributed
|
|
7
|
+
as a versioned artifact. Multiple sources compose by **union**; ordering them cannot change
|
|
8
|
+
a decision, because an override is expressed as a Cedar ``forbid`` and forbid always wins.
|
|
9
|
+
* :class:`GrantStore` — roles, group edges, per-argument constraints, expiries. **Mutable**;
|
|
10
|
+
the only thing an admin surface ever writes. Multiple stores merge by precedence or route
|
|
11
|
+
by namespace.
|
|
12
|
+
* :class:`AuditSink` — the decision log. **Append-only and hash-chained.** Multiple sinks
|
|
13
|
+
fan out.
|
|
14
|
+
|
|
15
|
+
They are :class:`~typing.Protocol` s, not base classes, so a third-party backend satisfies
|
|
16
|
+
them structurally without importing anything from sentrykit — the discovery half of the
|
|
17
|
+
plugin mechanism.
|
|
18
|
+
Every reference implementation in this package registers through the same decorator a
|
|
19
|
+
third-party package would use, so the core cannot tell them apart.
|
|
20
|
+
|
|
21
|
+
This module holds only contracts and value objects. It deliberately imports no backend, so
|
|
22
|
+
``registry.py`` can talk about a sink it has not yet loaded.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from datetime import UTC, datetime
|
|
31
|
+
from typing import TYPE_CHECKING, Final, Literal, Protocol, runtime_checkable
|
|
32
|
+
|
|
33
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
34
|
+
|
|
35
|
+
from sentrykit.cedar import Entity, EntityRef, PolicyAnnotation, PolicyEngine, policy_annotations
|
|
36
|
+
from sentrykit.constraints import ArgumentConstraint, ConstraintSet, parse_ceiling
|
|
37
|
+
from sentrykit.context import Principal
|
|
38
|
+
from sentrykit.errors import ConfigurationError
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING: # pragma: no cover - imported for annotations only, and audit imports us
|
|
41
|
+
from sentrykit.audit import AuthzAuditRecord
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"CONSTRAINTS_ANNOTATION",
|
|
45
|
+
"OBLIGATIONS_ANNOTATION",
|
|
46
|
+
"WILDCARD_ACTION",
|
|
47
|
+
"AuditSink",
|
|
48
|
+
"AuditWindow",
|
|
49
|
+
"ContextProvider",
|
|
50
|
+
"GrantKind",
|
|
51
|
+
"GrantRecord",
|
|
52
|
+
"GrantSpec",
|
|
53
|
+
"GrantStore",
|
|
54
|
+
"NullAuditSink",
|
|
55
|
+
"PolicyBundle",
|
|
56
|
+
"PolicyEffect",
|
|
57
|
+
"PolicyProvenance",
|
|
58
|
+
"PolicySource",
|
|
59
|
+
"SupportsClose",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
PolicyEffect = Literal["permit", "forbid"]
|
|
63
|
+
"""Whether a policy permits or forbids."""
|
|
64
|
+
|
|
65
|
+
GrantKind = Literal["role", "parent", "attribute", "constraint"]
|
|
66
|
+
"""What a grant confers.
|
|
67
|
+
|
|
68
|
+
``role`` and ``parent`` are both parent edges — ``role`` is the shorthand that becomes
|
|
69
|
+
``Role::"<subject>"`` — and they are what make a multi-hop relationship resolvable by Cedar.
|
|
70
|
+
``attribute`` sets a principal attribute. ``constraint`` attaches an
|
|
71
|
+
:class:`~sentrykit.constraints.ArgumentConstraint` for one argument of one action.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
OBLIGATIONS_ANNOTATION: Final = "obligations"
|
|
75
|
+
"""The Cedar annotation obligations are declared in.
|
|
76
|
+
|
|
77
|
+
One comma-separated value, because Cedar rejects a repeated annotation key outright
|
|
78
|
+
(``ValueError: duplicate annotation``).
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
CONSTRAINTS_ANNOTATION: Final = "constraints"
|
|
82
|
+
"""The Cedar annotation an argument ceiling is declared in.
|
|
83
|
+
|
|
84
|
+
One JSON object per policy, for the reason ``@obligations`` carries one comma-separated
|
|
85
|
+
value: Cedar rejects a repeated annotation key outright.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
WILDCARD_ACTION: Final = "*"
|
|
89
|
+
"""Constraint key meaning "every action", matching :mod:`sentrykit.context`'s spelling."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --- policy source -----------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class PolicyProvenance:
|
|
97
|
+
"""Where one policy came from, and what it declares.
|
|
98
|
+
|
|
99
|
+
One of these per static policy in a loaded bundle. The audit record carries the entries
|
|
100
|
+
matching a decision's determining policies, which is what lets an auditor answer "which
|
|
101
|
+
file, at which version, in which store, produced this denial" without re-deriving it.
|
|
102
|
+
|
|
103
|
+
Attributes:
|
|
104
|
+
cedar_id: Cedar's generated id (``policy0``), positional in the bundle's policy text.
|
|
105
|
+
policy_id: The ``@id`` annotation, namespaced by the composite when more than one
|
|
106
|
+
source contributes. Falls back to :attr:`cedar_id`. This is the spelling
|
|
107
|
+
:attr:`~sentrykit.cedar.CedarDecision.determining_policies` reports.
|
|
108
|
+
effect: ``permit`` or ``forbid``.
|
|
109
|
+
source: Name of the :class:`PolicySource` that contributed it.
|
|
110
|
+
origin: Artifact-level provenance — a file path, an S3 version id, a git commit.
|
|
111
|
+
annotations: Every annotation on the policy.
|
|
112
|
+
obligations: The parsed ``@obligations`` annotation, split and stripped.
|
|
113
|
+
ceiling: The parsed ``@constraints`` annotation -- the argument bounds this policy
|
|
114
|
+
declares -- or ``None`` when it declares none. Parsed once at load, never at
|
|
115
|
+
decision time, so a malformed declaration fails at wiring.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
cedar_id: str
|
|
119
|
+
policy_id: str
|
|
120
|
+
effect: PolicyEffect
|
|
121
|
+
source: str = ""
|
|
122
|
+
origin: str = ""
|
|
123
|
+
annotations: Mapping[str, str | None] = field(default_factory=dict)
|
|
124
|
+
obligations: tuple[str, ...] = ()
|
|
125
|
+
ceiling: ConstraintSet | None = None
|
|
126
|
+
|
|
127
|
+
def as_record(self) -> dict[str, object]:
|
|
128
|
+
"""Render for the audit record's ``provenance`` list.
|
|
129
|
+
|
|
130
|
+
The declaring annotations are carried through when the policy has any, because
|
|
131
|
+
``@risk("high")`` on the policy that refused a call is exactly the kind of thing an
|
|
132
|
+
auditor reads the provenance for. Empty collections are omitted so a record stays
|
|
133
|
+
readable.
|
|
134
|
+
"""
|
|
135
|
+
rendered: dict[str, object] = {
|
|
136
|
+
"cedar_id": self.cedar_id,
|
|
137
|
+
"policy_id": self.policy_id,
|
|
138
|
+
"effect": self.effect,
|
|
139
|
+
"source": self.source,
|
|
140
|
+
"origin": self.origin,
|
|
141
|
+
}
|
|
142
|
+
if self.obligations:
|
|
143
|
+
rendered["obligations"] = list(self.obligations)
|
|
144
|
+
if self.annotations:
|
|
145
|
+
rendered["annotations"] = dict(self.annotations)
|
|
146
|
+
return rendered
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True, slots=True)
|
|
150
|
+
class PolicyBundle:
|
|
151
|
+
"""Everything a :class:`PolicySource` load produced, ready to become a policy engine.
|
|
152
|
+
|
|
153
|
+
The bundle owns the join between a decision and the policies that made it. Cedar numbers
|
|
154
|
+
policies positionally in the text it parsed, so :attr:`provenance` is only meaningful
|
|
155
|
+
alongside the *exact* :attr:`policies` string it was derived from — which is why the two
|
|
156
|
+
travel together in one immutable object rather than being reassembled by a caller.
|
|
157
|
+
|
|
158
|
+
Attributes:
|
|
159
|
+
policies: The concatenated Cedar policy text.
|
|
160
|
+
schema: Cedar schema text, when the source supplies one.
|
|
161
|
+
entities: The stable entity graph the source contributes — roles, teams, resources.
|
|
162
|
+
Principals come from a :class:`GrantStore`, never from here.
|
|
163
|
+
provenance: One entry per static policy, in Cedar's numbering order.
|
|
164
|
+
bundle_hash: Digest pinning the exact logic a decision was made against, so the
|
|
165
|
+
decision replays.
|
|
166
|
+
source: Name of the source (or composite) that produced this bundle.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
policies: str
|
|
170
|
+
schema: str | None = None
|
|
171
|
+
entities: tuple[Entity, ...] = ()
|
|
172
|
+
provenance: tuple[PolicyProvenance, ...] = ()
|
|
173
|
+
bundle_hash: str = ""
|
|
174
|
+
source: str = ""
|
|
175
|
+
|
|
176
|
+
def engine(self) -> PolicyEngine:
|
|
177
|
+
"""Build the Cedar engine for this bundle.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
An engine over :attr:`policies`, :attr:`entities` and :attr:`schema`.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
ConfigurationError: If any of the three fail to parse.
|
|
184
|
+
"""
|
|
185
|
+
return PolicyEngine(self.policies, self.entities, schema=self.schema)
|
|
186
|
+
|
|
187
|
+
def _index(self) -> dict[str, PolicyProvenance]:
|
|
188
|
+
"""Key every provenance entry by each name a decision could report it under.
|
|
189
|
+
|
|
190
|
+
Three keys per entry, in precedence order: the (possibly namespaced)
|
|
191
|
+
:attr:`~PolicyProvenance.policy_id`, Cedar's generated id, and — the awkward one —
|
|
192
|
+
the *raw* ``@id`` annotation. The third is needed because namespacing happens on our
|
|
193
|
+
side of Cedar: a bundle loaded with ``namespace="hr"`` records ``hr/btg-read`` while
|
|
194
|
+
Cedar keeps reporting ``btg-read``, and without the raw key the join would silently
|
|
195
|
+
find nothing exactly when a composite is in play.
|
|
196
|
+
|
|
197
|
+
Earlier entries win, so a duplicate ``@id`` across two sources resolves to the first
|
|
198
|
+
one rather than raising — the namespaced key already distinguishes them for anyone
|
|
199
|
+
who asks precisely.
|
|
200
|
+
"""
|
|
201
|
+
index: dict[str, PolicyProvenance] = {}
|
|
202
|
+
for entry in self.provenance:
|
|
203
|
+
index.setdefault(entry.policy_id, entry)
|
|
204
|
+
for entry in self.provenance:
|
|
205
|
+
index.setdefault(entry.cedar_id, entry)
|
|
206
|
+
for entry in self.provenance:
|
|
207
|
+
declared = entry.annotations.get("id")
|
|
208
|
+
if isinstance(declared, str) and declared:
|
|
209
|
+
index.setdefault(declared, entry)
|
|
210
|
+
return index
|
|
211
|
+
|
|
212
|
+
def provenance_for(self, determining: Sequence[str]) -> tuple[PolicyProvenance, ...]:
|
|
213
|
+
"""Resolve the provenance of the policies that decided a request.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
determining: :attr:`~sentrykit.cedar.CedarDecision.determining_policies`, which
|
|
217
|
+
names policies by their ``@id`` where they declare one and by Cedar's
|
|
218
|
+
generated id otherwise — so both spellings are looked up.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
The matching entries, in the order given. Names with no match are skipped rather
|
|
222
|
+
than raising: an audit record must never fail the decision it describes.
|
|
223
|
+
"""
|
|
224
|
+
if not determining or not self.provenance:
|
|
225
|
+
return ()
|
|
226
|
+
index = self._index()
|
|
227
|
+
resolved = [index[name] for name in determining if name in index]
|
|
228
|
+
return tuple(resolved)
|
|
229
|
+
|
|
230
|
+
def obligations_for(self, determining: Sequence[str], *, allowed: bool) -> tuple[str, ...]:
|
|
231
|
+
"""Collect the obligations attached to the policies that decided a request.
|
|
232
|
+
|
|
233
|
+
Only the determining **permit** policies contribute obligations to a call that
|
|
234
|
+
proceeds — an obligation on a ``forbid`` describes what must happen about a
|
|
235
|
+
refusal, and belongs to the denial record instead.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
determining: The determining policy names from the decision.
|
|
239
|
+
allowed: Whether the decision permitted the call. Selects ``permit`` policies
|
|
240
|
+
when true and ``forbid`` policies when false.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
The obligations, de-duplicated, first-seen order preserved.
|
|
244
|
+
"""
|
|
245
|
+
wanted: PolicyEffect = "permit" if allowed else "forbid"
|
|
246
|
+
# A dict deduplicates while preserving first-seen order, which matters because
|
|
247
|
+
# obligations are discharged in the order they are reported.
|
|
248
|
+
collected: dict[str, None] = {}
|
|
249
|
+
for entry in self.provenance_for(determining):
|
|
250
|
+
if entry.effect != wanted:
|
|
251
|
+
continue
|
|
252
|
+
for obligation in entry.obligations:
|
|
253
|
+
collected.setdefault(obligation, None)
|
|
254
|
+
return tuple(collected)
|
|
255
|
+
|
|
256
|
+
def ceilings_for(self, determining: Sequence[str]) -> tuple[ConstraintSet, ...]:
|
|
257
|
+
"""Collect the argument ceilings declared by the policies that permitted a call.
|
|
258
|
+
|
|
259
|
+
Cedar's gate can only *deny*. A ceiling reaches sentrykit's narrow-and-continue path
|
|
260
|
+
instead, so "you asked for 500, you may not have that -- here is the call at what you
|
|
261
|
+
may have" becomes expressible in the reviewed policy file rather than only in mutable
|
|
262
|
+
grant data. The value is not clamped to the bound: it is rejected, and the principal's
|
|
263
|
+
own default fills the hole.
|
|
264
|
+
|
|
265
|
+
Only determining **permit** policies contribute. A determining ``forbid`` never
|
|
266
|
+
coexists with an allow, so a ceiling there could never apply -- which is why one is
|
|
267
|
+
refused at load rather than ignored here.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
determining: The determining policy names from the decision.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
One entry per contributing policy, in the order reported. Returned separately
|
|
274
|
+
rather than pre-merged so the caller decides how they compose; they intersect,
|
|
275
|
+
because a second layer may only narrow.
|
|
276
|
+
"""
|
|
277
|
+
return tuple(
|
|
278
|
+
entry.ceiling
|
|
279
|
+
for entry in self.provenance_for(determining)
|
|
280
|
+
if entry.effect == "permit" and entry.ceiling is not None
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def declares_ceilings(self) -> bool:
|
|
285
|
+
"""Whether any policy in this bundle declares an argument ceiling.
|
|
286
|
+
|
|
287
|
+
Read on the fail-open path. A ``no-decision`` reports *no* determining policies, so
|
|
288
|
+
every ceiling in the bundle silently stops applying at exactly the moment the engine
|
|
289
|
+
is least trustworthy. A governor over a bundle that declares any refuses instead.
|
|
290
|
+
"""
|
|
291
|
+
return any(entry.ceiling is not None for entry in self.provenance)
|
|
292
|
+
|
|
293
|
+
@classmethod
|
|
294
|
+
def from_text(
|
|
295
|
+
cls,
|
|
296
|
+
policies: str,
|
|
297
|
+
*,
|
|
298
|
+
schema: str | None = None,
|
|
299
|
+
entities: Sequence[Entity] = (),
|
|
300
|
+
source: str = "",
|
|
301
|
+
origin: str = "",
|
|
302
|
+
namespace: str | None = None,
|
|
303
|
+
) -> PolicyBundle:
|
|
304
|
+
"""Build a bundle from policy text, reading the annotation index out of it.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
policies: Cedar policy text.
|
|
308
|
+
schema: Cedar schema text, if any.
|
|
309
|
+
entities: The stable entity graph.
|
|
310
|
+
source: Name of the contributing source, recorded on every provenance entry.
|
|
311
|
+
origin: Artifact-level provenance, recorded on every provenance entry.
|
|
312
|
+
namespace: When set, declared ids are rewritten as ``<namespace>/<id>`` so that
|
|
313
|
+
two sources declaring the same ``@id`` stay distinguishable. Cedar does not
|
|
314
|
+
enforce ``@id`` uniqueness, so this is sentrykit's hygiene, not Cedar's.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
The bundle, with :attr:`bundle_hash` computed.
|
|
318
|
+
|
|
319
|
+
Raises:
|
|
320
|
+
ConfigurationError: If the policy text does not parse, or if a policy declares a
|
|
321
|
+
malformed ``@constraints`` ceiling. Cedar type-checks annotation values not
|
|
322
|
+
at all, so a bad one would otherwise sit in a validated policy set enforcing
|
|
323
|
+
nothing.
|
|
324
|
+
"""
|
|
325
|
+
# One parse of one string produces both halves: the annotation index is keyed by
|
|
326
|
+
# Cedar's *positional* generated ids, so deriving it from anything other than the
|
|
327
|
+
# exact text the engine will be built from silently mis-joins every decision.
|
|
328
|
+
provenance = tuple(
|
|
329
|
+
_provenance_of(annotation, source=source, origin=origin, namespace=namespace)
|
|
330
|
+
for annotation in policy_annotations(policies)
|
|
331
|
+
)
|
|
332
|
+
return cls(
|
|
333
|
+
policies=policies,
|
|
334
|
+
schema=schema,
|
|
335
|
+
entities=tuple(entities),
|
|
336
|
+
provenance=provenance,
|
|
337
|
+
bundle_hash=_bundle_hash(policies, schema),
|
|
338
|
+
source=source,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@runtime_checkable
|
|
343
|
+
class PolicySource(Protocol):
|
|
344
|
+
"""Where Cedar policy logic comes from.
|
|
345
|
+
|
|
346
|
+
Read-mostly by construction: policy *logic* is reviewed in version control, so a
|
|
347
|
+
source's job is to fetch an artifact and say which artifact it fetched — never to
|
|
348
|
+
author or mutate policy at runtime.
|
|
349
|
+
|
|
350
|
+
Implementations are addressed by DSN and registered under the
|
|
351
|
+
``sentrykit.policy_sources`` entry-point group.
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def name(self) -> str:
|
|
356
|
+
"""Short name for this source, used to namespace policy ids and tag provenance."""
|
|
357
|
+
...
|
|
358
|
+
|
|
359
|
+
def load(self) -> PolicyBundle:
|
|
360
|
+
"""Fetch the current policy artifact.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
The loaded bundle, ready to build a :class:`~sentrykit.cedar.PolicyEngine`.
|
|
364
|
+
|
|
365
|
+
Raises:
|
|
366
|
+
ConfigurationError: If the artifact is missing, unparseable, or fails schema
|
|
367
|
+
validation.
|
|
368
|
+
"""
|
|
369
|
+
...
|
|
370
|
+
|
|
371
|
+
def current_hash(self) -> str:
|
|
372
|
+
"""Return the digest of the artifact that :meth:`load` would return right now.
|
|
373
|
+
|
|
374
|
+
Cheap enough to poll: a caller uses it to decide whether a reload is needed without
|
|
375
|
+
paying for a parse. Equal to the loaded bundle's :attr:`PolicyBundle.bundle_hash`.
|
|
376
|
+
"""
|
|
377
|
+
...
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# --- grant store -------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class GrantSpec(BaseModel):
|
|
384
|
+
"""A grant to make: what to confer on whom, and for how long.
|
|
385
|
+
|
|
386
|
+
Grants are *data*, never logic. Nothing here changes reviewed policy — a role is a
|
|
387
|
+
parent edge, an expiry is a timestamp the store enforces at materialization, and an
|
|
388
|
+
argument constraint is an attribute of the grant. That separation is what lets an admin
|
|
389
|
+
API exist without an approval workflow around every call.
|
|
390
|
+
|
|
391
|
+
Attributes:
|
|
392
|
+
principal: Who receives the grant.
|
|
393
|
+
kind: What is being conferred.
|
|
394
|
+
subject: The role name, parent uid, attribute name, or argument name, depending on
|
|
395
|
+
:attr:`kind`.
|
|
396
|
+
value: The attribute value, for ``kind="attribute"``.
|
|
397
|
+
constraint: The argument constraint, for ``kind="constraint"``.
|
|
398
|
+
action: Which action a ``constraint`` applies to — a full uid, a bare action id, or
|
|
399
|
+
``"*"`` for every action, matching
|
|
400
|
+
:meth:`~sentrykit.context.Principal.constraints_for`.
|
|
401
|
+
granted_by: The actor making the grant, recorded for the audit trail.
|
|
402
|
+
expires_at: When the grant stops applying. ``None`` means it does not expire.
|
|
403
|
+
reason: Why the grant was made — the justification an auditor asks for.
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
407
|
+
|
|
408
|
+
principal: EntityRef
|
|
409
|
+
kind: GrantKind
|
|
410
|
+
subject: str
|
|
411
|
+
value: object | None = None
|
|
412
|
+
constraint: ArgumentConstraint | None = None
|
|
413
|
+
action: str = WILDCARD_ACTION
|
|
414
|
+
granted_by: str | None = None
|
|
415
|
+
expires_at: datetime | None = None
|
|
416
|
+
reason: str | None = None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class GrantRecord(BaseModel):
|
|
420
|
+
"""A grant as stored: a :class:`GrantSpec` plus its lifecycle.
|
|
421
|
+
|
|
422
|
+
Returned by every mutation so the caller — and the audit trail — sees exactly what
|
|
423
|
+
changed, including the id needed to revoke it later.
|
|
424
|
+
|
|
425
|
+
Attributes:
|
|
426
|
+
id: Stable identifier for this grant.
|
|
427
|
+
spec: What was granted.
|
|
428
|
+
granted_at: When it was made.
|
|
429
|
+
revoked_at: When it was withdrawn, or ``None`` while it stands.
|
|
430
|
+
revoked_by: The actor who withdrew it.
|
|
431
|
+
revoked_reason: Why it was withdrawn.
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
435
|
+
|
|
436
|
+
id: str
|
|
437
|
+
spec: GrantSpec
|
|
438
|
+
granted_at: datetime
|
|
439
|
+
revoked_at: datetime | None = None
|
|
440
|
+
revoked_by: str | None = None
|
|
441
|
+
revoked_reason: str | None = None
|
|
442
|
+
|
|
443
|
+
def is_active(self, now: datetime) -> bool:
|
|
444
|
+
"""Whether this grant applies at ``now`` — neither revoked nor expired.
|
|
445
|
+
|
|
446
|
+
A naive timestamp on either side is read as UTC rather than as local time, for the
|
|
447
|
+
same reason :func:`~sentrykit.cedar.coerce_value` does it: whether a grant has
|
|
448
|
+
expired must not depend on the timezone of the host that happened to ask.
|
|
449
|
+
"""
|
|
450
|
+
if self.revoked_at is not None:
|
|
451
|
+
return False
|
|
452
|
+
expires_at = self.spec.expires_at
|
|
453
|
+
return expires_at is None or _as_utc(expires_at) > _as_utc(now)
|
|
454
|
+
|
|
455
|
+
def as_record(self) -> dict[str, object]:
|
|
456
|
+
"""Render for an audit mutation record.
|
|
457
|
+
|
|
458
|
+
Rendered by hand rather than dumped, because two fields need shaping: the principal
|
|
459
|
+
is written as its Cedar uid (a bare ``model_dump`` would emit the underlying
|
|
460
|
+
two-tuple), and a constraint is *described* rather than dumped, so a per-principal
|
|
461
|
+
default value cannot leak into the log through the back door.
|
|
462
|
+
"""
|
|
463
|
+
spec = self.spec
|
|
464
|
+
rendered: dict[str, object] = {
|
|
465
|
+
"id": self.id,
|
|
466
|
+
"principal": str(spec.principal),
|
|
467
|
+
"kind": spec.kind,
|
|
468
|
+
"subject": spec.subject,
|
|
469
|
+
"action": spec.action,
|
|
470
|
+
"granted_at": self.granted_at.isoformat(),
|
|
471
|
+
"granted_by": spec.granted_by,
|
|
472
|
+
"reason": spec.reason,
|
|
473
|
+
"expires_at": spec.expires_at.isoformat() if spec.expires_at is not None else None,
|
|
474
|
+
"revoked_at": self.revoked_at.isoformat() if self.revoked_at is not None else None,
|
|
475
|
+
"revoked_by": self.revoked_by,
|
|
476
|
+
"revoked_reason": self.revoked_reason,
|
|
477
|
+
}
|
|
478
|
+
if spec.value is not None:
|
|
479
|
+
rendered["value"] = spec.value
|
|
480
|
+
if spec.constraint is not None:
|
|
481
|
+
rendered["constraint"] = spec.constraint.describe()
|
|
482
|
+
return rendered
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@runtime_checkable
|
|
486
|
+
class GrantStore(Protocol):
|
|
487
|
+
"""Where principals, their edges, and their argument constraints live.
|
|
488
|
+
|
|
489
|
+
The mutable store, and the admin surface's only write target. Because
|
|
490
|
+
``Action::"grant_role"`` is an ordinary Cedar action, an admin surface built on this is
|
|
491
|
+
governed by the same engine it administers and logged into the same sink.
|
|
492
|
+
|
|
493
|
+
Implementations are addressed by DSN and registered under the
|
|
494
|
+
``sentrykit.grant_stores`` entry-point group.
|
|
495
|
+
"""
|
|
496
|
+
|
|
497
|
+
@property
|
|
498
|
+
def name(self) -> str:
|
|
499
|
+
"""Short name for this store, used for namespace routing and audit provenance."""
|
|
500
|
+
...
|
|
501
|
+
|
|
502
|
+
def get_principal(
|
|
503
|
+
self,
|
|
504
|
+
principal: str | EntityRef,
|
|
505
|
+
*,
|
|
506
|
+
now: datetime | None = None,
|
|
507
|
+
) -> Principal | None:
|
|
508
|
+
"""Materialize a principal with its roles, parents, attributes and constraints.
|
|
509
|
+
|
|
510
|
+
"Materialized" is the whole contract: the returned
|
|
511
|
+
:class:`~sentrykit.context.Principal` is ready to hand to the engine, with expired
|
|
512
|
+
and revoked grants already excluded, so the decision path never reasons about grant
|
|
513
|
+
lifecycles.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
principal: The principal id (assumed type ``User``) or a full reference.
|
|
517
|
+
now: The moment to evaluate expiries against. Defaults to the current UTC time;
|
|
518
|
+
pass one to make a test deterministic.
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
The principal, or ``None`` when the store knows nothing about them. ``None`` is
|
|
522
|
+
not an error — the caller fails closed on it.
|
|
523
|
+
"""
|
|
524
|
+
...
|
|
525
|
+
|
|
526
|
+
def grants_for(
|
|
527
|
+
self,
|
|
528
|
+
principal: str | EntityRef,
|
|
529
|
+
*,
|
|
530
|
+
include_inactive: bool = False,
|
|
531
|
+
) -> tuple[GrantRecord, ...]:
|
|
532
|
+
"""List the grants held by ``principal``.
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
principal: The principal id or reference.
|
|
536
|
+
include_inactive: Whether to include revoked and expired grants, which an audit
|
|
537
|
+
report needs and a decision does not.
|
|
538
|
+
|
|
539
|
+
Returns:
|
|
540
|
+
The grants, oldest first.
|
|
541
|
+
"""
|
|
542
|
+
...
|
|
543
|
+
|
|
544
|
+
def grant(self, spec: GrantSpec, *, now: datetime | None = None) -> GrantRecord:
|
|
545
|
+
"""Confer a grant.
|
|
546
|
+
|
|
547
|
+
A store refuses a grant it could not honour, and an
|
|
548
|
+
:class:`~sentrykit.constraints.ArgumentConstraint` that contradicts itself is one of
|
|
549
|
+
those: inverted bounds, an uncompilable pattern, a permitted value the constraint's
|
|
550
|
+
own other rules reject, or a default it would refuse. Left to be discovered at call
|
|
551
|
+
time, each of those is a refusal of everything, found by whoever calls next. The
|
|
552
|
+
check is :meth:`~sentrykit.constraints.ArgumentConstraint.inconsistencies` and it
|
|
553
|
+
belongs at the write, where the operator who wrote it is still present.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
spec: What to confer.
|
|
557
|
+
now: The grant timestamp. Defaults to the current UTC time.
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
The stored record, carrying the id needed to revoke it.
|
|
561
|
+
|
|
562
|
+
Raises:
|
|
563
|
+
ConfigurationError: If the spec is inconsistent — a ``constraint`` grant with no
|
|
564
|
+
constraint, or one carrying a constraint no value could satisfy.
|
|
565
|
+
"""
|
|
566
|
+
...
|
|
567
|
+
|
|
568
|
+
def revoke(
|
|
569
|
+
self,
|
|
570
|
+
grant_id: str,
|
|
571
|
+
*,
|
|
572
|
+
revoked_by: str | None = None,
|
|
573
|
+
reason: str | None = None,
|
|
574
|
+
now: datetime | None = None,
|
|
575
|
+
) -> GrantRecord:
|
|
576
|
+
"""Withdraw a grant.
|
|
577
|
+
|
|
578
|
+
Revocation marks the record rather than deleting it: "who had what, when" has to
|
|
579
|
+
survive the withdrawal or the audit trail has a hole in it.
|
|
580
|
+
|
|
581
|
+
Args:
|
|
582
|
+
grant_id: The id returned by :meth:`grant`.
|
|
583
|
+
revoked_by: The actor withdrawing it.
|
|
584
|
+
reason: Why.
|
|
585
|
+
now: The revocation timestamp. Defaults to the current UTC time.
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
The updated record.
|
|
589
|
+
|
|
590
|
+
Raises:
|
|
591
|
+
ConfigurationError: If no such grant exists.
|
|
592
|
+
"""
|
|
593
|
+
...
|
|
594
|
+
|
|
595
|
+
def entities(self) -> tuple[Entity, ...]:
|
|
596
|
+
"""Return the stable entities this store contributes to the Cedar graph.
|
|
597
|
+
|
|
598
|
+
Roles, teams and projects that grants point at — the nodes a parent edge needs to
|
|
599
|
+
resolve. Principals are *not* included: they are per-request deltas, so one
|
|
600
|
+
principal's snapshot can never leak into another's decision.
|
|
601
|
+
"""
|
|
602
|
+
...
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# --- audit sink --------------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
class AuditWindow(BaseModel):
|
|
609
|
+
"""A filter over an audit log — the query half of "the yearly report is a query".
|
|
610
|
+
|
|
611
|
+
Every field narrows; an empty window matches everything. Filtering lives on the window
|
|
612
|
+
rather than in each sink so that a third-party backend can push down what it can and lean
|
|
613
|
+
on :meth:`matches` for the rest, without the semantics drifting per backend.
|
|
614
|
+
|
|
615
|
+
Attributes:
|
|
616
|
+
start: Inclusive lower bound on ``timestamp``.
|
|
617
|
+
end: Exclusive upper bound on ``timestamp``.
|
|
618
|
+
principal: Match this principal uid exactly.
|
|
619
|
+
action: Match this action, as either a full uid or a bare id.
|
|
620
|
+
resource: Match this resource uid exactly.
|
|
621
|
+
decision: ``allow`` or ``deny``.
|
|
622
|
+
kind: ``decision`` or ``mutation``.
|
|
623
|
+
limit: Stop after this many records.
|
|
624
|
+
"""
|
|
625
|
+
|
|
626
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
627
|
+
|
|
628
|
+
start: datetime | None = None
|
|
629
|
+
end: datetime | None = None
|
|
630
|
+
principal: str | None = None
|
|
631
|
+
action: str | None = None
|
|
632
|
+
resource: str | None = None
|
|
633
|
+
decision: Literal["allow", "deny"] | None = None
|
|
634
|
+
kind: Literal["decision", "mutation"] | None = None
|
|
635
|
+
limit: int | None = Field(default=None, ge=1)
|
|
636
|
+
|
|
637
|
+
def matches(self, record: AuthzAuditRecord) -> bool:
|
|
638
|
+
"""Whether ``record`` falls inside this window, ignoring :attr:`limit`.
|
|
639
|
+
|
|
640
|
+
The time bounds are half-open — ``start`` inclusive, ``end`` exclusive — so adjacent
|
|
641
|
+
windows tile a log without double-counting a record on the boundary.
|
|
642
|
+
"""
|
|
643
|
+
if self.start is not None and _as_utc(record.timestamp) < _as_utc(self.start):
|
|
644
|
+
return False
|
|
645
|
+
if self.end is not None and _as_utc(record.timestamp) >= _as_utc(self.end):
|
|
646
|
+
return False
|
|
647
|
+
if self.principal is not None and record.principal.uid != self.principal:
|
|
648
|
+
return False
|
|
649
|
+
if self.action is not None and not _names_action(self.action, record.action):
|
|
650
|
+
return False
|
|
651
|
+
if self.resource is not None and record.resource != self.resource:
|
|
652
|
+
return False
|
|
653
|
+
if self.decision is not None and record.decision != self.decision:
|
|
654
|
+
return False
|
|
655
|
+
return not (self.kind is not None and record.kind != self.kind)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@runtime_checkable
|
|
659
|
+
class AuditSink(Protocol):
|
|
660
|
+
"""Where audit records are written, and read back from.
|
|
661
|
+
|
|
662
|
+
Append-only and hash-chained: the chain makes an edited or deleted record *visible*,
|
|
663
|
+
which is detection rather than prevention — durable immutability comes from the storage
|
|
664
|
+
layer behind this protocol. A sink never updates or deletes a record, and never
|
|
665
|
+
masks one: by the time a record reaches :meth:`write` its sensitive values are already
|
|
666
|
+
masked, because masking runs upstream of the immutable write and
|
|
667
|
+
:class:`~sentrykit.audit.AuditTrail` is the only thing that calls this method.
|
|
668
|
+
|
|
669
|
+
Implementations are addressed by DSN and registered under the ``sentrykit.audit_sinks``
|
|
670
|
+
entry-point group.
|
|
671
|
+
"""
|
|
672
|
+
|
|
673
|
+
@property
|
|
674
|
+
def name(self) -> str:
|
|
675
|
+
"""Short name for this sink, used in fan-out error reporting."""
|
|
676
|
+
...
|
|
677
|
+
|
|
678
|
+
def write(self, record: AuthzAuditRecord) -> None:
|
|
679
|
+
"""Append one record.
|
|
680
|
+
|
|
681
|
+
Args:
|
|
682
|
+
record: The record, already masked and already chained.
|
|
683
|
+
|
|
684
|
+
Raises:
|
|
685
|
+
AuditUnavailable: If the record could not be durably appended. The caller
|
|
686
|
+
decides what that means: under ``fail_mode="closed"`` the governed call is
|
|
687
|
+
refused, because an action that cannot be proven cannot be taken.
|
|
688
|
+
"""
|
|
689
|
+
...
|
|
690
|
+
|
|
691
|
+
def query(self, window: AuditWindow) -> Iterator[AuthzAuditRecord]:
|
|
692
|
+
"""Read back the records matching ``window``, oldest first.
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
An iterator, so a sink over a large log need not materialize it.
|
|
696
|
+
|
|
697
|
+
Raises:
|
|
698
|
+
AuditUnavailable: If the log cannot be read.
|
|
699
|
+
"""
|
|
700
|
+
...
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
@runtime_checkable
|
|
704
|
+
class SupportsClose(Protocol):
|
|
705
|
+
"""A backend holding a resource that should be released.
|
|
706
|
+
|
|
707
|
+
Kept separate from :class:`AuditSink` on purpose: a third-party sink that posts to an
|
|
708
|
+
HTTP endpoint has nothing to close, and requiring a no-op ``close`` from it would be
|
|
709
|
+
contract for contract's sake. Composites and the registry close what supports closing.
|
|
710
|
+
"""
|
|
711
|
+
|
|
712
|
+
def close(self) -> None:
|
|
713
|
+
"""Release the backend's resources. Idempotent."""
|
|
714
|
+
...
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
class NullAuditSink:
|
|
718
|
+
"""The sink that discards everything — the default when none is configured.
|
|
719
|
+
|
|
720
|
+
A governor with no sink does not construct records at all, so this exists for the case
|
|
721
|
+
where a sink is *wanted* structurally (a composite with nothing configured, a test
|
|
722
|
+
asserting the write path runs) but nothing should be persisted.
|
|
723
|
+
"""
|
|
724
|
+
|
|
725
|
+
name: str = "null"
|
|
726
|
+
|
|
727
|
+
def write(self, record: AuthzAuditRecord) -> None:
|
|
728
|
+
"""Discard ``record``."""
|
|
729
|
+
|
|
730
|
+
def query(self, window: AuditWindow) -> Iterator[AuthzAuditRecord]:
|
|
731
|
+
"""Yield nothing."""
|
|
732
|
+
return iter(())
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
# --- context provider ---------------------------------------------------------------------
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
@runtime_checkable
|
|
739
|
+
class ContextProvider(Protocol):
|
|
740
|
+
"""How a framework establishes ambient context and renders sentrykit's refusals.
|
|
741
|
+
|
|
742
|
+
Declared here, with the other three contracts, so the plugin machinery is uniform across
|
|
743
|
+
all four entry-point groups. The implementations live under ``sentrykit/integrations/``,
|
|
744
|
+
behind optional extras, and the core must not import them to know the contract.
|
|
745
|
+
Registered under the ``sentrykit.context_providers`` entry-point group.
|
|
746
|
+
"""
|
|
747
|
+
|
|
748
|
+
@property
|
|
749
|
+
def name(self) -> str:
|
|
750
|
+
"""Short name for this provider, e.g. ``fastapi``."""
|
|
751
|
+
...
|
|
752
|
+
|
|
753
|
+
def establish(self, request: object) -> object:
|
|
754
|
+
"""Resolve the principal and environment from a framework request and establish them.
|
|
755
|
+
|
|
756
|
+
Args:
|
|
757
|
+
request: The framework's request object.
|
|
758
|
+
|
|
759
|
+
Returns:
|
|
760
|
+
A :class:`~sentrykit.context.ContextScope` to reset at the end of the boundary.
|
|
761
|
+
"""
|
|
762
|
+
...
|
|
763
|
+
|
|
764
|
+
def render_error(self, error: Exception) -> object:
|
|
765
|
+
"""Render a sentrykit refusal as the framework's error response.
|
|
766
|
+
|
|
767
|
+
Args:
|
|
768
|
+
error: A :class:`~sentrykit.errors.SentrykitError`.
|
|
769
|
+
|
|
770
|
+
Returns:
|
|
771
|
+
The framework-native response, carrying the ``_authz`` envelope at whatever the
|
|
772
|
+
effective verbosity allows.
|
|
773
|
+
"""
|
|
774
|
+
...
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
# --- helpers -----------------------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _as_utc(moment: datetime) -> datetime:
|
|
781
|
+
"""Read a naive timestamp as UTC so two timestamps are always comparable.
|
|
782
|
+
|
|
783
|
+
Mixing an aware and a naive ``datetime`` is a ``TypeError`` in Python, and the places
|
|
784
|
+
that compare them here — grant expiry, an audit window — must not be able to raise from
|
|
785
|
+
a caller passing ``datetime.now()`` instead of ``datetime.now(UTC)``.
|
|
786
|
+
"""
|
|
787
|
+
return moment if moment.tzinfo is not None else moment.replace(tzinfo=UTC)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _names_action(wanted: str, actual: str) -> bool:
|
|
791
|
+
"""Whether ``wanted`` names ``actual``, as either a full uid or a bare action id.
|
|
792
|
+
|
|
793
|
+
Compared textually rather than through :meth:`~sentrykit.cedar.EntityRef.parse`, because
|
|
794
|
+
a mutation record carries an empty action and an unparseable name must read as "does not
|
|
795
|
+
match" rather than as a configuration fault raised from inside a report query.
|
|
796
|
+
"""
|
|
797
|
+
if wanted == actual:
|
|
798
|
+
return True
|
|
799
|
+
_, separator, quoted = actual.partition('::"')
|
|
800
|
+
return bool(separator) and quoted.endswith('"') and quoted[:-1] == wanted
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _bundle_hash(policies: str, schema: str | None) -> str:
|
|
804
|
+
"""Digest the policy text and schema that a bundle pins.
|
|
805
|
+
|
|
806
|
+
Mirrors :func:`sentrykit.cedar.PolicyEngine.policy_set_hash`'s construction — the policy
|
|
807
|
+
bytes, a separator, then the schema bytes — so a bundle's hash and the hash recorded on a
|
|
808
|
+
decision made against it agree. Computed here rather than imported, because a digest
|
|
809
|
+
format the audit trail depends on should not be a private detail of another module.
|
|
810
|
+
"""
|
|
811
|
+
digest = hashlib.sha256()
|
|
812
|
+
digest.update(policies.encode("utf-8"))
|
|
813
|
+
digest.update(b"\x00")
|
|
814
|
+
digest.update((schema or "").encode("utf-8"))
|
|
815
|
+
return digest.hexdigest()
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _provenance_of(
|
|
819
|
+
annotation: PolicyAnnotation,
|
|
820
|
+
*,
|
|
821
|
+
source: str,
|
|
822
|
+
origin: str,
|
|
823
|
+
namespace: str | None,
|
|
824
|
+
) -> PolicyProvenance:
|
|
825
|
+
"""Turn one parsed policy's annotations into a provenance entry.
|
|
826
|
+
|
|
827
|
+
Args:
|
|
828
|
+
annotation: One entry from :func:`~sentrykit.cedar.policy_annotations`.
|
|
829
|
+
source: Name of the contributing source.
|
|
830
|
+
origin: Artifact-level provenance for this policy.
|
|
831
|
+
namespace: Prefix for the declared id, per :meth:`PolicyBundle.from_text`.
|
|
832
|
+
|
|
833
|
+
Returns:
|
|
834
|
+
The entry, with the ``@obligations`` annotation split on commas and stripped (an
|
|
835
|
+
empty segment is dropped, so a trailing comma is not an obligation named ``""``) and
|
|
836
|
+
the ``@constraints`` annotation parsed into a ceiling.
|
|
837
|
+
|
|
838
|
+
Raises:
|
|
839
|
+
ConfigurationError: If a ``@constraints`` declaration is malformed, or is attached to
|
|
840
|
+
a ``forbid``.
|
|
841
|
+
"""
|
|
842
|
+
declared = annotation.declared_id
|
|
843
|
+
policy_id = f"{namespace}/{declared}" if namespace else declared
|
|
844
|
+
declared_obligations = annotation.annotation(OBLIGATIONS_ANNOTATION)
|
|
845
|
+
obligations = (
|
|
846
|
+
tuple(part.strip() for part in declared_obligations.split(",") if part.strip())
|
|
847
|
+
if declared_obligations
|
|
848
|
+
else ()
|
|
849
|
+
)
|
|
850
|
+
ceiling: ConstraintSet | None = None
|
|
851
|
+
if CONSTRAINTS_ANNOTATION in annotation.annotations:
|
|
852
|
+
if annotation.effect == "forbid":
|
|
853
|
+
# Dead metadata, and dangerous as a silent no-op: a reviewer reading the file
|
|
854
|
+
# believes the bound is enforced. A forbid that is determining has already
|
|
855
|
+
# refused the call, so there is no narrowed call left to bound.
|
|
856
|
+
raise ConfigurationError(
|
|
857
|
+
f"policy {policy_id!r} is a forbid and cannot declare an argument ceiling; "
|
|
858
|
+
"a ceiling narrows a call that proceeds"
|
|
859
|
+
)
|
|
860
|
+
ceiling = parse_ceiling(annotation.annotations[CONSTRAINTS_ANNOTATION], policy=policy_id)
|
|
861
|
+
return PolicyProvenance(
|
|
862
|
+
cedar_id=annotation.cedar_id,
|
|
863
|
+
policy_id=policy_id,
|
|
864
|
+
effect=annotation.effect,
|
|
865
|
+
source=source,
|
|
866
|
+
origin=origin,
|
|
867
|
+
annotations=dict(annotation.annotations),
|
|
868
|
+
obligations=obligations,
|
|
869
|
+
ceiling=ceiling,
|
|
870
|
+
)
|