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