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.
Files changed (52) hide show
  1. xtr_security/.agents/skills/xtr-security/SKILL.md +291 -0
  2. xtr_security/.agents/skills/xtr-security/references/authorization.md +149 -0
  3. xtr_security/.agents/skills/xtr-security/references/firewalls-and-authenticators.md +165 -0
  4. xtr_security/.agents/skills/xtr-security/references/password-hashing.md +122 -0
  5. xtr_security/__init__.py +13 -0
  6. xtr_security/access_token/__init__.py +19 -0
  7. xtr_security/access_token/add_token_handler_factory.py +35 -0
  8. xtr_security/access_token/oidc_token_handler_factory.py +114 -0
  9. xtr_security/access_token/service_token_handler_factory.py +48 -0
  10. xtr_security/access_token/token_handler_factory_interface.py +52 -0
  11. xtr_security/bundle/__init__.py +97 -0
  12. xtr_security/bundle/_firewall_scheme_middleware.py +38 -0
  13. xtr_security/bundle/_firewall_scheme_middleware_factory.py +33 -0
  14. xtr_security/bundle/_password_hasher_build.py +83 -0
  15. xtr_security/bundle/_wiring.py +736 -0
  16. xtr_security/bundle/access_control_configs.py +83 -0
  17. xtr_security/bundle/access_decision_manager_config.py +66 -0
  18. xtr_security/bundle/authenticator_configs.py +56 -0
  19. xtr_security/bundle/firewall_dispatcher_name.py +19 -0
  20. xtr_security/bundle/make_firewalls_event_dispatcher_traceable_pass.py +53 -0
  21. xtr_security/bundle/password_hasher_configs.py +166 -0
  22. xtr_security/bundle/register_global_security_event_listeners_pass.py +69 -0
  23. xtr_security/bundle/security_bundle.py +305 -0
  24. xtr_security/bundle/security_config.py +155 -0
  25. xtr_security/bundle/token_handler_configs.py +120 -0
  26. xtr_security/bundle/user_provider_configs.py +98 -0
  27. xtr_security/command/__init__.py +15 -0
  28. xtr_security/command/debug_firewall_command.py +76 -0
  29. xtr_security/exception/__init__.py +12 -0
  30. xtr_security/exception/invalid_configuration_error.py +20 -0
  31. xtr_security/factory/__init__.py +17 -0
  32. xtr_security/factory/access_token_factory.py +220 -0
  33. xtr_security/factory/add_authenticator_factory.py +36 -0
  34. xtr_security/factory/authenticator_factory_interface.py +68 -0
  35. xtr_security/firewall_config.py +107 -0
  36. xtr_security/firewall_context.py +68 -0
  37. xtr_security/firewall_map.py +93 -0
  38. xtr_security/py.typed +0 -0
  39. xtr_security/recipe/__init__.py +16 -0
  40. xtr_security/recipe/manifest.toml +30 -0
  41. xtr_security/security.py +108 -0
  42. xtr_security/user_provider/__init__.py +21 -0
  43. xtr_security/user_provider/add_user_provider_factory.py +35 -0
  44. xtr_security/user_provider/chain_factory.py +82 -0
  45. xtr_security/user_provider/in_memory_factory.py +68 -0
  46. xtr_security/user_provider/service_factory.py +48 -0
  47. xtr_security/user_provider/user_provider_factory_interface.py +51 -0
  48. xtr_security-3.0.0.dist-info/METADATA +398 -0
  49. xtr_security-3.0.0.dist-info/RECORD +52 -0
  50. xtr_security-3.0.0.dist-info/WHEEL +4 -0
  51. xtr_security-3.0.0.dist-info/entry_points.txt +6 -0
  52. 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.