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/context.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Ambient authorization context: who is acting, and under what circumstances.
|
|
2
|
+
|
|
3
|
+
A protected callable almost never receives the principal as an argument — a Flask view takes
|
|
4
|
+
a request, an agent tool takes the arguments the model proposed, a background job takes
|
|
5
|
+
none. Threading a principal through every signature between the boundary and the call would
|
|
6
|
+
be the tax that stops teams from protecting the inner functions at all.
|
|
7
|
+
|
|
8
|
+
So sentrykit makes the principal ambient instead: a context provider calls
|
|
9
|
+
:func:`establish` once at the boundary, and ``@protect`` reads the ambient principal
|
|
10
|
+
wherever the callable happens to live. Three rules follow from that, and they are load-bearing:
|
|
11
|
+
|
|
12
|
+
* An explicit ``_as=`` on the call beats the ambient principal.
|
|
13
|
+
* **No principal means deny.** Not "run unauthenticated", not "skip the check".
|
|
14
|
+
* ``ContextVar`` values propagate across ``await`` and across ``asyncio.to_thread``, but
|
|
15
|
+
*not* across a bare ``ThreadPoolExecutor.submit``. Offloading through an executor without
|
|
16
|
+
:func:`bind_context` would silently lose the principal — and, because of the previous
|
|
17
|
+
rule, fail closed rather than fail open.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from collections.abc import Callable, Generator, Mapping
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from contextvars import ContextVar, Token, copy_context
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from types import MappingProxyType, TracebackType
|
|
28
|
+
from typing import Final, Literal, ParamSpec, TypeVar
|
|
29
|
+
|
|
30
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
31
|
+
|
|
32
|
+
from sentrykit.cedar import EntityRef
|
|
33
|
+
from sentrykit.constraints import ConstraintSet
|
|
34
|
+
from sentrykit.envelope import set_last_envelope
|
|
35
|
+
from sentrykit.errors import NoContext
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Channel",
|
|
39
|
+
"ContextScope",
|
|
40
|
+
"Invocation",
|
|
41
|
+
"Principal",
|
|
42
|
+
"bind_context",
|
|
43
|
+
"clear_context",
|
|
44
|
+
"current_environment",
|
|
45
|
+
"current_principal",
|
|
46
|
+
"establish",
|
|
47
|
+
"principal_context",
|
|
48
|
+
"require_principal",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
P = ParamSpec("P")
|
|
52
|
+
T = TypeVar("T")
|
|
53
|
+
V = TypeVar("V")
|
|
54
|
+
|
|
55
|
+
_logger: Final = logging.getLogger("sentrykit.context")
|
|
56
|
+
|
|
57
|
+
Channel = Literal["http", "agent", "internal"]
|
|
58
|
+
"""Where the invocation came from. Policies can condition on it via ``context.channel``."""
|
|
59
|
+
|
|
60
|
+
WILDCARD_ACTION: str = "*"
|
|
61
|
+
"""Key under which a :class:`Principal` may carry constraints that apply to every action."""
|
|
62
|
+
|
|
63
|
+
_DEFAULT_CHANNEL: Final = "internal"
|
|
64
|
+
"""Channel assumed when a provider established none — a plain in-process call."""
|
|
65
|
+
|
|
66
|
+
_EMPTY_ENVIRONMENT: Final[Mapping[str, object]] = MappingProxyType({})
|
|
67
|
+
"""The environment seen before anything is established.
|
|
68
|
+
|
|
69
|
+
A read-only proxy rather than a plain ``dict``, so a caller that pokes at the ambient
|
|
70
|
+
environment cannot mutate the default that every other context starts from.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
_principal_var: ContextVar[Principal | None] = ContextVar("sentrykit_principal", default=None)
|
|
74
|
+
_environment_var: ContextVar[Mapping[str, object]] = ContextVar(
|
|
75
|
+
"sentrykit_environment", default=_EMPTY_ENVIRONMENT
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _restore(variable: ContextVar[V], token: Token[V], empty: V) -> None:
|
|
80
|
+
"""Reset one context variable, clearing it outright if its token belongs elsewhere.
|
|
81
|
+
|
|
82
|
+
A scope reset from a different ``Context`` than the one that established it — a provider
|
|
83
|
+
whose teardown hook runs on another task — cannot use its token. Leaving the
|
|
84
|
+
establishment in place would be failing *open*, so the variable is cleared instead and
|
|
85
|
+
the anomaly is logged.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
variable: The context variable to restore.
|
|
89
|
+
token: The token captured when the variable was set.
|
|
90
|
+
empty: The value meaning "nothing established", used when the token is unusable.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
variable.reset(token)
|
|
94
|
+
except ValueError:
|
|
95
|
+
variable.set(empty)
|
|
96
|
+
_logger.warning(
|
|
97
|
+
"Context scope for %s was reset outside its originating context; cleared instead",
|
|
98
|
+
variable.name,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Principal(BaseModel):
|
|
103
|
+
"""Who is acting, as sentrykit sees them.
|
|
104
|
+
|
|
105
|
+
A principal is a Cedar entity plus the argument constraints that have been granted to
|
|
106
|
+
them. Roles, teams and delegation edges are all just parent edges: Cedar computes the
|
|
107
|
+
transitive closure, which is how a multi-hop relationship — user in team, team on
|
|
108
|
+
project, project owns resource — gets resolved by policy rather than by a chain of
|
|
109
|
+
``if`` statements in the application.
|
|
110
|
+
|
|
111
|
+
Attributes:
|
|
112
|
+
id: The entity id.
|
|
113
|
+
type: The entity type. ``User`` covers the common case; an agent acting for a human
|
|
114
|
+
is still modelled as that human, with the delegation recorded in
|
|
115
|
+
:attr:`attributes`.
|
|
116
|
+
roles: Convenience for the most common parent edge. Each entry ``r`` becomes a
|
|
117
|
+
parent ``Role::"r"``.
|
|
118
|
+
parents: Any other direct parent edges — teams, projects, delegation targets.
|
|
119
|
+
attributes: Cedar attributes for this principal, snapshotted at decision time.
|
|
120
|
+
Values are coerced for Cedar, so ``None`` entries are dropped.
|
|
121
|
+
constraints: Argument constraints keyed by action. The key may be a full uid
|
|
122
|
+
(``Action::"tool.search"``), a bare action id (``tool.search``), or ``"*"`` for
|
|
123
|
+
constraints that apply to every action. In a wired application these are
|
|
124
|
+
materialized from the grant store rather than constructed by hand.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
128
|
+
|
|
129
|
+
id: str
|
|
130
|
+
type: str = "User"
|
|
131
|
+
roles: tuple[str, ...] = ()
|
|
132
|
+
parents: tuple[EntityRef, ...] = ()
|
|
133
|
+
attributes: dict[str, object] = Field(default_factory=dict)
|
|
134
|
+
constraints: dict[str, ConstraintSet] = Field(default_factory=dict)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def uid(self) -> EntityRef:
|
|
138
|
+
"""This principal as a Cedar entity reference."""
|
|
139
|
+
return EntityRef(self.type, self.id)
|
|
140
|
+
|
|
141
|
+
def all_parents(self) -> tuple[EntityRef, ...]:
|
|
142
|
+
"""Every parent edge — :attr:`roles` expanded to ``Role::"..."`` plus :attr:`parents`."""
|
|
143
|
+
# A dict keyed by the reference deduplicates while preserving first-seen order, so a
|
|
144
|
+
# role also listed explicitly in ``parents`` produces one edge rather than two.
|
|
145
|
+
edges: dict[EntityRef, None] = {}
|
|
146
|
+
for role in self.roles:
|
|
147
|
+
edges.setdefault(EntityRef("Role", role), None)
|
|
148
|
+
for parent in self.parents:
|
|
149
|
+
edges.setdefault(parent, None)
|
|
150
|
+
return tuple(edges)
|
|
151
|
+
|
|
152
|
+
def constraints_for(self, action: EntityRef) -> ConstraintSet:
|
|
153
|
+
"""Resolve the argument constraints that apply to ``action``.
|
|
154
|
+
|
|
155
|
+
Lookup runs from least to most specific: the ``"*"`` wildcard set first, then the
|
|
156
|
+
bare action id, then the full uid — each layered over the previous, so a
|
|
157
|
+
wildcard baseline can be refined per action.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
action: The action being authorized.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
The merged constraint set, empty when nothing applies.
|
|
164
|
+
"""
|
|
165
|
+
resolved = ConstraintSet()
|
|
166
|
+
if not self.constraints:
|
|
167
|
+
return resolved
|
|
168
|
+
for key in (WILDCARD_ACTION, action.id, str(action)):
|
|
169
|
+
layer = self.constraints.get(key)
|
|
170
|
+
if layer is not None:
|
|
171
|
+
resolved = resolved.merged_with(layer)
|
|
172
|
+
return resolved
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class Invocation(BaseModel):
|
|
176
|
+
"""One normalized call, as the core sees it — independent of how it arrived.
|
|
177
|
+
|
|
178
|
+
This is the object architecture §1 describes: the decorator supplies the action and the
|
|
179
|
+
proposed arguments, a context provider supplies the principal and the environment, and
|
|
180
|
+
nothing downstream can tell whether it came from an HTTP route, an MCP tool dispatch, or
|
|
181
|
+
a plain function call.
|
|
182
|
+
|
|
183
|
+
Attributes:
|
|
184
|
+
id: Correlation id, shared by the Cedar result, the operational logs and the audit
|
|
185
|
+
record.
|
|
186
|
+
principal: Who is acting.
|
|
187
|
+
action: What they propose to do.
|
|
188
|
+
resource: What they propose to do it to.
|
|
189
|
+
proposed: The arguments as supplied — untrusted input, before any filtering.
|
|
190
|
+
environment: Cedar request context: ``now``, ``channel``, request id, auth strength,
|
|
191
|
+
delegation chain, break-the-glass flag.
|
|
192
|
+
callable_name: Qualified name of the protected callable, for diagnostics.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
model_config = ConfigDict(frozen=True)
|
|
196
|
+
|
|
197
|
+
id: str
|
|
198
|
+
principal: Principal
|
|
199
|
+
action: EntityRef
|
|
200
|
+
resource: EntityRef
|
|
201
|
+
proposed: dict[str, object] = Field(default_factory=dict)
|
|
202
|
+
environment: dict[str, object] = Field(default_factory=dict)
|
|
203
|
+
callable_name: str = ""
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def channel(self) -> str:
|
|
207
|
+
"""The channel from :attr:`environment`, defaulting to ``internal``."""
|
|
208
|
+
return str(self.environment.get("channel", _DEFAULT_CHANNEL))
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def now(self) -> datetime | None:
|
|
212
|
+
"""The decision timestamp from :attr:`environment`, if one was established."""
|
|
213
|
+
established = self.environment.get("now")
|
|
214
|
+
return established if isinstance(established, datetime) else None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class ContextScope:
|
|
218
|
+
"""The handle :func:`establish` returns: undo the establishment when the boundary ends.
|
|
219
|
+
|
|
220
|
+
Usable two ways, because the two boundaries need different shapes. A framework hook that
|
|
221
|
+
has no enclosing block — Flask's ``before_request``, an MCP dispatch callback — keeps the
|
|
222
|
+
handle and calls :meth:`reset`. Everything else uses it as a context manager, which
|
|
223
|
+
resets even when the body raises.
|
|
224
|
+
|
|
225
|
+
Example:
|
|
226
|
+
```python
|
|
227
|
+
with establish(principal, channel="agent"):
|
|
228
|
+
await run_tool()
|
|
229
|
+
```
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
__slots__ = ("_environment_token", "_principal_token", "_reset")
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self,
|
|
236
|
+
principal_token: Token[Principal | None],
|
|
237
|
+
environment_token: Token[Mapping[str, object]],
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Capture the tokens needed to undo one establishment.
|
|
240
|
+
|
|
241
|
+
Constructed by :func:`establish`; there is no reason for application code to build
|
|
242
|
+
one directly.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
principal_token: Token returned when the principal was set.
|
|
246
|
+
environment_token: Token returned when the environment was set.
|
|
247
|
+
"""
|
|
248
|
+
self._principal_token = principal_token
|
|
249
|
+
self._environment_token = environment_token
|
|
250
|
+
self._reset = False
|
|
251
|
+
|
|
252
|
+
def reset(self) -> None:
|
|
253
|
+
"""Restore the principal and environment that were ambient before establishment.
|
|
254
|
+
|
|
255
|
+
Idempotent: resetting an already-reset scope does nothing.
|
|
256
|
+
"""
|
|
257
|
+
if self._reset:
|
|
258
|
+
return
|
|
259
|
+
# Mark first: a failed restore must not leave the scope re-resettable, since the
|
|
260
|
+
# fallback below has already put the context somewhere safe.
|
|
261
|
+
self._reset = True
|
|
262
|
+
_restore(_principal_var, self._principal_token, None)
|
|
263
|
+
_restore(_environment_var, self._environment_token, _EMPTY_ENVIRONMENT)
|
|
264
|
+
|
|
265
|
+
def __enter__(self) -> ContextScope:
|
|
266
|
+
"""Return this scope, for use as a context manager."""
|
|
267
|
+
return self
|
|
268
|
+
|
|
269
|
+
def __exit__(
|
|
270
|
+
self,
|
|
271
|
+
exc_type: type[BaseException] | None,
|
|
272
|
+
exc: BaseException | None,
|
|
273
|
+
tb: TracebackType | None,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Reset the scope, whether or not the block raised."""
|
|
276
|
+
self.reset()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def establish(principal: Principal, **environment: object) -> ContextScope:
|
|
280
|
+
"""Make ``principal`` the ambient principal for this context.
|
|
281
|
+
|
|
282
|
+
Called by a context provider at the framework boundary. The environment keys become the
|
|
283
|
+
Cedar request context, so policies can condition on them directly.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
principal: Who the subsequent protected calls are acting as.
|
|
287
|
+
**environment: Request environment — ``channel``, ``request_id``, ``auth_strength``,
|
|
288
|
+
``break_glass``, a delegation chain, and anything else the policy pack expects.
|
|
289
|
+
``now`` is filled in by the engine at decision time unless supplied here.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
A scope handle. Use it as a context manager, or call :meth:`ContextScope.reset` when
|
|
293
|
+
the boundary has no enclosing block.
|
|
294
|
+
"""
|
|
295
|
+
principal_token = _principal_var.set(principal)
|
|
296
|
+
# Snapshot behind a read-only proxy: the environment a decision was made against should
|
|
297
|
+
# not be able to change under the decision.
|
|
298
|
+
environment_token = _environment_var.set(MappingProxyType(dict(environment)))
|
|
299
|
+
return ContextScope(principal_token, environment_token)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@contextmanager
|
|
303
|
+
def principal_context(principal: Principal, **environment: object) -> Generator[None, None, None]:
|
|
304
|
+
"""Establish ``principal`` for the duration of a ``with`` block.
|
|
305
|
+
|
|
306
|
+
The generator-based twin of :func:`establish`, for callers who prefer an explicit
|
|
307
|
+
context manager over a handle that happens to be one.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
principal: Who the block is acting as.
|
|
311
|
+
**environment: Request environment, as for :func:`establish`.
|
|
312
|
+
|
|
313
|
+
Yields:
|
|
314
|
+
``None``, with the context established.
|
|
315
|
+
"""
|
|
316
|
+
with establish(principal, **environment):
|
|
317
|
+
yield
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def current_principal() -> Principal | None:
|
|
321
|
+
"""Return the ambient principal, or ``None`` if none has been established."""
|
|
322
|
+
return _principal_var.get()
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def require_principal(action: str) -> Principal:
|
|
326
|
+
"""Return the ambient principal, or refuse the call.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
action: The action being attempted, for the error message.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
The ambient principal.
|
|
333
|
+
|
|
334
|
+
Raises:
|
|
335
|
+
NoContext: If no principal is ambient. This is the fail-closed rule, and it holds
|
|
336
|
+
regardless of ``fail_mode``: an engine that cannot tell who is calling
|
|
337
|
+
has nothing to authorize.
|
|
338
|
+
"""
|
|
339
|
+
principal = _principal_var.get()
|
|
340
|
+
if principal is None:
|
|
341
|
+
# A missing principal is normal operation for an unauthenticated caller, not an
|
|
342
|
+
# operator-actionable error — hence INFO, per the logging standards.
|
|
343
|
+
_logger.info("No principal in context for action %s; denying", action)
|
|
344
|
+
raise NoContext(action)
|
|
345
|
+
return principal
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def current_environment() -> Mapping[str, object]:
|
|
349
|
+
"""Return the ambient request environment, empty when nothing was established."""
|
|
350
|
+
return _environment_var.get()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def clear_context() -> None:
|
|
354
|
+
"""Drop the ambient principal, the environment, and the last decision envelope.
|
|
355
|
+
|
|
356
|
+
For tests and for long-lived workers that reuse a context between units of work. The
|
|
357
|
+
envelope goes too: a worker that has finished one unit of work should not be able to
|
|
358
|
+
report the previous one's decision as if it were its own.
|
|
359
|
+
"""
|
|
360
|
+
_principal_var.set(None)
|
|
361
|
+
_environment_var.set(_EMPTY_ENVIRONMENT)
|
|
362
|
+
set_last_envelope(None)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def bind_context(fn: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs) -> Callable[[], T]:
|
|
366
|
+
"""Bind a call to a copy of the current context, for offload to a thread pool.
|
|
367
|
+
|
|
368
|
+
``ContextVar`` values follow ``await`` and ``asyncio.to_thread`` on their own, so most
|
|
369
|
+
code needs nothing. A bare ``ThreadPoolExecutor.submit`` or ``loop.run_in_executor`` is
|
|
370
|
+
the exception: the worker thread starts from an empty context, the ambient principal is
|
|
371
|
+
gone, and every protected call inside it denies. Wrapping the call here carries the
|
|
372
|
+
context across.
|
|
373
|
+
|
|
374
|
+
Example:
|
|
375
|
+
```python
|
|
376
|
+
executor.submit(bind_context(reconcile_orders, batch_id))
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
fn: The callable to run in the other thread.
|
|
381
|
+
*args: Positional arguments for ``fn``.
|
|
382
|
+
**kwargs: Keyword arguments for ``fn``.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
A zero-argument thunk that runs ``fn`` inside a copy of the calling context.
|
|
386
|
+
"""
|
|
387
|
+
context = copy_context()
|
|
388
|
+
|
|
389
|
+
def run_bound() -> T:
|
|
390
|
+
"""Run the bound call inside the copied context."""
|
|
391
|
+
return context.run(fn, *args, **kwargs)
|
|
392
|
+
|
|
393
|
+
return run_bound
|
sentrykit/decorator.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""``@protect`` — one decorator, and N context providers behind it.
|
|
2
|
+
|
|
3
|
+
Everything a framework contributes is a context provider. Everything a developer writes is
|
|
4
|
+
this decorator. A Flask view, a background reconciliation job and an MCP tool are all
|
|
5
|
+
guarded by the same three lines of code, because the decorator only supplies the *action*
|
|
6
|
+
and the *proposed arguments*; who is acting comes from the ambient context.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
@protect(action='Action::"tool.search_orders"', resource='Tool::"orders"')
|
|
10
|
+
async def search_orders(customer_id: str, limit: int = 10) -> list[Order]: ...
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The wrapper is signature-preserving, so type checkers, IDEs, and — importantly — FastAPI's
|
|
14
|
+
and Pydantic-AI's signature introspection see the original function.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import functools
|
|
20
|
+
from collections.abc import Awaitable, Callable
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Any, Final, ParamSpec, TypeVar, cast, overload
|
|
23
|
+
|
|
24
|
+
from sentrykit.cedar import EntityRef
|
|
25
|
+
from sentrykit.context import Principal, current_principal
|
|
26
|
+
from sentrykit.engine import ArgumentSpec, Governor, derive_action, get_governor
|
|
27
|
+
from sentrykit.errors import ConfigurationError, NoContext
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AS_KEYWORD",
|
|
31
|
+
"ProtectedMeta",
|
|
32
|
+
"is_protected",
|
|
33
|
+
"protect",
|
|
34
|
+
"protected_meta",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
P = ParamSpec("P")
|
|
38
|
+
T = TypeVar("T")
|
|
39
|
+
|
|
40
|
+
_P = ParamSpec("_P")
|
|
41
|
+
_T = TypeVar("_T")
|
|
42
|
+
|
|
43
|
+
AS_KEYWORD: str = "_as"
|
|
44
|
+
"""Call keyword that overrides the ambient principal for a single invocation."""
|
|
45
|
+
|
|
46
|
+
_META_ATTR: Final = "__sentrykit__"
|
|
47
|
+
"""Attribute under which :class:`ProtectedMeta` is stashed on the wrapper."""
|
|
48
|
+
|
|
49
|
+
_ABSENT: Final = object()
|
|
50
|
+
"""Sentinel distinguishing ``_as=None`` from no ``_as`` at all."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class ProtectedMeta:
|
|
55
|
+
"""What ``@protect`` records on the callable it wraps.
|
|
56
|
+
|
|
57
|
+
Attached as ``__sentrykit__`` so that tooling can introspect a protected callable without
|
|
58
|
+
calling it — which is exactly what the agent-side ``list_tools`` filtering does to decide
|
|
59
|
+
whether a principal should be shown a tool at all.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
action: The action this callable is authorized as.
|
|
63
|
+
resource: The declared resource, or ``None`` to let the governor synthesize one.
|
|
64
|
+
spec: The cached argument spec.
|
|
65
|
+
governor: A governor pinned to this callable, or ``None`` to use the process default
|
|
66
|
+
at call time.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
action: EntityRef
|
|
70
|
+
resource: EntityRef | None
|
|
71
|
+
spec: ArgumentSpec
|
|
72
|
+
governor: Governor | None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@overload
|
|
76
|
+
def protect(fn: Callable[P, T], /) -> Callable[P, T]: ...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@overload
|
|
80
|
+
def protect(
|
|
81
|
+
*,
|
|
82
|
+
action: str | EntityRef | None = None,
|
|
83
|
+
resource: str | EntityRef | None = None,
|
|
84
|
+
governor: Governor | None = None,
|
|
85
|
+
) -> Callable[[Callable[P, T]], Callable[P, T]]: ...
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def protect(
|
|
89
|
+
fn: Callable[P, T] | None = None,
|
|
90
|
+
/,
|
|
91
|
+
*,
|
|
92
|
+
action: str | EntityRef | None = None,
|
|
93
|
+
resource: str | EntityRef | None = None,
|
|
94
|
+
governor: Governor | None = None,
|
|
95
|
+
) -> Callable[P, T] | Callable[[Callable[P, T]], Callable[P, T]]:
|
|
96
|
+
"""Guard a callable with pre-action authorization.
|
|
97
|
+
|
|
98
|
+
On every call the wrapper resolves the principal, binds the proposed arguments to
|
|
99
|
+
parameter names, and hands both to the governor, which gates, filters, defaults,
|
|
100
|
+
validates and only then invokes. Sync and async callables are both supported; the
|
|
101
|
+
wrapper matches whichever it wraps.
|
|
102
|
+
|
|
103
|
+
Usable bare (``@protect``) or with arguments (``@protect(action=...)``).
|
|
104
|
+
|
|
105
|
+
The principal comes from the ambient context established by a provider. It can be
|
|
106
|
+
overridden for one call by passing ``_as=<Principal>``, which the wrapper strips before
|
|
107
|
+
binding — useful for a scheduled job that acts for a specific user, and for tests.
|
|
108
|
+
Because the wrapper preserves the wrapped signature, a strictly type-checked caller
|
|
109
|
+
passing ``_as=`` needs a ``type: ignore[call-arg]``; prefer
|
|
110
|
+
:func:`~sentrykit.context.establish` where that matters.
|
|
111
|
+
|
|
112
|
+
**No ambient principal and no ``_as=`` means the call is denied**, not run unguarded.
|
|
113
|
+
That is the fail-closed rule, and it is why forgetting to wire a provider surfaces
|
|
114
|
+
immediately rather than as a silent hole.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
fn: The callable, when used bare as ``@protect``.
|
|
118
|
+
action: The Cedar action. Accepts a full uid (``Action::"tool.search"``) or a bare
|
|
119
|
+
id, which is read as an ``Action``. Defaults to
|
|
120
|
+
``Action::"<module>.<qualname>"``.
|
|
121
|
+
resource: The Cedar resource. Accepts a full uid or a bare id read as a
|
|
122
|
+
``Resource``. Defaults to a resource derived from the action, so that a callable
|
|
123
|
+
with no natural resource still fits Cedar's principal/action/resource model.
|
|
124
|
+
governor: Pin a governor to this callable. Defaults to the process-installed one,
|
|
125
|
+
resolved at call time so wiring order does not matter.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The guarded callable, with the original signature and an attached
|
|
129
|
+
:class:`ProtectedMeta`.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ConfigurationError: If ``action`` or ``resource`` cannot be parsed, or the callable's
|
|
133
|
+
signature cannot be introspected.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def decorate(target: Callable[_P, _T]) -> Callable[_P, _T]:
|
|
137
|
+
spec = ArgumentSpec.from_callable(target)
|
|
138
|
+
meta = ProtectedMeta(
|
|
139
|
+
action=(
|
|
140
|
+
EntityRef.parse(action, default_type="Action")
|
|
141
|
+
if action is not None
|
|
142
|
+
else derive_action(target)
|
|
143
|
+
),
|
|
144
|
+
resource=(
|
|
145
|
+
EntityRef.parse(resource, default_type="Resource") if resource is not None else None
|
|
146
|
+
),
|
|
147
|
+
spec=spec,
|
|
148
|
+
governor=governor,
|
|
149
|
+
)
|
|
150
|
+
if spec.is_async:
|
|
151
|
+
asynchronous = _wrap_async(cast("Callable[_P, Awaitable[Any]]", target), meta)
|
|
152
|
+
wrapped = cast("Callable[_P, _T]", asynchronous)
|
|
153
|
+
else:
|
|
154
|
+
wrapped = _wrap_sync(target, meta)
|
|
155
|
+
setattr(wrapped, _META_ATTR, meta)
|
|
156
|
+
return wrapped
|
|
157
|
+
|
|
158
|
+
if fn is not None:
|
|
159
|
+
return decorate(fn)
|
|
160
|
+
return decorate
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def protected_meta(fn: object) -> ProtectedMeta | None:
|
|
164
|
+
"""Return the metadata ``@protect`` attached to ``fn``, if it is protected.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
fn: Any object, typically a callable.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
The metadata, or ``None`` when ``fn`` is not protected.
|
|
171
|
+
"""
|
|
172
|
+
meta = getattr(fn, _META_ATTR, None)
|
|
173
|
+
return meta if isinstance(meta, ProtectedMeta) else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def is_protected(fn: object) -> bool:
|
|
177
|
+
"""Whether ``fn`` carries a ``@protect`` wrapper."""
|
|
178
|
+
return protected_meta(fn) is not None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _resolve_principal(meta: ProtectedMeta, override: object) -> Principal:
|
|
182
|
+
"""Decide who this call acts as.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
meta: The protected callable's metadata, for the error message.
|
|
186
|
+
override: Whatever came in as ``_as=``, or the absent sentinel.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The explicit override when one was passed, otherwise the ambient principal.
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
ConfigurationError: If ``_as=`` was passed something other than a
|
|
193
|
+
:class:`~sentrykit.context.Principal`.
|
|
194
|
+
NoContext: If nothing was passed and no principal is ambient. Fail-closed, and
|
|
195
|
+
deliberately independent of ``fail_mode``: an engine that cannot
|
|
196
|
+
tell who is calling has nothing to authorize.
|
|
197
|
+
"""
|
|
198
|
+
if override is not _ABSENT:
|
|
199
|
+
if not isinstance(override, Principal):
|
|
200
|
+
raise ConfigurationError(
|
|
201
|
+
f"{AS_KEYWORD}= must be a Principal, got {type(override).__name__}"
|
|
202
|
+
)
|
|
203
|
+
return override
|
|
204
|
+
principal = current_principal()
|
|
205
|
+
if principal is None:
|
|
206
|
+
raise NoContext(str(meta.action))
|
|
207
|
+
return principal
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _wrap_sync(
|
|
211
|
+
fn: Callable[P, T],
|
|
212
|
+
meta: ProtectedMeta,
|
|
213
|
+
) -> Callable[P, T]:
|
|
214
|
+
"""Build the synchronous wrapper for ``fn``."""
|
|
215
|
+
|
|
216
|
+
@functools.wraps(fn)
|
|
217
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
218
|
+
# Stripped before binding: ``_as`` is sentrykit's keyword, not the callable's.
|
|
219
|
+
principal = _resolve_principal(meta, kwargs.pop(AS_KEYWORD, _ABSENT))
|
|
220
|
+
active = meta.governor or get_governor()
|
|
221
|
+
plan = active.plan(
|
|
222
|
+
meta.spec,
|
|
223
|
+
principal=principal,
|
|
224
|
+
action=meta.action,
|
|
225
|
+
resource=meta.resource,
|
|
226
|
+
proposed=meta.spec.bind(args, kwargs),
|
|
227
|
+
)
|
|
228
|
+
return active.invoke(fn, plan)
|
|
229
|
+
|
|
230
|
+
return wrapper
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _wrap_async(
|
|
234
|
+
fn: Callable[P, Awaitable[T]],
|
|
235
|
+
meta: ProtectedMeta,
|
|
236
|
+
) -> Callable[P, Awaitable[T]]:
|
|
237
|
+
"""Build the asynchronous wrapper for ``fn``."""
|
|
238
|
+
|
|
239
|
+
@functools.wraps(fn)
|
|
240
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
241
|
+
principal = _resolve_principal(meta, kwargs.pop(AS_KEYWORD, _ABSENT))
|
|
242
|
+
active = meta.governor or get_governor()
|
|
243
|
+
plan = active.plan(
|
|
244
|
+
meta.spec,
|
|
245
|
+
principal=principal,
|
|
246
|
+
action=meta.action,
|
|
247
|
+
resource=meta.resource,
|
|
248
|
+
proposed=meta.spec.bind(args, kwargs),
|
|
249
|
+
)
|
|
250
|
+
return await active.ainvoke(fn, plan)
|
|
251
|
+
|
|
252
|
+
return wrapper
|