xtr-security 3.0.0__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.
- xtr_security/.agents/skills/xtr-security/SKILL.md +291 -0
- xtr_security/.agents/skills/xtr-security/references/authorization.md +149 -0
- xtr_security/.agents/skills/xtr-security/references/firewalls-and-authenticators.md +165 -0
- xtr_security/.agents/skills/xtr-security/references/password-hashing.md +122 -0
- xtr_security/__init__.py +13 -0
- xtr_security/access_token/__init__.py +19 -0
- xtr_security/access_token/add_token_handler_factory.py +35 -0
- xtr_security/access_token/oidc_token_handler_factory.py +114 -0
- xtr_security/access_token/service_token_handler_factory.py +48 -0
- xtr_security/access_token/token_handler_factory_interface.py +52 -0
- xtr_security/bundle/__init__.py +97 -0
- xtr_security/bundle/_firewall_scheme_middleware.py +38 -0
- xtr_security/bundle/_firewall_scheme_middleware_factory.py +33 -0
- xtr_security/bundle/_password_hasher_build.py +83 -0
- xtr_security/bundle/_wiring.py +736 -0
- xtr_security/bundle/access_control_configs.py +83 -0
- xtr_security/bundle/access_decision_manager_config.py +66 -0
- xtr_security/bundle/authenticator_configs.py +56 -0
- xtr_security/bundle/firewall_dispatcher_name.py +19 -0
- xtr_security/bundle/make_firewalls_event_dispatcher_traceable_pass.py +53 -0
- xtr_security/bundle/password_hasher_configs.py +166 -0
- xtr_security/bundle/register_global_security_event_listeners_pass.py +69 -0
- xtr_security/bundle/security_bundle.py +305 -0
- xtr_security/bundle/security_config.py +155 -0
- xtr_security/bundle/token_handler_configs.py +120 -0
- xtr_security/bundle/user_provider_configs.py +98 -0
- xtr_security/command/__init__.py +15 -0
- xtr_security/command/debug_firewall_command.py +76 -0
- xtr_security/exception/__init__.py +12 -0
- xtr_security/exception/invalid_configuration_error.py +20 -0
- xtr_security/factory/__init__.py +17 -0
- xtr_security/factory/access_token_factory.py +220 -0
- xtr_security/factory/add_authenticator_factory.py +36 -0
- xtr_security/factory/authenticator_factory_interface.py +68 -0
- xtr_security/firewall_config.py +107 -0
- xtr_security/firewall_context.py +68 -0
- xtr_security/firewall_map.py +93 -0
- xtr_security/py.typed +0 -0
- xtr_security/recipe/__init__.py +16 -0
- xtr_security/recipe/manifest.toml +30 -0
- xtr_security/security.py +108 -0
- xtr_security/user_provider/__init__.py +21 -0
- xtr_security/user_provider/add_user_provider_factory.py +35 -0
- xtr_security/user_provider/chain_factory.py +82 -0
- xtr_security/user_provider/in_memory_factory.py +68 -0
- xtr_security/user_provider/service_factory.py +48 -0
- xtr_security/user_provider/user_provider_factory_interface.py +51 -0
- xtr_security-3.0.0.dist-info/METADATA +398 -0
- xtr_security-3.0.0.dist-info/RECORD +52 -0
- xtr_security-3.0.0.dist-info/WHEEL +4 -0
- xtr_security-3.0.0.dist-info/entry_points.txt +6 -0
- xtr_security-3.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: xtr-security
|
|
3
|
+
description: Authentication and authorization for an xtr application — firewalls, bearer access tokens, users and providers, roles, voters, the current user, and password hashing — configured by one SecurityConfig. Use when adding a login or sign-in flow, protecting routes or a whole router, reading the current user in an endpoint or a service, checking a role or a permission, writing a voter or an owner-only rule, declaring a user provider, setting a role hierarchy, hashing or verifying a password, choosing a decision strategy, answering 401 or 403, requiring OAuth2 scopes, verifying a third-party OIDC issuer's tokens, or activating SecurityBundle in an application on xtr-dependency-injection.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# xtr-security
|
|
7
|
+
|
|
8
|
+
Two questions, kept apart. *Who is calling?* settles on a **token** — a user and the roles fixed
|
|
9
|
+
on them for this request, settled by a **firewall** over a slice of the request space. *May
|
|
10
|
+
they?* asks a set of **voters** and folds the answers into one decision, through access control,
|
|
11
|
+
`IsGranted` or the `Security` facade.
|
|
12
|
+
|
|
13
|
+
This is the bundle of the family: depend on it and the core (users, tokens, roles, voters), the
|
|
14
|
+
HTTP edge (firewalls, authenticators, the route surface) and the password hasher come with it.
|
|
15
|
+
Write one `SecurityConfig`; the container owns the rest.
|
|
16
|
+
|
|
17
|
+
## Quick reference
|
|
18
|
+
|
|
19
|
+
| Want | Write |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| Protect every route | `app = FastAPI(dependencies=[Firewall()])` |
|
|
22
|
+
| Protect one router by name | `router = Firewall("api")(APIRouter())` |
|
|
23
|
+
| Require a role on a route | `@IsGranted("ROLE_ADMIN")` under the route decorator |
|
|
24
|
+
| Require it over a subject | `dependencies=[IsGranted("BOOK_EDIT", subject=load_book)]` |
|
|
25
|
+
| The current user in an endpoint | `user: Annotated[UserInterface, CurrentUser()]` |
|
|
26
|
+
| The current user, or a decision, in a service | inject `Security` |
|
|
27
|
+
| Refuse in a service | `await security.deny_access_unless_granted("EDIT", book)` |
|
|
28
|
+
| A rule of your own | a class extending `Voter`, registered `@as_service` |
|
|
29
|
+
| Hash a password | inject `UserPasswordHasherInterface` |
|
|
30
|
+
| Require scopes | `api.scoped("books:read")` in a route's `dependencies` |
|
|
31
|
+
|
|
32
|
+
Import from these surfaces, nothing deeper: `xtr_security` (`Security`), `xtr_security.bundle`
|
|
33
|
+
(every `*Config` and `SecurityBundle`), `xtr_security_core` (users, tokens, voters, errors),
|
|
34
|
+
`xtr_security_http` (`Firewall`, `IsGranted`, `CurrentUser`, badges) and `xtr_password_hasher`.
|
|
35
|
+
|
|
36
|
+
Self-issued JSON Web Tokens are an add-on package, `xtr-security-jwt`, with a skill of its own
|
|
37
|
+
(`xtr-security-jwt`); it plugs its authenticator into this bundle.
|
|
38
|
+
|
|
39
|
+
## Configure the firewalls
|
|
40
|
+
|
|
41
|
+
One `@configure` function in `<app>/config/security.py`. Every field has a default, so start
|
|
42
|
+
small and add.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from xtr_dependency_injection import configure
|
|
46
|
+
from xtr_security.bundle import (
|
|
47
|
+
AccessControlConfig,
|
|
48
|
+
AccessTokenConfig,
|
|
49
|
+
AutoHasherConfig,
|
|
50
|
+
FirewallConfig,
|
|
51
|
+
InMemoryUserProviderConfig,
|
|
52
|
+
SecurityConfig,
|
|
53
|
+
ServiceTokenHandlerConfig,
|
|
54
|
+
)
|
|
55
|
+
from xtr_security_core import InMemoryUser
|
|
56
|
+
|
|
57
|
+
from app.security_handler import MyTokenHandler # your AccessTokenHandlerInterface service
|
|
58
|
+
|
|
59
|
+
_ADA = {"password": _ADA_HASH, "roles": ("ROLE_USER",)}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@configure
|
|
63
|
+
def security() -> SecurityConfig:
|
|
64
|
+
return SecurityConfig(
|
|
65
|
+
providers={ # one entry per name a firewall's `provider` refers to
|
|
66
|
+
"users": InMemoryUserProviderConfig(users={"ada@example.com": _ADA}),
|
|
67
|
+
},
|
|
68
|
+
password_hashers={InMemoryUser: AutoHasherConfig()},
|
|
69
|
+
role_hierarchy={"ROLE_ADMIN": ("ROLE_USER",)},
|
|
70
|
+
firewalls={
|
|
71
|
+
"api": FirewallConfig(
|
|
72
|
+
pattern=r"^/api",
|
|
73
|
+
provider="users",
|
|
74
|
+
authenticators=(
|
|
75
|
+
AccessTokenConfig(
|
|
76
|
+
token_handler=ServiceTokenHandlerConfig(service=MyTokenHandler),
|
|
77
|
+
realm="api",
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
},
|
|
82
|
+
access_control=(
|
|
83
|
+
AccessControlConfig(path=r"^/api/admin", attribute="ROLE_ADMIN"),
|
|
84
|
+
AccessControlConfig(path=r"^/api", attribute="IS_AUTHENTICATED"),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- Firewalls are matched in order; the first whose `pattern`, `host`, `methods` or
|
|
90
|
+
`request_matcher` claims the request wins. A firewall with none of them claims everything, so
|
|
91
|
+
it goes last.
|
|
92
|
+
- `access_control` rules are tried in order too, so the narrow `^/api/admin` rule comes before
|
|
93
|
+
the broad `^/api`. One attribute per rule: a role, `"IS_AUTHENTICATED"`, `"PUBLIC_ACCESS"`, or
|
|
94
|
+
an `oauth2_scope(...)` string.
|
|
95
|
+
- `role_hierarchy` expands the token's roles, so `ROLE_ADMIN` need not list `ROLE_USER`.
|
|
96
|
+
- Every string in a bundle config is parameter-resolved: write a literal `%` as `%%`, and read
|
|
97
|
+
secrets with `env("...")`.
|
|
98
|
+
|
|
99
|
+
Field tables, the token handler you write, the other provider kinds and the authenticator seam:
|
|
100
|
+
[references/firewalls-and-authenticators.md](references/firewalls-and-authenticators.md).
|
|
101
|
+
|
|
102
|
+
## Protect a route, and read the user
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from typing import Annotated
|
|
106
|
+
|
|
107
|
+
from fastapi import APIRouter, FastAPI
|
|
108
|
+
from xtr_security_core import UserInterface
|
|
109
|
+
from xtr_security_http import CurrentUser, Firewall, IsGranted
|
|
110
|
+
|
|
111
|
+
api = Firewall("api")
|
|
112
|
+
app = FastAPI(dependencies=[Firewall()])
|
|
113
|
+
router = api(APIRouter(prefix="/api"))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@router.get("/me")
|
|
117
|
+
async def me(user: Annotated[UserInterface, CurrentUser()]) -> dict[str, object]:
|
|
118
|
+
return {"identifier": user.get_user_identifier(), "roles": list(user.get_roles())}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.delete("/books/{isbn}")
|
|
122
|
+
@IsGranted("ROLE_ADMIN")
|
|
123
|
+
async def delete_book(isbn: str) -> None: ...
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- `Firewall()` runs the firewall the configuration matches; `Firewall("api")` runs that one and
|
|
127
|
+
shows its exact scheme in the generated schema. Authentication runs once per request however
|
|
128
|
+
many firewall dependencies a route carries.
|
|
129
|
+
- `CurrentUser()` raises on an anonymous request, answered `401`. `optional=True` yields `None`
|
|
130
|
+
instead, for a parameter typed `UserInterface | None`; `CurrentUser(MyUser)` requires a class.
|
|
131
|
+
- `IsGranted` takes a `subject` in `dependencies=[...]`: a path-parameter name, or a callable
|
|
132
|
+
shared with the endpoint through `Depends`. A denial is `403` for a fully authenticated caller
|
|
133
|
+
and `401` for an anonymous one, or `status_code=` answered directly.
|
|
134
|
+
|
|
135
|
+
## Decide in a service
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from xtr_security import Security
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class PublishBook:
|
|
142
|
+
def __init__(self, security: Security) -> None:
|
|
143
|
+
self._security = security
|
|
144
|
+
|
|
145
|
+
async def publish(self, book: Book) -> None:
|
|
146
|
+
await self._security.deny_access_unless_granted("BOOK_PUBLISH", book)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`Security` answers `get_token()`, `get_user()`, `is_granted(attribute, subject=None)`,
|
|
150
|
+
`is_granted_for_user(user, attribute, subject=None)` and `deny_access_unless_granted(...)`, and is
|
|
151
|
+
scoped to the request — a singleton cannot depend on it, so take it as a method argument or make
|
|
152
|
+
the service scoped too.
|
|
153
|
+
|
|
154
|
+
## Write a voter
|
|
155
|
+
|
|
156
|
+
A class implementing `VoterInterface` is gathered into the decision manager by the bundle's
|
|
157
|
+
`security.voter` tag. Extend `Voter`, register it as a service, and that is the whole wiring.
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
from typing_extensions import override
|
|
161
|
+
from xtr_dependency_injection import as_service
|
|
162
|
+
from xtr_security_core import TokenInterface, Vote, Voter
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@as_service
|
|
166
|
+
class OrderViewVoter(Voter):
|
|
167
|
+
@override
|
|
168
|
+
def supports(self, attribute: object, subject: object) -> bool:
|
|
169
|
+
return attribute == "ORDER_VIEW" and isinstance(subject, Order)
|
|
170
|
+
|
|
171
|
+
@override
|
|
172
|
+
async def vote_on_attribute(
|
|
173
|
+
self, attribute: object, subject: object, token: TokenInterface, vote: Vote | None
|
|
174
|
+
) -> bool:
|
|
175
|
+
owner = subject.email == token.get_user_identifier()
|
|
176
|
+
if vote is not None and not owner:
|
|
177
|
+
vote.add_reason("the order belongs to another customer")
|
|
178
|
+
return owner
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Keep `supports` narrow: a voter that speaks about everything drowns out the others under the
|
|
182
|
+
affirmative strategy. A one-off rule needs no class — an `IsGranted` attribute may be a callable
|
|
183
|
+
`(IsGrantedContext, subject) -> bool`, run by the built-in closure voter.
|
|
184
|
+
|
|
185
|
+
Strategies, the built-in voters, scopes and the hierarchy's wildcards:
|
|
186
|
+
[references/authorization.md](references/authorization.md).
|
|
187
|
+
|
|
188
|
+
## Hash and verify a password
|
|
189
|
+
|
|
190
|
+
Inject `UserPasswordHasherInterface`; it reads the stored hash off the user itself.
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
# in an async service, hashing is CPU-bound: never run it on the event loop
|
|
194
|
+
user.password = await anyio.to_thread.run_sync(hasher.hash_password, user, plain_password)
|
|
195
|
+
ok = await anyio.to_thread.run_sync(hasher.is_password_valid, user, attempt)
|
|
196
|
+
stale = hasher.needs_rehash(user) # re-store right after a successful check
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`password_hashers` maps each user class to its hasher; `AutoHasherConfig()` is argon2id with
|
|
200
|
+
bcrypt and PBKDF2 verifying behind it, so a hash from an older algorithm still verifies and
|
|
201
|
+
`needs_rehash` says when to replace it. Costs, migrations and the command:
|
|
202
|
+
[references/password-hashing.md](references/password-hashing.md).
|
|
203
|
+
|
|
204
|
+
## Testing
|
|
205
|
+
|
|
206
|
+
Voters, hashers and the decision are plain objects — test them with no kernel.
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
import pytest
|
|
210
|
+
from xtr_security_core import Access, InMemoryUser, UsernamePasswordToken
|
|
211
|
+
|
|
212
|
+
pytestmark = pytest.mark.anyio
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def test_a_voter_refuses_another_customers_order() -> None:
|
|
216
|
+
ada = InMemoryUser("ada@example.com", roles=["ROLE_USER"])
|
|
217
|
+
token = UsernamePasswordToken(ada, "api", roles=ada.get_roles())
|
|
218
|
+
order = Order("lin@example.com")
|
|
219
|
+
|
|
220
|
+
assert await OrderViewVoter().vote(token, order, ["ORDER_VIEW"]) is Access.DENIED
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
A whole decision needs no kernel either: an `AccessDecisionManager(voters=[...],
|
|
224
|
+
strategy=AffirmativeStrategy())` over a `TokenStorage`, asked through
|
|
225
|
+
`AuthorizationChecker(storage, manager)`. The wired services — the token storage, the authorization checker, `Security`, the firewall map —
|
|
226
|
+
are **scoped**, so reach them inside a unit of work:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
from xtr_dependency_injection import unit_of_work
|
|
230
|
+
from xtr_dependency_injection.testing import boot_for_test
|
|
231
|
+
|
|
232
|
+
async with await boot_for_test(kernel) as booted, unit_of_work(booted.container) as unit:
|
|
233
|
+
security = await unit.get(Security)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
End to end, sign a request the way a client does — call your own token route, send the credential
|
|
237
|
+
back — rather than writing a token into storage by hand.
|
|
238
|
+
|
|
239
|
+
## Use in an application
|
|
240
|
+
|
|
241
|
+
`uv run xtr-recipes recipes:sync` applies the recipe shipped with this package: it lists
|
|
242
|
+
`SecurityBundle`, which brings the event dispatcher and http-kernel bundles with it. That is the
|
|
243
|
+
steps below a recipe can do; the `concurrent_scoped_access`, `setup(app, kernel)` and
|
|
244
|
+
`config/security.py` steps it prints for you to make.
|
|
245
|
+
|
|
246
|
+
1. **Install** — `uv add xtr-security`; `[console]` adds `debug:firewall` and
|
|
247
|
+
`security:hash-password`, `"xtr-security-http[oidc]"` adds OIDC token verification.
|
|
248
|
+
2. **Activate** — `SecurityBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported
|
|
249
|
+
from `xtr_security.bundle`. Call `setup(app, kernel)` where the application is built, and
|
|
250
|
+
build the kernel with `concurrent_scoped_access=True` so concurrent requests keep their own
|
|
251
|
+
token storage.
|
|
252
|
+
3. **Brings along** — the event-dispatcher and http-kernel bundles always; the logging and
|
|
253
|
+
console bundles whenever those packages are installed.
|
|
254
|
+
4. **Configure** — optional: unconfigured there are no firewalls and the affirmative strategy.
|
|
255
|
+
`<app>/config/security.py`, a `@configure` function returning a `SecurityConfig`, changes it.
|
|
256
|
+
5. **Environment** — nothing of its own; read your secrets with `env(...)` in the configuration.
|
|
257
|
+
6. **Ignore** — nothing.
|
|
258
|
+
7. **Check** — `debug:bundles` shows `security` as `listed` and `active`, `event_dispatcher` and
|
|
259
|
+
`http_kernel` as `required`; `debug:firewall [name]` lists or describes the firewalls.
|
|
260
|
+
8. **Remove** — drop the `BUNDLES` entry, delete `<app>/config/security.py`, then
|
|
261
|
+
`uv remove xtr-security`.
|
|
262
|
+
|
|
263
|
+
## Errors
|
|
264
|
+
|
|
265
|
+
Everything the family raises derives from `SecurityError` (`xtr_security_core.exception`).
|
|
266
|
+
|
|
267
|
+
| Error | Raised when |
|
|
268
|
+
| --- | --- |
|
|
269
|
+
| `InvalidConfigurationError` | A configuration the bundle cannot turn into services — an unknown provider, a non-stateless firewall, an authenticator with no factory. Also a `ValueError` |
|
|
270
|
+
| `AuthenticationError` | Authentication failed, answered `401`. The base of `BadCredentialsError` (rejected), `AuthenticationCredentialsNotFoundError` (none presented — what `CurrentUser()` raises when anonymous), `InvalidAccessTokenError` (a bearer token present but untrusted), `UserNotFoundError`, and the account-state errors `DisabledError`, `LockedError`, `AccountExpiredError` |
|
|
271
|
+
| `AccessDeniedError` | Authorization refused; carries `attributes`, `subject` and `access_decision` |
|
|
272
|
+
| `UnknownFirewallError` | A firewall was asked for by a name none is registered under |
|
|
273
|
+
| `InvalidPasswordError` | A password encodes to more than `MAX_PASSWORD_LENGTH` (4096) UTF-8 bytes |
|
|
274
|
+
|
|
275
|
+
Catch them where you answer them; the firewall's exception listener already turns an uncaught one
|
|
276
|
+
into the right status and challenge.
|
|
277
|
+
|
|
278
|
+
## Do not
|
|
279
|
+
|
|
280
|
+
- Do not read the `Authorization` header, or parse a credential, in an endpoint. That is the
|
|
281
|
+
firewall's job: write a token handler or an authenticator instead.
|
|
282
|
+
- Do not build `TokenStorage`, `AuthorizationChecker`, `AccessDecisionManager` or
|
|
283
|
+
`PasswordHasherFactory` by hand — the bundle registers each; inject it.
|
|
284
|
+
- Do not call a hasher on the event loop; wrap it in `anyio.to_thread.run_sync`.
|
|
285
|
+
- Do not store a plaintext password, and do not use `PlaintextHasherConfig()` outside a test.
|
|
286
|
+
- Do not depend on a scoped security service from a singleton; take it as an argument.
|
|
287
|
+
- Do not write a voter whose `supports` is broadly `True`: under the affirmative strategy one
|
|
288
|
+
careless grant decides everything.
|
|
289
|
+
- Do not set `security=False` on a firewall to make a `401` go away: that opens every request it
|
|
290
|
+
claims. Give the open routes a `PUBLIC_ACCESS` access-control rule instead.
|
|
291
|
+
- Do not trust a claim from client input when minting a user; read the roles from your own store.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Authorization: voters, strategies, roles and scopes
|
|
2
|
+
|
|
3
|
+
An attribute is asked over an optional subject. Every voter grants, denies or abstains; a strategy
|
|
4
|
+
folds the answers into one decision. `Security` (or `AuthorizationChecker`) is the one call the
|
|
5
|
+
rest of the application makes.
|
|
6
|
+
|
|
7
|
+
## The decision, with no container
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from xtr_security_core import (
|
|
11
|
+
AccessDecisionManager,
|
|
12
|
+
AffirmativeStrategy,
|
|
13
|
+
AuthenticatedVoter,
|
|
14
|
+
AuthenticationTrustResolver,
|
|
15
|
+
AuthorizationChecker,
|
|
16
|
+
InMemoryUser,
|
|
17
|
+
RoleHierarchy,
|
|
18
|
+
RoleHierarchyVoter,
|
|
19
|
+
TokenStorage,
|
|
20
|
+
UsernamePasswordToken,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
hierarchy = RoleHierarchy({"ROLE_ADMIN": ("ROLE_USER",)})
|
|
24
|
+
manager = AccessDecisionManager(
|
|
25
|
+
voters=[
|
|
26
|
+
RoleHierarchyVoter(hierarchy),
|
|
27
|
+
AuthenticatedVoter(AuthenticationTrustResolver()),
|
|
28
|
+
OrderViewVoter(),
|
|
29
|
+
],
|
|
30
|
+
strategy=AffirmativeStrategy(),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
ada = InMemoryUser("ada@example.com", roles=["ROLE_USER"])
|
|
34
|
+
storage = TokenStorage()
|
|
35
|
+
storage.set_token(UsernamePasswordToken(ada, "api", roles=ada.get_roles()))
|
|
36
|
+
checker = AuthorizationChecker(storage, manager)
|
|
37
|
+
|
|
38
|
+
await checker.is_granted("ROLE_USER") # True
|
|
39
|
+
await checker.is_granted("ORDER_VIEW", Order("lin@example.com")) # False
|
|
40
|
+
await checker.is_granted("IS_AUTHENTICATED") # True
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
In an application the bundle builds exactly this from the configuration, and gathers every voter
|
|
44
|
+
you registered. Build it by hand only in a test.
|
|
45
|
+
|
|
46
|
+
## Strategies
|
|
47
|
+
|
|
48
|
+
`AccessDecisionManagerConfig(strategy=…, allow_if_all_abstain=False, allow_if_equal_granted_denied=True)`.
|
|
49
|
+
|
|
50
|
+
| `strategy` | Grants when |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `"affirmative"` (default) | any voter grants |
|
|
53
|
+
| `"consensus"` | grants outnumber denials; a tie follows `allow_if_equal_granted_denied` |
|
|
54
|
+
| `"unanimous"` | no voter denies |
|
|
55
|
+
| `"priority"` | the first voter that does not abstain grants |
|
|
56
|
+
|
|
57
|
+
A decision every voter abstained on is refused unless `allow_if_all_abstain=True`. Under the
|
|
58
|
+
affirmative default, one voter that grants too eagerly decides everything — which is why a
|
|
59
|
+
voter's `supports` must be narrow.
|
|
60
|
+
|
|
61
|
+
## The built-in voters
|
|
62
|
+
|
|
63
|
+
| Voter | Grants on |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `RoleHierarchyVoter(hierarchy)` | a role the token's roles *reach* through the hierarchy |
|
|
66
|
+
| `RoleVoter()` | a role the token holds outright |
|
|
67
|
+
| `AuthenticatedVoter(trust_resolver)` | `IS_AUTHENTICATED`, `IS_AUTHENTICATED_FULLY`, `PUBLIC_ACCESS` |
|
|
68
|
+
| `OAuth2ScopeVoter()` | an `OAUTH2_SCOPE(...)` attribute every scope of which the token holds |
|
|
69
|
+
| `ClosureVoter()` | a callable attribute `(IsGrantedContext, subject) -> bool` returning `True` |
|
|
70
|
+
|
|
71
|
+
The bundle registers all five and adds yours. `PUBLIC_ACCESS` is how an access-control rule says
|
|
72
|
+
"open": it grants whether or not anybody is authenticated.
|
|
73
|
+
|
|
74
|
+
## Your voter
|
|
75
|
+
|
|
76
|
+
Extend `Voter` and answer two methods; `@as_service` is all the wiring.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
def supports(self, attribute: object, subject: object) -> bool: ...
|
|
80
|
+
async def vote_on_attribute(
|
|
81
|
+
self, attribute: object, subject: object, token: TokenInterface, vote: Vote | None
|
|
82
|
+
) -> bool: ...
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- `supports` is asked per attribute and subject. Return `False` for anything you do not own; the
|
|
86
|
+
voter then abstains and the others decide.
|
|
87
|
+
- `vote` is the record of this voter's answer; `vote.add_reason("...")` explains a refusal and
|
|
88
|
+
shows up in the `AccessDecision` a handler can read.
|
|
89
|
+
- A voter is a service like any other: inject a repository, a clock, a logger.
|
|
90
|
+
- `CacheableVoterInterface` additionally declares `supports_attribute` / `supports_type` so the
|
|
91
|
+
manager can skip a voter that could never speak.
|
|
92
|
+
|
|
93
|
+
A one-off rule needs no class at all:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from xtr_security_core import IsGrantedContext
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def can_publish(context: IsGrantedContext, subject: object) -> bool:
|
|
100
|
+
return await context.is_granted("ROLE_ADMIN")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@router.post("/books/{isbn}/publish")
|
|
104
|
+
@IsGranted(can_publish)
|
|
105
|
+
async def publish(isbn: str) -> None: ...
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`IsGrantedContext` carries the `token`, its `user`, and `is_granted(...)` for a nested question.
|
|
109
|
+
|
|
110
|
+
## The role hierarchy
|
|
111
|
+
|
|
112
|
+
`role_hierarchy={"ROLE_ADMIN": ("ROLE_USER",)}` makes an admin reach the user role, so a token
|
|
113
|
+
needs only the role it really has. Expansion is transitive and safe against cycles. A `*` in a key
|
|
114
|
+
captures a segment and substitutes it into the values, so
|
|
115
|
+
`{"ROLE_TENANT_*_ADMIN": ("ROLE_TENANT_*_USER",)}` makes `ROLE_TENANT_42_ADMIN` reach
|
|
116
|
+
`ROLE_TENANT_42_USER`.
|
|
117
|
+
|
|
118
|
+
Standalone, `RoleHierarchy(mapping).get_reachable_role_names(roles)` is the same expansion and
|
|
119
|
+
`get_parent_role_names(role)` is one level of it.
|
|
120
|
+
|
|
121
|
+
## OAuth2 scopes
|
|
122
|
+
|
|
123
|
+
A scope requirement is an attribute like any other. `oauth2_scope("books:read", "books:write")`
|
|
124
|
+
builds `"OAUTH2_SCOPE(books:read books:write)"`, which goes in an `AccessControlConfig` or an
|
|
125
|
+
`IsGranted`; `parse_oauth2_scope(attribute)` — from `xtr_security_http.authorization` — reads the
|
|
126
|
+
scopes back, or `None` for anything not shaped like one.
|
|
127
|
+
|
|
128
|
+
On a route, prefer the firewall's own variant, which also adds the scopes to the generated schema:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
api = Firewall("api")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@router.get("/books", dependencies=[api.scoped("books:read")])
|
|
135
|
+
async def books() -> list[str]: ...
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`api.scoped(...)` shares the one scheme with `api`, so the two are a single security scheme in the
|
|
139
|
+
schema. An access-token authenticator copies the badge's granted `scope` onto the token, and
|
|
140
|
+
`OAuth2ScopeVoter` reads it; a refused scope is answered `403` with an RFC 6750
|
|
141
|
+
`insufficient_scope` challenge.
|
|
142
|
+
|
|
143
|
+
## Reading a decision
|
|
144
|
+
|
|
145
|
+
`is_granted(attribute, subject=None, access_decision=None)` fills an `AccessDecision` when given
|
|
146
|
+
one: every `Vote` cast, each with its `voter`, `result` and `reasons`, the deciding strategy's
|
|
147
|
+
name and a `message`. `deny_access_unless_granted(...)` attaches one to the `AccessDeniedError` it
|
|
148
|
+
raises, which is how a handler can tell a caller *why*. With `trace_votes` on (the default in
|
|
149
|
+
debug) each vote is also announced as a `VoteEvent` a listener can log.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Firewalls, providers and authenticators
|
|
2
|
+
|
|
3
|
+
Every field an application writes, and the pieces it writes itself. Import the configurations
|
|
4
|
+
from `xtr_security.bundle`.
|
|
5
|
+
|
|
6
|
+
## `SecurityConfig`
|
|
7
|
+
|
|
8
|
+
| Field | Default | What it is |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| `firewalls` | `{}` | The firewalls, keyed by name, matched in order — first match wins |
|
|
11
|
+
| `providers` | `{}` | The user providers, keyed by the name a firewall's `provider` refers to |
|
|
12
|
+
| `password_hashers` | `{}` | The hasher each user class, `"module:Class"` string or declared name is hashed by |
|
|
13
|
+
| `role_hierarchy` | `{}` | The roles each role reaches, expanding a token's roles |
|
|
14
|
+
| `access_control` | `()` | The access-control rules, tried in order |
|
|
15
|
+
| `access_decision_manager` | affirmative | `AccessDecisionManagerConfig` — how the votes become one decision |
|
|
16
|
+
| `expose_security_errors` | `ExposeSecurityLevel.NONE` | How much of an authentication failure reaches the client |
|
|
17
|
+
| `trace_votes` | `None` | Announce every vote as an event; `None` follows the kernel's debug flag |
|
|
18
|
+
| `authenticator_factories` | built-ins | The factories that build firewalls' authenticators |
|
|
19
|
+
| `token_handler_factories` | built-ins | The factories that build access-token handlers |
|
|
20
|
+
| `user_provider_factories` | built-ins | The factories that build user providers |
|
|
21
|
+
|
|
22
|
+
A firewall naming a `provider` that is not declared fails the build with
|
|
23
|
+
`InvalidConfigurationError`, as the configuration is written.
|
|
24
|
+
|
|
25
|
+
## `FirewallConfig`
|
|
26
|
+
|
|
27
|
+
| Field | Default | What it is |
|
|
28
|
+
| --- | --- | --- |
|
|
29
|
+
| `pattern` | `None` | A regular expression matched against the request path |
|
|
30
|
+
| `host` | `None` | A regular expression matched against the request host |
|
|
31
|
+
| `methods` | `()` | The HTTP methods claimed; every method when empty |
|
|
32
|
+
| `request_matcher` | `None` | A callable `(Request) -> bool`, tried last |
|
|
33
|
+
| `security` | `True` | `False` lets every request the firewall claims through untouched |
|
|
34
|
+
| `stateless` | `True` | Only `True` is accepted; `False` raises `InvalidConfigurationError` |
|
|
35
|
+
| `provider` | `None` | The name of the user provider the firewall loads users from |
|
|
36
|
+
| `user_checker` | `None` | A user-checker service type, or the default in-memory checker |
|
|
37
|
+
| `entry_point` | `None` | The authenticator whose challenge answers an unauthenticated request |
|
|
38
|
+
| `access_denied_handler` | `None` | A handler service type for a denied, fully-authenticated caller |
|
|
39
|
+
| `authenticators` | `()` | The authenticator configurations the firewall runs |
|
|
40
|
+
| `required_badges` | `()` | Badge types every passport must carry |
|
|
41
|
+
|
|
42
|
+
A secured firewall with no authenticators is refused. A firewall with no `pattern`, `host`,
|
|
43
|
+
`methods` or `request_matcher` claims every request, so register it last.
|
|
44
|
+
|
|
45
|
+
## `AccessControlConfig`
|
|
46
|
+
|
|
47
|
+
`AccessControlConfig(path, attribute, host=None, methods=(), ips=(), firewall=None)`. `path` and
|
|
48
|
+
`host` are regular expressions, searched against the request. One attribute per rule, by design:
|
|
49
|
+
a rule needing two conditions is two rules, or one closure attribute.
|
|
50
|
+
|
|
51
|
+
## User providers
|
|
52
|
+
|
|
53
|
+
| Configuration | Loads a user from |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| `InMemoryUserProviderConfig(users={identifier: {"password": …, "roles": (…), "enabled": True}})` | the table written inline |
|
|
56
|
+
| `ChainUserProviderConfig(providers=("db", "ldap"))` | each named provider in turn; needs at least one |
|
|
57
|
+
| `ServiceUserProviderConfig(service=MyUserProvider, qualifier=None)` | a provider the container provides |
|
|
58
|
+
|
|
59
|
+
Write your own by implementing `UserProviderInterface` from `xtr_security_core`:
|
|
60
|
+
`load_user_by_identifier(identifier)` (raising `UserNotFoundError`) and
|
|
61
|
+
`supports_class(user_class)`. Register it `@as_service` and name it with
|
|
62
|
+
`ServiceUserProviderConfig`. Implement `PasswordUpgraderInterface` as well and the password
|
|
63
|
+
migrating listener re-stores a rehashed password for you after a successful sign-in.
|
|
64
|
+
|
|
65
|
+
A configured provider is also injectable by name:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from typing import Annotated
|
|
69
|
+
|
|
70
|
+
from xtr_dependency_injection import Target
|
|
71
|
+
from xtr_security_core import InMemoryUserProvider
|
|
72
|
+
|
|
73
|
+
users: Annotated[InMemoryUserProvider, Target("users")]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Access-token authenticators
|
|
77
|
+
|
|
78
|
+
`AccessTokenConfig(token_handler, token_extractors=("header",), realm=None)`. Extractors are
|
|
79
|
+
`"header"`, `"query"` and `"body"`, tried in order; the first one's scheme documents the firewall
|
|
80
|
+
in the generated schema. `realm` is named in the `WWW-Authenticate` challenge.
|
|
81
|
+
|
|
82
|
+
| Token handler | Verifies |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `ServiceTokenHandlerConfig(service=MyTokenHandler, qualifier=None)` | with a handler you registered as a service |
|
|
85
|
+
| `OidcTokenHandlerConfig(issuers=(…), audience="…", keyset=… \| discovery_uri=… \| jwks_uri=…)` | a third-party OIDC issuer's tokens; needs the `oidc` extra |
|
|
86
|
+
|
|
87
|
+
`OidcTokenHandlerConfig` takes **exactly one** key source, and at least one issuer and an
|
|
88
|
+
audience; `algorithms` default to `("RS256",)`, `claim` to `"sub"`, plus `leeway`,
|
|
89
|
+
`enforce_at_jwt_type` and `allow_insecure_http`. `exp` is required on a token: one that never
|
|
90
|
+
expires is refused.
|
|
91
|
+
|
|
92
|
+
## The token handler you write
|
|
93
|
+
|
|
94
|
+
A handler turns a token string into a `UserBadge` — the identifier, and how to load the user.
|
|
95
|
+
Raise `InvalidAccessTokenError` for anything it cannot trust; that becomes an `invalid_token`
|
|
96
|
+
challenge.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from xtr_dependency_injection import as_service
|
|
100
|
+
from xtr_security_core import InMemoryUser
|
|
101
|
+
from xtr_security_http import AccessTokenHandlerInterface, InvalidAccessTokenError, UserBadge
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@as_service
|
|
105
|
+
class MyTokenHandler(AccessTokenHandlerInterface):
|
|
106
|
+
async def get_user_badge_from(self, access_token: str) -> UserBadge:
|
|
107
|
+
if access_token != "s3cret":
|
|
108
|
+
raise InvalidAccessTokenError("Unknown token.")
|
|
109
|
+
return UserBadge("ada", user_loader=lambda i: InMemoryUser(i, roles=["ROLE_USER"]))
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Leave the `user_loader` out and the firewall's own `provider` loads the user, so the token carries
|
|
113
|
+
identity and your store carries the roles — usually what you want.
|
|
114
|
+
|
|
115
|
+
## What runs on a request
|
|
116
|
+
|
|
117
|
+
1. The firewall map matches the request, or the named firewall is taken.
|
|
118
|
+
2. The first authenticator whose `supports()` is not `False` reads the request into a `Passport`
|
|
119
|
+
of badges and credentials.
|
|
120
|
+
3. Listeners resolve the badges: the user provider gives a `UserBadge` its loader, credentials are
|
|
121
|
+
verified, the account's pre- and post-authentication checks run.
|
|
122
|
+
4. Every badge must be resolved and every `required_badges` type present, or
|
|
123
|
+
`BadCredentialsError`.
|
|
124
|
+
5. A token is created and stored; the access map's rule for the request is decided against it.
|
|
125
|
+
|
|
126
|
+
An unknown user in a password passport is still verified against a dummy hash, so a bad
|
|
127
|
+
identifier and a bad password take the same time.
|
|
128
|
+
|
|
129
|
+
`expose_security_errors` controls what a client learns: `ExposeSecurityLevel.NONE` reports
|
|
130
|
+
everything as bad credentials, `ACCOUNT_STATUS` reveals disabled, locked and expired accounts,
|
|
131
|
+
`ALL` reveals the failure. Leave it at `NONE` in production.
|
|
132
|
+
|
|
133
|
+
## Listening to a firewall's events
|
|
134
|
+
|
|
135
|
+
Each firewall dispatches `CheckPassportEvent`, `AuthenticationTokenCreatedEvent`,
|
|
136
|
+
`AuthenticationSuccessEvent`, `LoginSuccessEvent` and `LoginFailureEvent` on a dispatcher of its
|
|
137
|
+
own. A listener of them on the main dispatcher hears every firewall; to hear one alone, name its
|
|
138
|
+
dispatcher:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from xtr_event_dispatcher import as_event_listener
|
|
142
|
+
from xtr_security.bundle import firewall_dispatcher_name
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@as_event_listener(dispatcher=firewall_dispatcher_name("api"))
|
|
146
|
+
def count_api_login(event: LoginSuccessEvent) -> None: ...
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
In debug mode `debug:event-dispatcher --dispatcher security.event_dispatcher.api` lists that
|
|
150
|
+
firewall's listeners.
|
|
151
|
+
|
|
152
|
+
## Challenges
|
|
153
|
+
|
|
154
|
+
The exception listener answers for you: an `AuthenticationError` becomes the firewall's
|
|
155
|
+
entry-point challenge, or a bare `401` with `WWW-Authenticate: Bearer`; an `AccessDeniedError`
|
|
156
|
+
becomes `403`, or the entry-point challenge when the caller is not fully authenticated, or an
|
|
157
|
+
`insufficient_scope` challenge when a scope was the denied attribute.
|
|
158
|
+
|
|
159
|
+
## Adding a kind of authenticator
|
|
160
|
+
|
|
161
|
+
Only a library shipping its own bundle needs this. Implement `AuthenticatorFactoryInterface`
|
|
162
|
+
(a `key`, a `priority`, a `config_type` and `create_authenticator(...)`) and prepend it onto the
|
|
163
|
+
security configuration from the bundle's `prepend_extension` hook with `add_authenticator_factory`.
|
|
164
|
+
`add_token_handler_factory` and `add_user_provider_factory` are the same seam for the other two
|
|
165
|
+
registries. An application configures what those factories accept; it does not write factories.
|