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/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