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.
- xtr_security_core-3.0.0/LICENSE +21 -0
- xtr_security_core-3.0.0/PKG-INFO +339 -0
- xtr_security_core-3.0.0/README.md +316 -0
- xtr_security_core-3.0.0/pyproject.toml +144 -0
- xtr_security_core-3.0.0/pyproject.toml.orig +109 -0
- xtr_security_core-3.0.0/src/xtr_security_core/__init__.py +146 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authentication/__init__.py +11 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authentication/authentication_trust_resolver.py +39 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authentication/authentication_trust_resolver_interface.py +30 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authentication/token/__init__.py +15 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authentication/token/abstract_token.py +85 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authentication/token/null_token.py +23 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authentication/token/storage/__init__.py +8 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authentication/token/storage/token_storage.py +50 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authentication/token/storage/token_storage_interface.py +29 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authentication/token/token_interface.py +56 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authentication/token/username_password_token.py +42 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authentication_events.py +30 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/__init__.py +55 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/access_decision.py +46 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/access_decision_manager.py +180 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/access_decision_manager_interface.py +40 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/authorization_checker.py +86 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/authorization_checker_interface.py +29 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/guest_authorization_checker_interface.py +33 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/is_granted_context.py +43 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/strategy/__init__.py +17 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/strategy/access_decision_strategy_interface.py +35 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/strategy/affirmative_strategy.py +54 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/strategy/consensus_strategy.py +61 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/strategy/priority_strategy.py +51 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/strategy/unanimous_strategy.py +54 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/__init__.py +27 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/access.py +21 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/authenticated_voter.py +89 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/cacheable_voter_interface.py +31 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/closure_voter.py +91 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/role_hierarchy_voter.py +39 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/role_voter.py +70 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/traceable_voter.py +87 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/vote.py +35 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/voter.py +80 -0
- xtr_security_core-3.0.0/src/xtr_security_core/authorization/voter/voter_interface.py +43 -0
- xtr_security_core-3.0.0/src/xtr_security_core/event/__init__.py +9 -0
- xtr_security_core-3.0.0/src/xtr_security_core/event/authentication_event.py +32 -0
- xtr_security_core-3.0.0/src/xtr_security_core/event/authentication_success_event.py +22 -0
- xtr_security_core-3.0.0/src/xtr_security_core/event/vote_event.py +41 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/__init__.py +48 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/access_denied_error.py +47 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/account_expired_error.py +15 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/account_status_error.py +41 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/authentication_credentials_not_found_error.py +19 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/authentication_error.py +61 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/authentication_service_error.py +22 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/bad_credentials_error.py +20 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/credentials_expired_error.py +15 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/custom_user_message_account_status_error.py +39 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/custom_user_message_authentication_error.py +36 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/disabled_error.py +15 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/insufficient_authentication_error.py +19 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/invalid_argument_error.py +18 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/locked_error.py +15 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/security_error.py +14 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/unsupported_user_error.py +15 -0
- xtr_security_core-3.0.0/src/xtr_security_core/exception/user_not_found_error.py +44 -0
- xtr_security_core-3.0.0/src/xtr_security_core/py.typed +0 -0
- xtr_security_core-3.0.0/src/xtr_security_core/role/__init__.py +8 -0
- xtr_security_core-3.0.0/src/xtr_security_core/role/role_hierarchy.py +123 -0
- xtr_security_core-3.0.0/src/xtr_security_core/role/role_hierarchy_interface.py +28 -0
- xtr_security_core-3.0.0/src/xtr_security_core/user/__init__.py +43 -0
- xtr_security_core-3.0.0/src/xtr_security_core/user/attributes_based_user_provider_interface.py +41 -0
- xtr_security_core-3.0.0/src/xtr_security_core/user/chain_user_checker.py +57 -0
- xtr_security_core-3.0.0/src/xtr_security_core/user/chain_user_provider.py +110 -0
- xtr_security_core-3.0.0/src/xtr_security_core/user/equatable_interface.py +25 -0
- xtr_security_core-3.0.0/src/xtr_security_core/user/in_memory_user.py +87 -0
- xtr_security_core-3.0.0/src/xtr_security_core/user/in_memory_user_checker.py +48 -0
- xtr_security_core-3.0.0/src/xtr_security_core/user/in_memory_user_provider.py +96 -0
- xtr_security_core-3.0.0/src/xtr_security_core/user/oidc_user.py +66 -0
- xtr_security_core-3.0.0/src/xtr_security_core/user/password_upgrader_interface.py +33 -0
- xtr_security_core-3.0.0/src/xtr_security_core/user/user_checker_interface.py +43 -0
- xtr_security_core-3.0.0/src/xtr_security_core/user/user_interface.py +38 -0
- 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).
|