Why?
Two questions sit under every protected feature: who is this? and may they do this? The first settles on a token — a user and the roles fixed on them for this unit of work. The second asks a set of voters and folds their answers into one yes or no with a strategy. Keeping the two apart, and keeping the decision a small pluggable thing, is what lets a web edge, a console command and a background job all reach the same answer the same way.
This package is that core, with no web framework and no container in sight:
- 🪪 Users and providers — a
UserInterfaceis an identifier and a set of roles; a provider loads one by identifier, from memory or from anywhere an application writes. - 🎫 Tokens and storage — a token carries the authenticated user and their roles; a per-unit-of-work storage holds the current one.
- 🗳️ Voters, strategies and one decision — each voter grants, denies or abstains on an
attribute; a strategy turns the votes into a decision, and the
AuthorizationCheckeris the one call the rest of the application makes. - 🪜 A role hierarchy — one role reaches others, wildcards included, so
ROLE_ADMINneed not list every role it implies.
The HTTP edge — firewalls, authenticators, bearer tokens — is a separate package, xtr-security-http; the OAuth2 scope voter lives there, since scopes are a request concern.
Install
uv add xtr-security-core
Requires Python 3.11+. One sibling comes with it, xtr-password-hasher, for the password-authenticated user interface.
Quick start
Everything below runs without a container: build a user provider, a decision manager over a few
voters, and ask the checker. The roles on the token are expanded through the hierarchy, and a
voter you write joins the others by implementing Voter:
from __future__ import annotations
import asyncio
from xtr_security_core import (
AccessDecisionManager,
AffirmativeStrategy,
AuthenticatedVoter,
AuthenticationTrustResolver,
AuthorizationChecker,
ClosureVoter,
InMemoryUser,
InMemoryUserProvider,
IsGrantedContext,
RoleHierarchy,
RoleHierarchyVoter,
TokenStorage,
UsernamePasswordToken,
Vote,
Voter,
)
from xtr_security_core.authentication.token.token_interface import TokenInterface
class Document:
def __init__(self, owner: str) -> None:
self.owner = owner
class OwnsDocumentVoter(Voter):
def supports(self, attribute: object, subject: object) -> bool:
return attribute == "EDIT" and isinstance(subject, Document)
async def vote_on_attribute(
self,
attribute: object,
subject: object,
token: TokenInterface,
vote: Vote | None,
) -> bool:
assert isinstance(subject, Document)
return subject.owner == token.get_user_identifier()
async def main() -> None:
provider = InMemoryUserProvider(
{
"ada": InMemoryUser("ada", roles=["ROLE_ADMIN"]),
"lin": InMemoryUser("lin", roles=["ROLE_USER"]),
}
)
ada = await provider.load_user_by_identifier("ada")
hierarchy = RoleHierarchy({"ROLE_ADMIN": ["ROLE_USER"]})
manager = AccessDecisionManager(
voters=[
RoleHierarchyVoter(hierarchy),
AuthenticatedVoter(AuthenticationTrustResolver()),
OwnsDocumentVoter(),
ClosureVoter(),
],
strategy=AffirmativeStrategy(),
)
storage = TokenStorage()
storage.set_token(UsernamePasswordToken(ada, "main", roles=ada.get_roles()))
checker = AuthorizationChecker(storage, manager)
print("ROLE_USER (via hierarchy):", await checker.is_granted("ROLE_USER"))
print("IS_AUTHENTICATED:", await checker.is_granted("IS_AUTHENTICATED"))
print("EDIT own doc:", await checker.is_granted("EDIT", Document(owner="ada")))
print("EDIT other's doc:", await checker.is_granted("EDIT", Document(owner="lin")))
async def can_publish(context: IsGrantedContext, subject: object) -> bool:
return await context.is_granted("ROLE_ADMIN")
print("closure can_publish:", await checker.is_granted(can_publish))
asyncio.run(main())
ROLE_USER (via hierarchy): True
IS_AUTHENTICATED: True
EDIT own doc: True
EDIT other's doc: False
closure can_publish: True
Concepts
Users, providers and checkers
A UserInterface is the smallest useful account: get_user_identifier() names it, and
get_roles() returns the roles it carries. InMemoryUser(identifier, password=None, roles=(), enabled=True) is one for tests and small deployments; it also answers get_password() (it is a
password-authenticated user), is_enabled() and is_equal_to(other).
OidcUser(claims, identifier_claim="sub", roles=("ROLE_USER",)) is a user built from a verified
token's own claims, for a deployment that keeps no user store.
A UserProviderInterface loads a user by identifier — load_user_by_identifier(identifier),
raising UserNotFoundError when none matches — and supports_class(user_class) tells which
class it loads. InMemoryUserProvider holds a map of identifier to user; ChainUserProvider
tries several in order and forwards a upgrade_password to whichever can.
AttributesBasedUserProviderInterface adds an attributes mapping to the load, for a provider
that reads a verified token's claims. PasswordUpgraderInterface is the one method a provider
implements to store a freshly rehashed password, and EquatableInterface.is_equal_to lets two
users be compared field for field.
A UserCheckerInterface gates an account around authentication: check_pre_auth(user)
before the credentials are verified, check_post_auth(user, token) after. InMemoryUserChecker
refuses a disabled account with DisabledError; ChainUserChecker runs several in order.
Tokens and storage
A TokenInterface carries the authenticated user (get_user(), get_user_identifier()),
the roles fixed on it (get_role_names()), and a bag of attributes (get_attribute,
set_attribute, has_attribute, get_attributes). AbstractToken is the base;
NullToken is the anonymous caller, with no user and no roles; UsernamePasswordToken(user, firewall_name, roles=()) adds the firewall (or unit of work) that authenticated the user,
read back with get_firewall_name().
TokenStorage holds the current token for one unit of work — get_token() /
set_token(token) — and reset() forgets it so the next one starts clean. The
AuthenticationTrustResolver reads a token's standing: is_authenticated(token) tells
whether anyone is behind it, is_full_fledged(token) whether it was fully authenticated this
unit of work.
Voters, strategies and the decision
A VoterInterface answers vote(token, subject, attributes) -> Access, where
Access is GRANTED, DENIED or ABSTAIN. Write one by subclassing Voter and
answering supports(attribute, subject) and vote_on_attribute(attribute, subject, token, vote); a CacheableVoterInterface additionally declares supports_attribute /
supports_type so the manager can skip a voter that will never speak.
The AccessDecisionManager(voters, strategy) gathers the votes and folds them with a
strategy:
| Strategy | Grants when |
|---|---|
AffirmativeStrategy |
any voter grants (the default) |
ConsensusStrategy |
grants outnumber denials |
UnanimousStrategy |
no voter denies |
PriorityStrategy |
the first voter that does not abstain grants |
Each takes allow_if_all_abstain (and ConsensusStrategy a tie-breaker). decide(token, attributes, subject=None, access_decision=None) reports one bool, filling an optional
AccessDecision with every Vote cast, the deciding strategy's name, and a message
accounting for the outcome. Each Vote carries its voter, its result, the reasons the
voter added with add_reason(...), and any extra_data.
The built-in voters:
| Voter | Grants on | Reads |
|---|---|---|
RoleVoter(prefix="ROLE_") |
a role the token holds | the token's roles |
RoleHierarchyVoter(hierarchy, prefix="ROLE_") |
a role the token's roles reach | the token's roles, expanded |
AuthenticatedVoter(trust_resolver) |
IS_AUTHENTICATED, IS_AUTHENTICATED_FULLY, PUBLIC_ACCESS |
the token's standing |
ClosureVoter() |
a callable attribute returning True |
a fresh IsGrantedContext |
A ClosureVoter runs a callable attribute (IsGrantedContext, subject) -> bool, so a
one-off rule needs no class; the context it passes can defer to the manager again with
await context.is_granted(...).
The authorization checker
AuthorizationChecker(token_storage, access_decision_manager) is the one call the rest of
an application makes: is_granted(attribute, subject=None) decides for the token currently in
storage, and is_granted_for_user(user, attribute, subject=None) decides for a given user
(GuestAuthorizationCheckerInterface) without touching storage. IsGrantedContext is the
value a closure voter is handed — the token, its user, and is_granted(...) for a nested
question.
Role hierarchy and wildcards
RoleHierarchy(mapping) expands a set of roles: get_reachable_role_names(roles) returns
the input roles plus every role they reach transitively, with no duplicates and safe against
cycles; get_parent_role_names(role) is one level down. A * in a key captures a segment of a
matching role and substitutes it into the values, so
{"ROLE_TENANT_*_ADMIN": ["ROLE_TENANT_*_USER"]} makes ROLE_TENANT_42_ADMIN reach
ROLE_TENANT_42_USER.
Tracing votes and events
TraceableVoter(voter, event_dispatcher) wraps any voter and announces its answer as a
VoteEvent — the voter, the subject, the attributes, the Access and the reasons — so a
decision can be read after the fact; get_decorated_voter() returns the voter it wraps. The
two event names a dispatcher keys on live in authentication_events.py:
AUTHENTICATION_SUCCESS (announced once a token is created for an authenticated user, carrying
an AuthenticationSuccessEvent) and VOTE (announced by a TraceableVoter). An
AuthenticationEvent carries the token it settled on.
Errors
Everything this library raises derives from SecurityError, and carries what went wrong as
typed attributes rather than only a message.
| Error | Base (besides SecurityError) |
Raised when |
|---|---|---|
AccessDeniedError |
— | authorization refused a known caller; carries attributes, subject, access_decision |
AuthenticationError |
— | authentication failed; carries a message_key and message_data |
AccountStatusError |
AuthenticationError |
the account's own state refuses it; carries the user |
AccountExpiredError |
AccountStatusError |
the account has expired |
CredentialsExpiredError |
AccountStatusError |
the account's credentials have expired |
DisabledError |
AccountStatusError |
the account is disabled |
LockedError |
AccountStatusError |
the account is locked |
CustomUserMessageAccountStatusError |
AccountStatusError |
an account-status failure whose public text is chosen at the raise site |
AuthenticationCredentialsNotFoundError |
AuthenticationError |
no credentials were found in the request |
AuthenticationServiceError |
AuthenticationError |
a service authentication relies on failed |
BadCredentialsError |
AuthenticationError |
the credentials presented were rejected |
CustomUserMessageAuthenticationError |
AuthenticationError |
an authentication failure whose public text is chosen at the raise site |
InsufficientAuthenticationError |
AuthenticationError |
authenticated, but not strongly enough |
UserNotFoundError |
AuthenticationError, LookupError |
no user matched the identifier |
InvalidArgumentError |
ValueError |
a declaration, configuration or call was malformed |
UnsupportedUserError |
TypeError |
a user of the wrong class reached a provider, checker or resolver |
Layout
xtr_security_core/
├── user/ UserInterface, InMemoryUser(Provider/Checker), chains, OidcUser
├── authentication/
│ ├── token/ TokenInterface, AbstractToken, NullToken, UsernamePasswordToken
│ │ └── storage/ TokenStorage, the current token per unit of work
│ └── authentication_trust_resolver.py is a token authenticated, and how fully
├── authentication_events.py AUTHENTICATION_SUCCESS, VOTE
├── authorization/
│ ├── access_decision_manager.py gathers voters and decides with a strategy
│ ├── authorization_checker.py is_granted — the one call the application makes
│ ├── is_granted_context.py what a closure voter is handed
│ ├── strategy/ affirmative, consensus, unanimous, priority
│ └── voter/ VoterInterface, Voter, Access, Vote, the built-in voters
├── role/ RoleHierarchy, incl. wildcards
├── event/ AuthenticationSuccessEvent, VoteEvent
└── exception/ SecurityError, the root of everything this library raises
In an application
This package works on its own and ships no bundle. The
xtr-security bundle wires it — the token storage, the
role hierarchy, the voters and decision manager, the authorization checker — into an application
on xtr-dependency-injection; see that package's
Use in an application. A class implementing
VoterInterface is gathered into the decision manager by the bundle's security.voter tag.
Development
Developed in the python-xtr monorepo, under
packages/xtr-security-core; run the commands below from there. The python-xtr-security-core
repository is a read-only copy, so send issues and pull requests to the monorepo.
uv sync --all-extras
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
License
MIT — see LICENSE.
Metadata
Release files for xtr-security-core 3.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| xtr_security_core-3.0.0.tar.gz | 36.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| xtr_security_core-3.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 110.0 kB
Release files / xtr_security_core-3.0.0.tar.gz
| Download URL | xtr_security_core-3.0.0.tar.gz |
|---|---|
| Size | 36.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3b6f088575c42bb441a0bab5f8be4a69b1bc684032bdfdfac06307894768ab66
|
|
BLAKE2b-256 checksum How to use checksums |
f7914d0246ae57ec0ce28c1b89a967fabfd0560161ef5e3829b79e9174377565
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.
Transparency logRelease files / xtr_security_core-3.0.0-py3-none-any.whl
| Download URL | xtr_security_core-3.0.0-py3-none-any.whl |
|---|---|
| Size | 73.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
680b0210ef54a09fe364dc4c70b59f99189e91d728211191b34e280a99647e54
|
|
BLAKE2b-256 checksum How to use checksums |
bdce1652e25bb3e0da3a39ff5b372bdc9459ce2b32ad1aff26491dc9c7e55cff
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.
Transparency log