xtr-security-core 3.0.0__tar.gz

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 (82) hide show
  1. xtr_security_core-3.0.0/LICENSE +21 -0
  2. xtr_security_core-3.0.0/PKG-INFO +339 -0
  3. xtr_security_core-3.0.0/README.md +316 -0
  4. xtr_security_core-3.0.0/pyproject.toml +144 -0
  5. xtr_security_core-3.0.0/pyproject.toml.orig +109 -0
  6. xtr_security_core-3.0.0/src/xtr_security_core/__init__.py +146 -0
  7. xtr_security_core-3.0.0/src/xtr_security_core/authentication/__init__.py +11 -0
  8. xtr_security_core-3.0.0/src/xtr_security_core/authentication/authentication_trust_resolver.py +39 -0
  9. xtr_security_core-3.0.0/src/xtr_security_core/authentication/authentication_trust_resolver_interface.py +30 -0
  10. xtr_security_core-3.0.0/src/xtr_security_core/authentication/token/__init__.py +15 -0
  11. xtr_security_core-3.0.0/src/xtr_security_core/authentication/token/abstract_token.py +85 -0
  12. xtr_security_core-3.0.0/src/xtr_security_core/authentication/token/null_token.py +23 -0
  13. xtr_security_core-3.0.0/src/xtr_security_core/authentication/token/storage/__init__.py +8 -0
  14. xtr_security_core-3.0.0/src/xtr_security_core/authentication/token/storage/token_storage.py +50 -0
  15. xtr_security_core-3.0.0/src/xtr_security_core/authentication/token/storage/token_storage_interface.py +29 -0
  16. xtr_security_core-3.0.0/src/xtr_security_core/authentication/token/token_interface.py +56 -0
  17. xtr_security_core-3.0.0/src/xtr_security_core/authentication/token/username_password_token.py +42 -0
  18. xtr_security_core-3.0.0/src/xtr_security_core/authentication_events.py +30 -0
  19. xtr_security_core-3.0.0/src/xtr_security_core/authorization/__init__.py +55 -0
  20. xtr_security_core-3.0.0/src/xtr_security_core/authorization/access_decision.py +46 -0
  21. xtr_security_core-3.0.0/src/xtr_security_core/authorization/access_decision_manager.py +180 -0
  22. xtr_security_core-3.0.0/src/xtr_security_core/authorization/access_decision_manager_interface.py +40 -0
  23. xtr_security_core-3.0.0/src/xtr_security_core/authorization/authorization_checker.py +86 -0
  24. xtr_security_core-3.0.0/src/xtr_security_core/authorization/authorization_checker_interface.py +29 -0
  25. xtr_security_core-3.0.0/src/xtr_security_core/authorization/guest_authorization_checker_interface.py +33 -0
  26. xtr_security_core-3.0.0/src/xtr_security_core/authorization/is_granted_context.py +43 -0
  27. xtr_security_core-3.0.0/src/xtr_security_core/authorization/strategy/__init__.py +17 -0
  28. xtr_security_core-3.0.0/src/xtr_security_core/authorization/strategy/access_decision_strategy_interface.py +35 -0
  29. xtr_security_core-3.0.0/src/xtr_security_core/authorization/strategy/affirmative_strategy.py +54 -0
  30. xtr_security_core-3.0.0/src/xtr_security_core/authorization/strategy/consensus_strategy.py +61 -0
  31. xtr_security_core-3.0.0/src/xtr_security_core/authorization/strategy/priority_strategy.py +51 -0
  32. xtr_security_core-3.0.0/src/xtr_security_core/authorization/strategy/unanimous_strategy.py +54 -0
  33. xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/__init__.py +27 -0
  34. xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/access.py +21 -0
  35. xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/authenticated_voter.py +89 -0
  36. xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/cacheable_voter_interface.py +31 -0
  37. xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/closure_voter.py +91 -0
  38. xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/role_hierarchy_voter.py +39 -0
  39. xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/role_voter.py +70 -0
  40. xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/traceable_voter.py +87 -0
  41. xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/vote.py +35 -0
  42. xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/voter.py +80 -0
  43. xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/voter_interface.py +43 -0
  44. xtr_security_core-3.0.0/src/xtr_security_core/event/__init__.py +9 -0
  45. xtr_security_core-3.0.0/src/xtr_security_core/event/authentication_event.py +32 -0
  46. xtr_security_core-3.0.0/src/xtr_security_core/event/authentication_success_event.py +22 -0
  47. xtr_security_core-3.0.0/src/xtr_security_core/event/vote_event.py +41 -0
  48. xtr_security_core-3.0.0/src/xtr_security_core/exception/__init__.py +48 -0
  49. xtr_security_core-3.0.0/src/xtr_security_core/exception/access_denied_error.py +47 -0
  50. xtr_security_core-3.0.0/src/xtr_security_core/exception/account_expired_error.py +15 -0
  51. xtr_security_core-3.0.0/src/xtr_security_core/exception/account_status_error.py +41 -0
  52. xtr_security_core-3.0.0/src/xtr_security_core/exception/authentication_credentials_not_found_error.py +19 -0
  53. xtr_security_core-3.0.0/src/xtr_security_core/exception/authentication_error.py +61 -0
  54. xtr_security_core-3.0.0/src/xtr_security_core/exception/authentication_service_error.py +22 -0
  55. xtr_security_core-3.0.0/src/xtr_security_core/exception/bad_credentials_error.py +20 -0
  56. xtr_security_core-3.0.0/src/xtr_security_core/exception/credentials_expired_error.py +15 -0
  57. xtr_security_core-3.0.0/src/xtr_security_core/exception/custom_user_message_account_status_error.py +39 -0
  58. xtr_security_core-3.0.0/src/xtr_security_core/exception/custom_user_message_authentication_error.py +36 -0
  59. xtr_security_core-3.0.0/src/xtr_security_core/exception/disabled_error.py +15 -0
  60. xtr_security_core-3.0.0/src/xtr_security_core/exception/insufficient_authentication_error.py +19 -0
  61. xtr_security_core-3.0.0/src/xtr_security_core/exception/invalid_argument_error.py +18 -0
  62. xtr_security_core-3.0.0/src/xtr_security_core/exception/locked_error.py +15 -0
  63. xtr_security_core-3.0.0/src/xtr_security_core/exception/security_error.py +14 -0
  64. xtr_security_core-3.0.0/src/xtr_security_core/exception/unsupported_user_error.py +15 -0
  65. xtr_security_core-3.0.0/src/xtr_security_core/exception/user_not_found_error.py +44 -0
  66. xtr_security_core-3.0.0/src/xtr_security_core/py.typed +0 -0
  67. xtr_security_core-3.0.0/src/xtr_security_core/role/__init__.py +8 -0
  68. xtr_security_core-3.0.0/src/xtr_security_core/role/role_hierarchy.py +123 -0
  69. xtr_security_core-3.0.0/src/xtr_security_core/role/role_hierarchy_interface.py +28 -0
  70. xtr_security_core-3.0.0/src/xtr_security_core/user/__init__.py +43 -0
  71. xtr_security_core-3.0.0/src/xtr_security_core/user/attributes_based_user_provider_interface.py +41 -0
  72. xtr_security_core-3.0.0/src/xtr_security_core/user/chain_user_checker.py +57 -0
  73. xtr_security_core-3.0.0/src/xtr_security_core/user/chain_user_provider.py +110 -0
  74. xtr_security_core-3.0.0/src/xtr_security_core/user/equatable_interface.py +25 -0
  75. xtr_security_core-3.0.0/src/xtr_security_core/user/in_memory_user.py +87 -0
  76. xtr_security_core-3.0.0/src/xtr_security_core/user/in_memory_user_checker.py +48 -0
  77. xtr_security_core-3.0.0/src/xtr_security_core/user/in_memory_user_provider.py +96 -0
  78. xtr_security_core-3.0.0/src/xtr_security_core/user/oidc_user.py +66 -0
  79. xtr_security_core-3.0.0/src/xtr_security_core/user/password_upgrader_interface.py +33 -0
  80. xtr_security_core-3.0.0/src/xtr_security_core/user/user_checker_interface.py +43 -0
  81. xtr_security_core-3.0.0/src/xtr_security_core/user/user_interface.py +38 -0
  82. xtr_security_core-3.0.0/src/xtr_security_core/user/user_provider_interface.py +33 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xterr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,339 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtr-security-core
3
+ Version: 3.0.0
4
+ Summary: The security core for xtr applications: users, tokens, roles, voters and the authorization decision.
5
+ Keywords: security,authentication,authorization,users,voters
6
+ Author: Xterr
7
+ Author-email: Xterr <me@xterr.dev>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Typing :: Typed
17
+ Requires-Dist: xtr-password-hasher>=3.0,<4
18
+ Requires-Dist: xtr-event-dispatcher-contracts>=3.0,<4
19
+ Requires-Dist: xtr-service-contracts>=3.0,<4
20
+ Requires-Dist: typing-extensions>=4.12
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+
24
+ <div align="center">
25
+
26
+ # xtr-security-core
27
+
28
+ **The security core: users, tokens, roles, voters and the authorization decision.**
29
+
30
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
31
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
32
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
33
+
34
+ </div>
35
+
36
+ ---
37
+
38
+ ## Why?
39
+
40
+ Two questions sit under every protected feature: *who is this?* and *may they do this?* The
41
+ first settles on a **token** — a user and the roles fixed on them for this unit of work. The
42
+ second asks a set of **voters** and folds their answers into one yes or no with a **strategy**.
43
+ Keeping the two apart, and keeping the decision a small pluggable thing, is what lets a web edge,
44
+ a console command and a background job all reach the same answer the same way.
45
+
46
+ This package is that core, with no web framework and no container in sight:
47
+
48
+ - 🪪 **Users and providers** — a `UserInterface` is an identifier and a set of roles; a provider
49
+ loads one by identifier, from memory or from anywhere an application writes.
50
+ - 🎫 **Tokens and storage** — a token carries the authenticated user and their roles; a
51
+ per-unit-of-work storage holds the current one.
52
+ - 🗳️ **Voters, strategies and one decision** — each voter grants, denies or abstains on an
53
+ attribute; a strategy turns the votes into a decision, and the `AuthorizationChecker` is the
54
+ one call the rest of the application makes.
55
+ - 🪜 **A role hierarchy** — one role reaches others, wildcards included, so `ROLE_ADMIN` need
56
+ not list every role it implies.
57
+
58
+ The HTTP edge — firewalls, authenticators, bearer tokens — is a separate package,
59
+ [xtr-security-http](../xtr-security-http); the OAuth2 scope voter lives there, since scopes are a
60
+ request concern.
61
+
62
+ ## Install
63
+
64
+ ```sh
65
+ uv add xtr-security-core
66
+ ```
67
+
68
+ Requires Python 3.11+. One sibling comes with it,
69
+ [xtr-password-hasher](../xtr-password-hasher), for the password-authenticated user interface.
70
+
71
+ ## Quick start
72
+
73
+ Everything below runs without a container: build a user provider, a decision manager over a few
74
+ voters, and ask the checker. The roles on the token are expanded through the hierarchy, and a
75
+ voter you write joins the others by implementing `Voter`:
76
+
77
+ ```python
78
+ from __future__ import annotations
79
+
80
+ import asyncio
81
+
82
+ from xtr_security_core import (
83
+ AccessDecisionManager,
84
+ AffirmativeStrategy,
85
+ AuthenticatedVoter,
86
+ AuthenticationTrustResolver,
87
+ AuthorizationChecker,
88
+ ClosureVoter,
89
+ InMemoryUser,
90
+ InMemoryUserProvider,
91
+ IsGrantedContext,
92
+ RoleHierarchy,
93
+ RoleHierarchyVoter,
94
+ TokenStorage,
95
+ UsernamePasswordToken,
96
+ Vote,
97
+ Voter,
98
+ )
99
+ from xtr_security_core.authentication.token.token_interface import TokenInterface
100
+
101
+
102
+ class Document:
103
+ def __init__(self, owner: str) -> None:
104
+ self.owner = owner
105
+
106
+
107
+ class OwnsDocumentVoter(Voter):
108
+ def supports(self, attribute: object, subject: object) -> bool:
109
+ return attribute == "EDIT" and isinstance(subject, Document)
110
+
111
+ async def vote_on_attribute(
112
+ self,
113
+ attribute: object,
114
+ subject: object,
115
+ token: TokenInterface,
116
+ vote: Vote | None,
117
+ ) -> bool:
118
+ assert isinstance(subject, Document)
119
+ return subject.owner == token.get_user_identifier()
120
+
121
+
122
+ async def main() -> None:
123
+ provider = InMemoryUserProvider(
124
+ {
125
+ "ada": InMemoryUser("ada", roles=["ROLE_ADMIN"]),
126
+ "lin": InMemoryUser("lin", roles=["ROLE_USER"]),
127
+ }
128
+ )
129
+ ada = await provider.load_user_by_identifier("ada")
130
+
131
+ hierarchy = RoleHierarchy({"ROLE_ADMIN": ["ROLE_USER"]})
132
+ manager = AccessDecisionManager(
133
+ voters=[
134
+ RoleHierarchyVoter(hierarchy),
135
+ AuthenticatedVoter(AuthenticationTrustResolver()),
136
+ OwnsDocumentVoter(),
137
+ ClosureVoter(),
138
+ ],
139
+ strategy=AffirmativeStrategy(),
140
+ )
141
+
142
+ storage = TokenStorage()
143
+ storage.set_token(UsernamePasswordToken(ada, "main", roles=ada.get_roles()))
144
+ checker = AuthorizationChecker(storage, manager)
145
+
146
+ print("ROLE_USER (via hierarchy):", await checker.is_granted("ROLE_USER"))
147
+ print("IS_AUTHENTICATED:", await checker.is_granted("IS_AUTHENTICATED"))
148
+ print("EDIT own doc:", await checker.is_granted("EDIT", Document(owner="ada")))
149
+ print("EDIT other's doc:", await checker.is_granted("EDIT", Document(owner="lin")))
150
+
151
+ async def can_publish(context: IsGrantedContext, subject: object) -> bool:
152
+ return await context.is_granted("ROLE_ADMIN")
153
+
154
+ print("closure can_publish:", await checker.is_granted(can_publish))
155
+
156
+
157
+ asyncio.run(main())
158
+ ```
159
+
160
+ ```console
161
+ ROLE_USER (via hierarchy): True
162
+ IS_AUTHENTICATED: True
163
+ EDIT own doc: True
164
+ EDIT other's doc: False
165
+ closure can_publish: True
166
+ ```
167
+
168
+ ## Concepts
169
+
170
+ ### Users, providers and checkers
171
+
172
+ A **`UserInterface`** is the smallest useful account: `get_user_identifier()` names it, and
173
+ `get_roles()` returns the roles it carries. `InMemoryUser(identifier, password=None, roles=(),
174
+ enabled=True)` is one for tests and small deployments; it also answers `get_password()` (it is a
175
+ [password-authenticated user](../xtr-password-hasher)), `is_enabled()` and `is_equal_to(other)`.
176
+ `OidcUser(claims, identifier_claim="sub", roles=("ROLE_USER",))` is a user built from a verified
177
+ token's own claims, for a deployment that keeps no user store.
178
+
179
+ A **`UserProviderInterface`** loads a user by identifier — `load_user_by_identifier(identifier)`,
180
+ raising `UserNotFoundError` when none matches — and `supports_class(user_class)` tells which
181
+ class it loads. `InMemoryUserProvider` holds a map of identifier to user; `ChainUserProvider`
182
+ tries several in order and forwards a `upgrade_password` to whichever can.
183
+ `AttributesBasedUserProviderInterface` adds an `attributes` mapping to the load, for a provider
184
+ that reads a verified token's claims. `PasswordUpgraderInterface` is the one method a provider
185
+ implements to store a freshly rehashed password, and `EquatableInterface.is_equal_to` lets two
186
+ users be compared field for field.
187
+
188
+ A **`UserCheckerInterface`** gates an account around authentication: `check_pre_auth(user)`
189
+ before the credentials are verified, `check_post_auth(user, token)` after. `InMemoryUserChecker`
190
+ refuses a disabled account with `DisabledError`; `ChainUserChecker` runs several in order.
191
+
192
+ ### Tokens and storage
193
+
194
+ A **`TokenInterface`** carries the authenticated user (`get_user()`, `get_user_identifier()`),
195
+ the roles fixed on it (`get_role_names()`), and a bag of attributes (`get_attribute`,
196
+ `set_attribute`, `has_attribute`, `get_attributes`). `AbstractToken` is the base;
197
+ `NullToken` is the anonymous caller, with no user and no roles; `UsernamePasswordToken(user,
198
+ firewall_name, roles=())` adds the firewall (or unit of work) that authenticated the user,
199
+ read back with `get_firewall_name()`.
200
+
201
+ **`TokenStorage`** holds the current token for one unit of work — `get_token()` /
202
+ `set_token(token)` — and `reset()` forgets it so the next one starts clean. The
203
+ **`AuthenticationTrustResolver`** reads a token's standing: `is_authenticated(token)` tells
204
+ whether anyone is behind it, `is_full_fledged(token)` whether it was fully authenticated this
205
+ unit of work.
206
+
207
+ ### Voters, strategies and the decision
208
+
209
+ A **`VoterInterface`** answers `vote(token, subject, attributes) -> Access`, where
210
+ `Access` is `GRANTED`, `DENIED` or `ABSTAIN`. Write one by subclassing **`Voter`** and
211
+ answering `supports(attribute, subject)` and `vote_on_attribute(attribute, subject, token,
212
+ vote)`; a `CacheableVoterInterface` additionally declares `supports_attribute` /
213
+ `supports_type` so the manager can skip a voter that will never speak.
214
+
215
+ The **`AccessDecisionManager(voters, strategy)`** gathers the votes and folds them with a
216
+ **strategy**:
217
+
218
+ | Strategy | Grants when |
219
+ |---|---|
220
+ | `AffirmativeStrategy` | any voter grants (the default) |
221
+ | `ConsensusStrategy` | grants outnumber denials |
222
+ | `UnanimousStrategy` | no voter denies |
223
+ | `PriorityStrategy` | the first voter that does not abstain grants |
224
+
225
+ Each takes `allow_if_all_abstain` (and `ConsensusStrategy` a tie-breaker). `decide(token,
226
+ attributes, subject=None, access_decision=None)` reports one `bool`, filling an optional
227
+ **`AccessDecision`** with every `Vote` cast, the deciding strategy's name, and a `message`
228
+ accounting for the outcome. Each `Vote` carries its `voter`, its `result`, the `reasons` the
229
+ voter added with `add_reason(...)`, and any `extra_data`.
230
+
231
+ The built-in voters:
232
+
233
+ | Voter | Grants on | Reads |
234
+ |---|---|---|
235
+ | `RoleVoter(prefix="ROLE_")` | a role the token holds | the token's roles |
236
+ | `RoleHierarchyVoter(hierarchy, prefix="ROLE_")` | a role the token's roles *reach* | the token's roles, expanded |
237
+ | `AuthenticatedVoter(trust_resolver)` | `IS_AUTHENTICATED`, `IS_AUTHENTICATED_FULLY`, `PUBLIC_ACCESS` | the token's standing |
238
+ | `ClosureVoter()` | a callable attribute returning `True` | a fresh `IsGrantedContext` |
239
+
240
+ A **`ClosureVoter`** runs a callable attribute `(IsGrantedContext, subject) -> bool`, so a
241
+ one-off rule needs no class; the context it passes can defer to the manager again with
242
+ `await context.is_granted(...)`.
243
+
244
+ ### The authorization checker
245
+
246
+ **`AuthorizationChecker(token_storage, access_decision_manager)`** is the one call the rest of
247
+ an application makes: `is_granted(attribute, subject=None)` decides for the token currently in
248
+ storage, and `is_granted_for_user(user, attribute, subject=None)` decides for a given user
249
+ (`GuestAuthorizationCheckerInterface`) without touching storage. **`IsGrantedContext`** is the
250
+ value a closure voter is handed — the `token`, its `user`, and `is_granted(...)` for a nested
251
+ question.
252
+
253
+ ### Role hierarchy and wildcards
254
+
255
+ **`RoleHierarchy(mapping)`** expands a set of roles: `get_reachable_role_names(roles)` returns
256
+ the input roles plus every role they reach transitively, with no duplicates and safe against
257
+ cycles; `get_parent_role_names(role)` is one level down. A `*` in a key captures a segment of a
258
+ matching role and substitutes it into the values, so
259
+ `{"ROLE_TENANT_*_ADMIN": ["ROLE_TENANT_*_USER"]}` makes `ROLE_TENANT_42_ADMIN` reach
260
+ `ROLE_TENANT_42_USER`.
261
+
262
+ ### Tracing votes and events
263
+
264
+ **`TraceableVoter(voter, event_dispatcher)`** wraps any voter and announces its answer as a
265
+ `VoteEvent` — the voter, the subject, the attributes, the `Access` and the reasons — so a
266
+ decision can be read after the fact; `get_decorated_voter()` returns the voter it wraps. The
267
+ two event names a dispatcher keys on live in `authentication_events.py`:
268
+ `AUTHENTICATION_SUCCESS` (announced once a token is created for an authenticated user, carrying
269
+ an `AuthenticationSuccessEvent`) and `VOTE` (announced by a `TraceableVoter`). An
270
+ `AuthenticationEvent` carries the `token` it settled on.
271
+
272
+ ## Errors
273
+
274
+ Everything this library raises derives from `SecurityError`, and carries what went wrong as
275
+ typed attributes rather than only a message.
276
+
277
+ | Error | Base (besides `SecurityError`) | Raised when |
278
+ |---|---|---|
279
+ | `AccessDeniedError` | — | authorization refused a known caller; carries `attributes`, `subject`, `access_decision` |
280
+ | `AuthenticationError` | — | authentication failed; carries a `message_key` and `message_data` |
281
+ | `AccountStatusError` | `AuthenticationError` | the account's own state refuses it; carries the `user` |
282
+ | `AccountExpiredError` | `AccountStatusError` | the account has expired |
283
+ | `CredentialsExpiredError` | `AccountStatusError` | the account's credentials have expired |
284
+ | `DisabledError` | `AccountStatusError` | the account is disabled |
285
+ | `LockedError` | `AccountStatusError` | the account is locked |
286
+ | `CustomUserMessageAccountStatusError` | `AccountStatusError` | an account-status failure whose public text is chosen at the raise site |
287
+ | `AuthenticationCredentialsNotFoundError` | `AuthenticationError` | no credentials were found in the request |
288
+ | `AuthenticationServiceError` | `AuthenticationError` | a service authentication relies on failed |
289
+ | `BadCredentialsError` | `AuthenticationError` | the credentials presented were rejected |
290
+ | `CustomUserMessageAuthenticationError` | `AuthenticationError` | an authentication failure whose public text is chosen at the raise site |
291
+ | `InsufficientAuthenticationError` | `AuthenticationError` | authenticated, but not strongly enough |
292
+ | `UserNotFoundError` | `AuthenticationError`, `LookupError` | no user matched the identifier |
293
+ | `InvalidArgumentError` | `ValueError` | a declaration, configuration or call was malformed |
294
+ | `UnsupportedUserError` | `TypeError` | a user of the wrong class reached a provider, checker or resolver |
295
+
296
+ ## Layout
297
+
298
+ ```
299
+ xtr_security_core/
300
+ ├── user/ UserInterface, InMemoryUser(Provider/Checker), chains, OidcUser
301
+ ├── authentication/
302
+ │ ├── token/ TokenInterface, AbstractToken, NullToken, UsernamePasswordToken
303
+ │ │ └── storage/ TokenStorage, the current token per unit of work
304
+ │ └── authentication_trust_resolver.py is a token authenticated, and how fully
305
+ ├── authentication_events.py AUTHENTICATION_SUCCESS, VOTE
306
+ ├── authorization/
307
+ │ ├── access_decision_manager.py gathers voters and decides with a strategy
308
+ │ ├── authorization_checker.py is_granted — the one call the application makes
309
+ │ ├── is_granted_context.py what a closure voter is handed
310
+ │ ├── strategy/ affirmative, consensus, unanimous, priority
311
+ │ └── voter/ VoterInterface, Voter, Access, Vote, the built-in voters
312
+ ├── role/ RoleHierarchy, incl. wildcards
313
+ ├── event/ AuthenticationSuccessEvent, VoteEvent
314
+ └── exception/ SecurityError, the root of everything this library raises
315
+ ```
316
+
317
+ ## In an application
318
+
319
+ This package works on its own and ships no bundle. The
320
+ [`xtr-security`](../xtr-security#use-in-an-application) bundle wires it — the token storage, the
321
+ role hierarchy, the voters and decision manager, the authorization checker — into an application
322
+ on [xtr-dependency-injection](../xtr-dependency-injection); see that package's
323
+ [Use in an application](../xtr-security#use-in-an-application). A class implementing
324
+ `VoterInterface` is gathered into the decision manager by the bundle's `security.voter` tag.
325
+
326
+ ## Development
327
+
328
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
329
+ `packages/xtr-security-core`; run the commands below from there. The `python-xtr-security-core`
330
+ repository is a read-only copy, so send issues and pull requests to the monorepo.
331
+
332
+ ```sh
333
+ uv sync --all-extras
334
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
335
+ ```
336
+
337
+ ## License
338
+
339
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,316 @@
1
+ <div align="center">
2
+
3
+ # xtr-security-core
4
+
5
+ **The security core: users, tokens, roles, voters and the authorization decision.**
6
+
7
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
8
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
9
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
10
+
11
+ </div>
12
+
13
+ ---
14
+
15
+ ## Why?
16
+
17
+ Two questions sit under every protected feature: *who is this?* and *may they do this?* The
18
+ first settles on a **token** — a user and the roles fixed on them for this unit of work. The
19
+ second asks a set of **voters** and folds their answers into one yes or no with a **strategy**.
20
+ Keeping the two apart, and keeping the decision a small pluggable thing, is what lets a web edge,
21
+ a console command and a background job all reach the same answer the same way.
22
+
23
+ This package is that core, with no web framework and no container in sight:
24
+
25
+ - 🪪 **Users and providers** — a `UserInterface` is an identifier and a set of roles; a provider
26
+ loads one by identifier, from memory or from anywhere an application writes.
27
+ - 🎫 **Tokens and storage** — a token carries the authenticated user and their roles; a
28
+ per-unit-of-work storage holds the current one.
29
+ - 🗳️ **Voters, strategies and one decision** — each voter grants, denies or abstains on an
30
+ attribute; a strategy turns the votes into a decision, and the `AuthorizationChecker` is the
31
+ one call the rest of the application makes.
32
+ - 🪜 **A role hierarchy** — one role reaches others, wildcards included, so `ROLE_ADMIN` need
33
+ not list every role it implies.
34
+
35
+ The HTTP edge — firewalls, authenticators, bearer tokens — is a separate package,
36
+ [xtr-security-http](../xtr-security-http); the OAuth2 scope voter lives there, since scopes are a
37
+ request concern.
38
+
39
+ ## Install
40
+
41
+ ```sh
42
+ uv add xtr-security-core
43
+ ```
44
+
45
+ Requires Python 3.11+. One sibling comes with it,
46
+ [xtr-password-hasher](../xtr-password-hasher), for the password-authenticated user interface.
47
+
48
+ ## Quick start
49
+
50
+ Everything below runs without a container: build a user provider, a decision manager over a few
51
+ voters, and ask the checker. The roles on the token are expanded through the hierarchy, and a
52
+ voter you write joins the others by implementing `Voter`:
53
+
54
+ ```python
55
+ from __future__ import annotations
56
+
57
+ import asyncio
58
+
59
+ from xtr_security_core import (
60
+ AccessDecisionManager,
61
+ AffirmativeStrategy,
62
+ AuthenticatedVoter,
63
+ AuthenticationTrustResolver,
64
+ AuthorizationChecker,
65
+ ClosureVoter,
66
+ InMemoryUser,
67
+ InMemoryUserProvider,
68
+ IsGrantedContext,
69
+ RoleHierarchy,
70
+ RoleHierarchyVoter,
71
+ TokenStorage,
72
+ UsernamePasswordToken,
73
+ Vote,
74
+ Voter,
75
+ )
76
+ from xtr_security_core.authentication.token.token_interface import TokenInterface
77
+
78
+
79
+ class Document:
80
+ def __init__(self, owner: str) -> None:
81
+ self.owner = owner
82
+
83
+
84
+ class OwnsDocumentVoter(Voter):
85
+ def supports(self, attribute: object, subject: object) -> bool:
86
+ return attribute == "EDIT" and isinstance(subject, Document)
87
+
88
+ async def vote_on_attribute(
89
+ self,
90
+ attribute: object,
91
+ subject: object,
92
+ token: TokenInterface,
93
+ vote: Vote | None,
94
+ ) -> bool:
95
+ assert isinstance(subject, Document)
96
+ return subject.owner == token.get_user_identifier()
97
+
98
+
99
+ async def main() -> None:
100
+ provider = InMemoryUserProvider(
101
+ {
102
+ "ada": InMemoryUser("ada", roles=["ROLE_ADMIN"]),
103
+ "lin": InMemoryUser("lin", roles=["ROLE_USER"]),
104
+ }
105
+ )
106
+ ada = await provider.load_user_by_identifier("ada")
107
+
108
+ hierarchy = RoleHierarchy({"ROLE_ADMIN": ["ROLE_USER"]})
109
+ manager = AccessDecisionManager(
110
+ voters=[
111
+ RoleHierarchyVoter(hierarchy),
112
+ AuthenticatedVoter(AuthenticationTrustResolver()),
113
+ OwnsDocumentVoter(),
114
+ ClosureVoter(),
115
+ ],
116
+ strategy=AffirmativeStrategy(),
117
+ )
118
+
119
+ storage = TokenStorage()
120
+ storage.set_token(UsernamePasswordToken(ada, "main", roles=ada.get_roles()))
121
+ checker = AuthorizationChecker(storage, manager)
122
+
123
+ print("ROLE_USER (via hierarchy):", await checker.is_granted("ROLE_USER"))
124
+ print("IS_AUTHENTICATED:", await checker.is_granted("IS_AUTHENTICATED"))
125
+ print("EDIT own doc:", await checker.is_granted("EDIT", Document(owner="ada")))
126
+ print("EDIT other's doc:", await checker.is_granted("EDIT", Document(owner="lin")))
127
+
128
+ async def can_publish(context: IsGrantedContext, subject: object) -> bool:
129
+ return await context.is_granted("ROLE_ADMIN")
130
+
131
+ print("closure can_publish:", await checker.is_granted(can_publish))
132
+
133
+
134
+ asyncio.run(main())
135
+ ```
136
+
137
+ ```console
138
+ ROLE_USER (via hierarchy): True
139
+ IS_AUTHENTICATED: True
140
+ EDIT own doc: True
141
+ EDIT other's doc: False
142
+ closure can_publish: True
143
+ ```
144
+
145
+ ## Concepts
146
+
147
+ ### Users, providers and checkers
148
+
149
+ A **`UserInterface`** is the smallest useful account: `get_user_identifier()` names it, and
150
+ `get_roles()` returns the roles it carries. `InMemoryUser(identifier, password=None, roles=(),
151
+ enabled=True)` is one for tests and small deployments; it also answers `get_password()` (it is a
152
+ [password-authenticated user](../xtr-password-hasher)), `is_enabled()` and `is_equal_to(other)`.
153
+ `OidcUser(claims, identifier_claim="sub", roles=("ROLE_USER",))` is a user built from a verified
154
+ token's own claims, for a deployment that keeps no user store.
155
+
156
+ A **`UserProviderInterface`** loads a user by identifier — `load_user_by_identifier(identifier)`,
157
+ raising `UserNotFoundError` when none matches — and `supports_class(user_class)` tells which
158
+ class it loads. `InMemoryUserProvider` holds a map of identifier to user; `ChainUserProvider`
159
+ tries several in order and forwards a `upgrade_password` to whichever can.
160
+ `AttributesBasedUserProviderInterface` adds an `attributes` mapping to the load, for a provider
161
+ that reads a verified token's claims. `PasswordUpgraderInterface` is the one method a provider
162
+ implements to store a freshly rehashed password, and `EquatableInterface.is_equal_to` lets two
163
+ users be compared field for field.
164
+
165
+ A **`UserCheckerInterface`** gates an account around authentication: `check_pre_auth(user)`
166
+ before the credentials are verified, `check_post_auth(user, token)` after. `InMemoryUserChecker`
167
+ refuses a disabled account with `DisabledError`; `ChainUserChecker` runs several in order.
168
+
169
+ ### Tokens and storage
170
+
171
+ A **`TokenInterface`** carries the authenticated user (`get_user()`, `get_user_identifier()`),
172
+ the roles fixed on it (`get_role_names()`), and a bag of attributes (`get_attribute`,
173
+ `set_attribute`, `has_attribute`, `get_attributes`). `AbstractToken` is the base;
174
+ `NullToken` is the anonymous caller, with no user and no roles; `UsernamePasswordToken(user,
175
+ firewall_name, roles=())` adds the firewall (or unit of work) that authenticated the user,
176
+ read back with `get_firewall_name()`.
177
+
178
+ **`TokenStorage`** holds the current token for one unit of work — `get_token()` /
179
+ `set_token(token)` — and `reset()` forgets it so the next one starts clean. The
180
+ **`AuthenticationTrustResolver`** reads a token's standing: `is_authenticated(token)` tells
181
+ whether anyone is behind it, `is_full_fledged(token)` whether it was fully authenticated this
182
+ unit of work.
183
+
184
+ ### Voters, strategies and the decision
185
+
186
+ A **`VoterInterface`** answers `vote(token, subject, attributes) -> Access`, where
187
+ `Access` is `GRANTED`, `DENIED` or `ABSTAIN`. Write one by subclassing **`Voter`** and
188
+ answering `supports(attribute, subject)` and `vote_on_attribute(attribute, subject, token,
189
+ vote)`; a `CacheableVoterInterface` additionally declares `supports_attribute` /
190
+ `supports_type` so the manager can skip a voter that will never speak.
191
+
192
+ The **`AccessDecisionManager(voters, strategy)`** gathers the votes and folds them with a
193
+ **strategy**:
194
+
195
+ | Strategy | Grants when |
196
+ |---|---|
197
+ | `AffirmativeStrategy` | any voter grants (the default) |
198
+ | `ConsensusStrategy` | grants outnumber denials |
199
+ | `UnanimousStrategy` | no voter denies |
200
+ | `PriorityStrategy` | the first voter that does not abstain grants |
201
+
202
+ Each takes `allow_if_all_abstain` (and `ConsensusStrategy` a tie-breaker). `decide(token,
203
+ attributes, subject=None, access_decision=None)` reports one `bool`, filling an optional
204
+ **`AccessDecision`** with every `Vote` cast, the deciding strategy's name, and a `message`
205
+ accounting for the outcome. Each `Vote` carries its `voter`, its `result`, the `reasons` the
206
+ voter added with `add_reason(...)`, and any `extra_data`.
207
+
208
+ The built-in voters:
209
+
210
+ | Voter | Grants on | Reads |
211
+ |---|---|---|
212
+ | `RoleVoter(prefix="ROLE_")` | a role the token holds | the token's roles |
213
+ | `RoleHierarchyVoter(hierarchy, prefix="ROLE_")` | a role the token's roles *reach* | the token's roles, expanded |
214
+ | `AuthenticatedVoter(trust_resolver)` | `IS_AUTHENTICATED`, `IS_AUTHENTICATED_FULLY`, `PUBLIC_ACCESS` | the token's standing |
215
+ | `ClosureVoter()` | a callable attribute returning `True` | a fresh `IsGrantedContext` |
216
+
217
+ A **`ClosureVoter`** runs a callable attribute `(IsGrantedContext, subject) -> bool`, so a
218
+ one-off rule needs no class; the context it passes can defer to the manager again with
219
+ `await context.is_granted(...)`.
220
+
221
+ ### The authorization checker
222
+
223
+ **`AuthorizationChecker(token_storage, access_decision_manager)`** is the one call the rest of
224
+ an application makes: `is_granted(attribute, subject=None)` decides for the token currently in
225
+ storage, and `is_granted_for_user(user, attribute, subject=None)` decides for a given user
226
+ (`GuestAuthorizationCheckerInterface`) without touching storage. **`IsGrantedContext`** is the
227
+ value a closure voter is handed — the `token`, its `user`, and `is_granted(...)` for a nested
228
+ question.
229
+
230
+ ### Role hierarchy and wildcards
231
+
232
+ **`RoleHierarchy(mapping)`** expands a set of roles: `get_reachable_role_names(roles)` returns
233
+ the input roles plus every role they reach transitively, with no duplicates and safe against
234
+ cycles; `get_parent_role_names(role)` is one level down. A `*` in a key captures a segment of a
235
+ matching role and substitutes it into the values, so
236
+ `{"ROLE_TENANT_*_ADMIN": ["ROLE_TENANT_*_USER"]}` makes `ROLE_TENANT_42_ADMIN` reach
237
+ `ROLE_TENANT_42_USER`.
238
+
239
+ ### Tracing votes and events
240
+
241
+ **`TraceableVoter(voter, event_dispatcher)`** wraps any voter and announces its answer as a
242
+ `VoteEvent` — the voter, the subject, the attributes, the `Access` and the reasons — so a
243
+ decision can be read after the fact; `get_decorated_voter()` returns the voter it wraps. The
244
+ two event names a dispatcher keys on live in `authentication_events.py`:
245
+ `AUTHENTICATION_SUCCESS` (announced once a token is created for an authenticated user, carrying
246
+ an `AuthenticationSuccessEvent`) and `VOTE` (announced by a `TraceableVoter`). An
247
+ `AuthenticationEvent` carries the `token` it settled on.
248
+
249
+ ## Errors
250
+
251
+ Everything this library raises derives from `SecurityError`, and carries what went wrong as
252
+ typed attributes rather than only a message.
253
+
254
+ | Error | Base (besides `SecurityError`) | Raised when |
255
+ |---|---|---|
256
+ | `AccessDeniedError` | — | authorization refused a known caller; carries `attributes`, `subject`, `access_decision` |
257
+ | `AuthenticationError` | — | authentication failed; carries a `message_key` and `message_data` |
258
+ | `AccountStatusError` | `AuthenticationError` | the account's own state refuses it; carries the `user` |
259
+ | `AccountExpiredError` | `AccountStatusError` | the account has expired |
260
+ | `CredentialsExpiredError` | `AccountStatusError` | the account's credentials have expired |
261
+ | `DisabledError` | `AccountStatusError` | the account is disabled |
262
+ | `LockedError` | `AccountStatusError` | the account is locked |
263
+ | `CustomUserMessageAccountStatusError` | `AccountStatusError` | an account-status failure whose public text is chosen at the raise site |
264
+ | `AuthenticationCredentialsNotFoundError` | `AuthenticationError` | no credentials were found in the request |
265
+ | `AuthenticationServiceError` | `AuthenticationError` | a service authentication relies on failed |
266
+ | `BadCredentialsError` | `AuthenticationError` | the credentials presented were rejected |
267
+ | `CustomUserMessageAuthenticationError` | `AuthenticationError` | an authentication failure whose public text is chosen at the raise site |
268
+ | `InsufficientAuthenticationError` | `AuthenticationError` | authenticated, but not strongly enough |
269
+ | `UserNotFoundError` | `AuthenticationError`, `LookupError` | no user matched the identifier |
270
+ | `InvalidArgumentError` | `ValueError` | a declaration, configuration or call was malformed |
271
+ | `UnsupportedUserError` | `TypeError` | a user of the wrong class reached a provider, checker or resolver |
272
+
273
+ ## Layout
274
+
275
+ ```
276
+ xtr_security_core/
277
+ ├── user/ UserInterface, InMemoryUser(Provider/Checker), chains, OidcUser
278
+ ├── authentication/
279
+ │ ├── token/ TokenInterface, AbstractToken, NullToken, UsernamePasswordToken
280
+ │ │ └── storage/ TokenStorage, the current token per unit of work
281
+ │ └── authentication_trust_resolver.py is a token authenticated, and how fully
282
+ ├── authentication_events.py AUTHENTICATION_SUCCESS, VOTE
283
+ ├── authorization/
284
+ │ ├── access_decision_manager.py gathers voters and decides with a strategy
285
+ │ ├── authorization_checker.py is_granted — the one call the application makes
286
+ │ ├── is_granted_context.py what a closure voter is handed
287
+ │ ├── strategy/ affirmative, consensus, unanimous, priority
288
+ │ └── voter/ VoterInterface, Voter, Access, Vote, the built-in voters
289
+ ├── role/ RoleHierarchy, incl. wildcards
290
+ ├── event/ AuthenticationSuccessEvent, VoteEvent
291
+ └── exception/ SecurityError, the root of everything this library raises
292
+ ```
293
+
294
+ ## In an application
295
+
296
+ This package works on its own and ships no bundle. The
297
+ [`xtr-security`](../xtr-security#use-in-an-application) bundle wires it — the token storage, the
298
+ role hierarchy, the voters and decision manager, the authorization checker — into an application
299
+ on [xtr-dependency-injection](../xtr-dependency-injection); see that package's
300
+ [Use in an application](../xtr-security#use-in-an-application). A class implementing
301
+ `VoterInterface` is gathered into the decision manager by the bundle's `security.voter` tag.
302
+
303
+ ## Development
304
+
305
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
306
+ `packages/xtr-security-core`; run the commands below from there. The `python-xtr-security-core`
307
+ repository is a read-only copy, so send issues and pull requests to the monorepo.
308
+
309
+ ```sh
310
+ uv sync --all-extras
311
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
312
+ ```
313
+
314
+ ## License
315
+
316
+ MIT — see [LICENSE](LICENSE).