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/config.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Runtime settings for sentrykit.
|
|
2
|
+
|
|
3
|
+
Settings are read from the environment with the ``SENTRYKIT_`` prefix (or set explicitly by
|
|
4
|
+
the host application through :func:`configure`) and resolved per protected action, so a
|
|
5
|
+
single noisy or strict action can deviate from the process-wide default without a second
|
|
6
|
+
configuration mechanism.
|
|
7
|
+
|
|
8
|
+
The knobs and their meaning:
|
|
9
|
+
|
|
10
|
+
``verbosity``
|
|
11
|
+
How much of the ``_authz`` envelope is rendered — ``off`` emits none at all, ``summary``
|
|
12
|
+
names what happened, ``verbose`` adds the argument values, ``debug`` adds the decision
|
|
13
|
+
trace.
|
|
14
|
+
``arg_handling``
|
|
15
|
+
``lenient`` silently drops arguments the policy rejects; ``strict`` raises
|
|
16
|
+
:class:`~sentrykit.errors.ArgumentRejected` on the first violation.
|
|
17
|
+
``fail_mode``
|
|
18
|
+
What to do when the *engine* fails (unparseable policies, a Cedar evaluation error, an
|
|
19
|
+
unavailable audit sink) — ``closed`` refuses the call, ``open`` proceeds
|
|
20
|
+
and records the failure. A policy *denial* is not an engine failure: it denies under both
|
|
21
|
+
modes, as does a missing principal.
|
|
22
|
+
``redaction``
|
|
23
|
+
Whether sensitive argument values are masked before they leave the engine. The flag is
|
|
24
|
+
carried through the envelope; the strategies themselves live in
|
|
25
|
+
:mod:`sentrykit.masking`.
|
|
26
|
+
``envelope_on_success``
|
|
27
|
+
Whether a successful invocation gets an envelope attached at all. Denials always carry
|
|
28
|
+
their detail on the raised error, regardless.
|
|
29
|
+
``audit_sink``
|
|
30
|
+
DSN of the sink decisions are written to — ``jsonl://./audit.jsonl``. Unset means no
|
|
31
|
+
audit trail is emitted at all, which is the right default for a library that must not
|
|
32
|
+
start writing files because it was imported.
|
|
33
|
+
``hmac_key``
|
|
34
|
+
Key for the ``hmac`` masking strategy. Held as a ``SecretStr`` so it cannot be printed
|
|
35
|
+
into a debug trace or a repr by accident.
|
|
36
|
+
``mask_default``
|
|
37
|
+
Which strategy an unqualified ``mask:`` obligation or a bare ``Sensitive()`` marker
|
|
38
|
+
resolves to. ``hmac`` by default, because it is the only strategy that keeps records
|
|
39
|
+
correlatable without keeping the value.
|
|
40
|
+
|
|
41
|
+
Note that ``redaction`` governs the caller-facing envelope and the operational logs only. It
|
|
42
|
+
does **not** reach the audit path: masking before an immutable write is not configurable,
|
|
43
|
+
because that write is the one that cannot be taken back.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import logging
|
|
49
|
+
import re
|
|
50
|
+
from typing import Final, Literal, TypedDict
|
|
51
|
+
|
|
52
|
+
from pydantic import SecretStr, ValidationError
|
|
53
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
54
|
+
from typing_extensions import Unpack
|
|
55
|
+
|
|
56
|
+
from sentrykit.errors import ConfigurationError
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"ENV_PREFIX",
|
|
60
|
+
"ArgHandling",
|
|
61
|
+
"FailMode",
|
|
62
|
+
"MaskDefault",
|
|
63
|
+
"Settings",
|
|
64
|
+
"SettingsOverrides",
|
|
65
|
+
"Verbosity",
|
|
66
|
+
"apply_overrides",
|
|
67
|
+
"clear_action_overrides",
|
|
68
|
+
"configure",
|
|
69
|
+
"get_settings",
|
|
70
|
+
"override_action",
|
|
71
|
+
"reset_settings",
|
|
72
|
+
"settings_for",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
_logger: Final = logging.getLogger("sentrykit.config")
|
|
76
|
+
|
|
77
|
+
Verbosity = Literal["off", "summary", "verbose", "debug"]
|
|
78
|
+
"""How much of the ``_authz`` envelope to render."""
|
|
79
|
+
|
|
80
|
+
ArgHandling = Literal["lenient", "strict"]
|
|
81
|
+
"""Whether a policy-rejected argument is dropped (``lenient``) or fatal (``strict``)."""
|
|
82
|
+
|
|
83
|
+
FailMode = Literal["closed", "open"]
|
|
84
|
+
"""Whether an engine failure refuses the call (``closed``) or lets it through (``open``)."""
|
|
85
|
+
|
|
86
|
+
MaskDefault = Literal["redact", "partial", "hmac", "tokenize"]
|
|
87
|
+
"""Default masking strategy.
|
|
88
|
+
|
|
89
|
+
Spelled out here rather than imported from :mod:`sentrykit.masking` so that configuration
|
|
90
|
+
stays at the bottom of the dependency graph — masking imports config, not the other way
|
|
91
|
+
round. The two are kept in step by a unit test.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
ENV_PREFIX: Final = "SENTRYKIT_"
|
|
95
|
+
"""Environment-variable prefix, e.g. ``SENTRYKIT_VERBOSITY=debug``."""
|
|
96
|
+
|
|
97
|
+
_ACTION_UID_RE: Final = re.compile(
|
|
98
|
+
r'^[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)*::"(?P<id>.*)"$'
|
|
99
|
+
)
|
|
100
|
+
"""Matches a Cedar uid so an override key can be reduced to its bare action id.
|
|
101
|
+
|
|
102
|
+
Deliberately local: config resolves the two spellings of an action name and nothing more, so
|
|
103
|
+
it has no reason to know about :mod:`sentrykit.cedar`.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SettingsOverrides(TypedDict, total=False):
|
|
108
|
+
"""Keyword overrides accepted by :func:`configure` and :func:`override_action`.
|
|
109
|
+
|
|
110
|
+
Declared as a ``TypedDict`` so the variadic keyword arguments of those two functions stay
|
|
111
|
+
fully typed instead of collapsing to ``Any``.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
verbosity: Verbosity
|
|
115
|
+
arg_handling: ArgHandling
|
|
116
|
+
fail_mode: FailMode
|
|
117
|
+
redaction: bool
|
|
118
|
+
envelope_on_success: bool
|
|
119
|
+
audit_sink: str | None
|
|
120
|
+
hmac_key: SecretStr | None
|
|
121
|
+
mask_default: MaskDefault
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Settings(BaseSettings):
|
|
125
|
+
"""Process-wide sentrykit configuration.
|
|
126
|
+
|
|
127
|
+
Frozen: an effective per-action configuration is produced by copying, never by mutating
|
|
128
|
+
the global, so a per-action override cannot leak into unrelated calls.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
model_config = SettingsConfigDict(env_prefix=ENV_PREFIX, frozen=True, extra="ignore")
|
|
132
|
+
|
|
133
|
+
verbosity: Verbosity = "summary"
|
|
134
|
+
arg_handling: ArgHandling = "lenient"
|
|
135
|
+
fail_mode: FailMode = "closed"
|
|
136
|
+
redaction: bool = True
|
|
137
|
+
envelope_on_success: bool = True
|
|
138
|
+
audit_sink: str | None = None
|
|
139
|
+
hmac_key: SecretStr | None = None
|
|
140
|
+
mask_default: MaskDefault = "hmac"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
_settings: Settings | None = None
|
|
144
|
+
"""The cached process-wide settings. Loaded lazily, so importing sentrykit reads no env."""
|
|
145
|
+
|
|
146
|
+
_action_overrides: dict[str, SettingsOverrides] = {}
|
|
147
|
+
"""Per-action overrides, keyed by the normalized (bare id) spelling of the action."""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _normalize_action(action: str) -> str:
|
|
151
|
+
"""Reduce an action name to the one spelling used as an override key.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
action: A full Cedar uid (``Action::"tool.search"``) or a bare id (``tool.search``).
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The bare action id, so both spellings address the same override.
|
|
158
|
+
"""
|
|
159
|
+
candidate = action.strip()
|
|
160
|
+
match = _ACTION_UID_RE.match(candidate)
|
|
161
|
+
return match.group("id") if match else candidate
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _describe(exc: ValidationError) -> str:
|
|
165
|
+
"""Render a pydantic validation failure as a message naming the offending fields.
|
|
166
|
+
|
|
167
|
+
A bad ``SENTRYKIT_FAIL_MODE`` should read as a configuration mistake, not as a pydantic
|
|
168
|
+
traceback the operator has to decode.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
exc: The failure raised while building :class:`Settings`.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
A single-line, sentence-case description of every rejected field.
|
|
175
|
+
"""
|
|
176
|
+
problems = []
|
|
177
|
+
for error in exc.errors():
|
|
178
|
+
location = ".".join(str(part) for part in error["loc"]) or "<settings>"
|
|
179
|
+
problems.append(f"{location}: {error['msg']}")
|
|
180
|
+
return f"Invalid sentrykit settings (env prefix {ENV_PREFIX}): {'; '.join(problems)}"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _build(overrides: SettingsOverrides) -> Settings:
|
|
184
|
+
"""Construct :class:`Settings` from the environment plus ``overrides``.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
overrides: Fields to set explicitly; the environment supplies the rest.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
The validated settings.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
ConfigurationError: If any resolved value is invalid.
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
return Settings(**overrides)
|
|
197
|
+
except ValidationError as exc:
|
|
198
|
+
raise ConfigurationError(_describe(exc)) from exc
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def get_settings() -> Settings:
|
|
202
|
+
"""Return the process-wide settings, loading them from the environment on first use.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
The cached global :class:`Settings`.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
ConfigurationError: If an environment variable holds a value the model rejects.
|
|
209
|
+
"""
|
|
210
|
+
global _settings
|
|
211
|
+
if _settings is None:
|
|
212
|
+
_settings = _build({})
|
|
213
|
+
_logger.debug("Loaded settings from environment with prefix %s", ENV_PREFIX)
|
|
214
|
+
return _settings
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def configure(**overrides: Unpack[SettingsOverrides]) -> Settings:
|
|
218
|
+
"""Replace the process-wide settings with environment values plus ``overrides``.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
**overrides: Fields to override on top of what the environment supplies.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
The newly installed global :class:`Settings`.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
ConfigurationError: If the resulting combination is invalid.
|
|
228
|
+
"""
|
|
229
|
+
global _settings
|
|
230
|
+
_settings = _build(overrides)
|
|
231
|
+
_logger.debug("Configured settings explicitly; fields=%s", sorted(overrides))
|
|
232
|
+
return _settings
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def reset_settings() -> None:
|
|
236
|
+
"""Drop the cached global settings and every per-action override.
|
|
237
|
+
|
|
238
|
+
Intended for tests and for hosts that reload configuration at runtime; the next
|
|
239
|
+
:func:`get_settings` re-reads the environment.
|
|
240
|
+
"""
|
|
241
|
+
global _settings
|
|
242
|
+
_settings = None
|
|
243
|
+
_action_overrides.clear()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def override_action(action: str, **overrides: Unpack[SettingsOverrides]) -> None:
|
|
247
|
+
"""Register settings that apply only to ``action``.
|
|
248
|
+
|
|
249
|
+
Registering twice for the same action replaces the earlier override outright, so what
|
|
250
|
+
applies to an action stays answerable by reading a single call.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
action: The action this override applies to, either the bare id
|
|
254
|
+
(``"tool.search_orders"``) or the full Cedar uid (``Action::"tool.search_orders"``).
|
|
255
|
+
Both spellings resolve to the same override.
|
|
256
|
+
**overrides: Fields to override for that action only.
|
|
257
|
+
"""
|
|
258
|
+
key = _normalize_action(action)
|
|
259
|
+
_action_overrides[key] = overrides
|
|
260
|
+
_logger.debug("Registered settings override for action %s; fields=%s", key, sorted(overrides))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def clear_action_overrides() -> None:
|
|
264
|
+
"""Remove every per-action override, leaving the global settings in place."""
|
|
265
|
+
_action_overrides.clear()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def settings_for(action: str | None = None) -> Settings:
|
|
269
|
+
"""Resolve the effective settings for ``action``.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
action: The action being authorized, or ``None`` for the global settings.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
The global settings, with any override registered for ``action`` applied on top.
|
|
276
|
+
"""
|
|
277
|
+
return apply_overrides(get_settings(), action)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def apply_overrides(base: Settings, action: str | None) -> Settings:
|
|
281
|
+
"""Layer the override registered for ``action`` on top of ``base``.
|
|
282
|
+
|
|
283
|
+
Split out from :func:`settings_for` so a governor constructed with explicit settings
|
|
284
|
+
still honors per-action overrides -- otherwise configuring a governor would silently
|
|
285
|
+
disable a mechanism the host had already relied on.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
base: The settings to start from.
|
|
289
|
+
action: The action being authorized, in either spelling, or ``None``.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
``base`` unchanged when nothing is registered for ``action``, otherwise a copy with
|
|
293
|
+
the override applied.
|
|
294
|
+
"""
|
|
295
|
+
if action is None or not _action_overrides:
|
|
296
|
+
return base
|
|
297
|
+
override = _action_overrides.get(_normalize_action(action))
|
|
298
|
+
if not override:
|
|
299
|
+
return base
|
|
300
|
+
return base.model_copy(update=dict(override))
|