# `sandloom.sdk.security`

Token issuing, revocation, blocking, password hashing and key generation.

Public API 1.0 (spec §2.2, Appendix C).

`from sandloom.sdk.security import <name>`. Signatures in a terminal: `sandloom docs <name>`. Beginner names are the ones generated code and the examples use; any other name is advanced (mark its import line `# sandloom: advanced <reason>` in generated code and examples).

## Advanced

| Name | Kind | Summary |
| --- | --- | --- |
| [`apply_pending_revocation`](<#apply_pending_revocation>) | async function | Apply a `core.auth.tokens_revoked.v1` payload (durable applier body). |
| [`block_user`](<#block_user>) | async function | Block `user_id` NOW: every authenticated request of theirs gets 401. |
| [`ClaimsUnavailable`](<#claimsunavailable>) | exception | A CRITICAL claims contributor failed: the token must not be minted. |
| [`create_access_token`](<#create_access_token>) | function | Create JWT access token via sandloom.core.auth (stable adapter). |
| [`decode_token`](<#decode_token>) | function | Decode JWT using sandloom.core.auth.tokens (stable adapter). |
| [`decode_token_cached`](<#decode_token_cached>) | function | Per-request-cached JWT decode via sandloom.core.auth (stable adapter). |
| [`ensure_token_not_revoked`](<#ensure_token_not_revoked>) | async function | Raise 401 when `claims` predate a revocation (503 on fail-closed outage). |
| [`ensure_user_not_blocked`](<#ensure_user_not_blocked>) | async function | Refuse to authenticate a blocked user: `401 "Account blocked"`. |
| [`generate_private_key`](<#generate_private_key>) | function | A fresh private key for `algorithm` (`sandloom keys generate`). |
| [`generate_x25519_key`](<#generate_x25519_key>) | function |  |
| [`has_internal_access`](<#has_internal_access>) | function | Non-raising variant: True when the caller may see internal details. |
| [`is_user_blocked`](<#is_user_blocked>) | async function | Live read of the subject-state record (raises on a cache outage). |
| [`password_hasher`](<#password_hasher>) | function | A fresh argon2id `PasswordHasher` with the CONFIGURED parameters (`PASSWORD_HASH_PROFILE` / `_TIME_COST` / `_MEMORY_KIB` / `_PARALLELISM`... |
| [`private_pem`](<#private_pem>) | function |  |
| [`public_pem`](<#public_pem>) | function | The SubjectPublicKeyInfo PEM of a key (or of a private key's public half). |
| [`rehash_password_if_needed`](<#rehash_password_if_needed>) | async function | New hash when `stored_hash` was made with other argon2 parameters than the configured ones (stronger or weaker), else `None`. |
| [`revoke_user_tokens`](<#revoke_user_tokens>) | async function | Invalidate every access token issued to `user_id` up to now. |
| [`revoke_user_tokens_on_commit`](<#revoke_user_tokens_on_commit>) | async function | Revoke `user_id`'s access tokens AFTER the current UoW commits. |
| [`run_password_hashing`](<#run_password_hashing>) | async function | Run an argon2 hash/verify `fn(*args)` on the BOUNDED password pool. |
| [`SERVICE_PRINCIPAL_ID`](<#service_principal_id>) | constant |  |
| [`subject_commit_check`](<#subject_commit_check>) | function | A `before_commit` check: is the request's bearer STILL valid now? |
| [`unblock_user`](<#unblock_user>) | async function | Lift an emergency block (audited `auth.user.unblocked`, same rules). |
| [`x25519_private_pem`](<#x25519_private_pem>) | function |  |
| [`x25519_public_pem`](<#x25519_public_pem>) | function |  |

## Advanced names

### `apply_pending_revocation`

```
async def apply_pending_revocation(payload: dict[str, Any]) -> None
```

Apply a `core.auth.tokens_revoked.v1` payload (durable applier body).

Idempotent; raises when the mark cannot be written so the delivery is retried. See `sandloom.core.auth.revocation.apply_pending_revocation`.

### `block_user`

```
async def block_user(
    user_id: Any,
    *,
    reason: str,
    actor_id: Any = None,
    database: str | None = None,
) -> None
```

Block `user_id` NOW: every authenticated request of theirs gets 401.

The `blocked` value of the subject-state record is read live on every authenticated request, so it applies on the very next request in every process — including tokens minted after the block. It stays until `unblock_user` (no TTL) and outranks every revocation.

Must run inside a Unit of Work: the audit record (`auth.user.blocked`, actor + reason), the immediate token revocation and the event are all written BEFORE the mark, and the mark is the LAST step — a failure of any earlier step leaves no mark behind the rolled-back audit (no block happens unaudited). A cache outage raises `BlockUnavailableError` (503) and the UoW rolls the audit back. Call it as the last step of the request (the mark itself is not transactional).

On commit it also ends the user's sessions, so lifting the block does not revive them: every access token issued so far is revoked (`revoke_user_tokens_on_commit`) and `core.auth.user_blocked.v1` is published (transactional outbox) — identity revokes the user's refresh tokens on it, locally or in its own service.

### `ClaimsUnavailable`

```
class ClaimsUnavailable(HTTPException)(owner: str) -> None
```

A CRITICAL claims contributor failed: the token must not be minted.

Surfaces as `503` (retry later) — never a token silently missing security claims such as tenancy's `tenant_ids` allowlist.

### `create_access_token`

```
def create_access_token(
    *,
    data: dict[str, Any],
    expires_delta: int | None = None,
) -> str
```

Create JWT access token via sandloom.core.auth (stable adapter).

Import at call-time so test patches against security are honored.

### `decode_token`

```
def decode_token(token: str) -> dict[str, Any] | None
```

Decode JWT using sandloom.core.auth.tokens (stable adapter).

Import at call-time so tests patching `sandloom.core.auth.tokens.decode_token` take effect.

### `decode_token_cached`

```
def decode_token_cached(request: Any, token: str) -> dict[str, Any] | None
```

Per-request-cached JWT decode via sandloom.core.auth (stable adapter).

Import at call-time so tests patching `sandloom.core.auth.tokens.decode_token` take effect (the cache wrapper calls it through the module global).

### `ensure_token_not_revoked`

```
async def ensure_token_not_revoked(request: Any, claims: dict[str, Any]) -> None
```

Raise 401 when `claims` predate a revocation (503 on fail-closed outage).

### `ensure_user_not_blocked`

```
async def ensure_user_not_blocked(user_id: Any) -> None
```

Refuse to authenticate a blocked user: `401 "Account blocked"`.

For token ISSUERS (password / SSO / 2FA login, refresh-token exchange): call it before minting or rotating anything — and before collecting the token's claims. A live read of the subject-state record — never a cached copy — so the token minted next in this context is stamped after any revocation it saw. On a cache outage `AUTH_REVOCATION_FAIL_CLOSED` decides, like the per-request check: closed answers `503`, open logs and lets the issuance proceed.

### `generate_private_key`

```
def generate_private_key(algorithm: str) -> PrivateKey
```

A fresh private key for `algorithm` (`sandloom keys generate`).

### `generate_x25519_key`

```
def generate_x25519_key() -> x25519.X25519PrivateKey
```

*(no docstring)*

### `has_internal_access`

```
def has_internal_access(x_internal_token: str | None) -> bool
```

Non-raising variant: True when the caller may see internal details.

### `is_user_blocked`

```
async def is_user_blocked(user_id: Any) -> bool
```

Live read of the subject-state record (raises on a cache outage).

### `password_hasher`

```
def password_hasher() -> Any
```

A fresh argon2id `PasswordHasher` with the CONFIGURED parameters (`PASSWORD_HASH_PROFILE` / `_TIME_COST` / `_MEMORY_KIB` / `_PARALLELISM`; fixed 16-byte salt, 32-byte digest).

Every component that hashes passwords builds its hasher here, so registration, password change/reset and CLIs share one work factor.

### `private_pem`

```
def private_pem(key: PrivateKey) -> str
```

*(no docstring)*

### `public_pem`

```
def public_pem(key: PrivateKey | PublicKey) -> str
```

The SubjectPublicKeyInfo PEM of a key (or of a private key's public half).

### `rehash_password_if_needed`

```
async def rehash_password_if_needed(
    plain_password: str,
    stored_hash: str,
    *,
    hasher: Any = None,
) -> str | None
```

New hash when `stored_hash` was made with other argon2 parameters than the configured ones (stronger or weaker), else `None`.

Call only after a SUCCESSFUL verify and store the result in the same Unit of Work. Never raises (a failure is logged + counted in `sandloom_password_rehash_total{outcome="failed"}`) — see `sandloom.core.auth.hashing.rehash_password_if_needed`.

### `revoke_user_tokens`

```
async def revoke_user_tokens(user_id: Any, *, valid_after: int | None = None) -> bool
```

Invalidate every access token issued to `user_id` up to now.

Framework-wide: `current_context` / `require(...)` / `get_current_user_id` / `TenantScope` reject an older token with 401. See `sandloom.core.auth.revocation.revoke_user_tokens`.

### `revoke_user_tokens_on_commit`

```
async def revoke_user_tokens_on_commit(
    user_id: Any,
    *,
    reason: str,
    valid_after: int | None = None,
    database: str | None = None,
    require_immediate: bool = False,
) -> int
```

Revoke `user_id`'s access tokens AFTER the current UoW commits.

The single framework path for "this DB change invalidates the user's sessions" (identity and tenancy both use it): skipped on rollback, retried after commit, persisted durably in the UoW's outbox (`core.auth.tokens_revoked.v1`) for the session owner's applier; returns the fixed `valid_after` mark in whole seconds, rounded UP (the precise mark has microsecond precision: a token issued at or before it is revoked). `require_immediate=True` also writes the mark BEFORE commit and refuses the change (`RevocationUnavailableError`, 503) when it cannot — for changes whose revocation must hold the moment they commit. See `sandloom.core.auth.revocation.revoke_user_tokens_on_commit`.

### `run_password_hashing`

```
async def run_password_hashing(fn: Callable[..., _T], *args: Any) -> _T
```

Run an argon2 hash/verify `fn(*args)` on the BOUNDED password pool.

See `sandloom.core.auth.hashing.run_password_hashing` (`PASSWORD_HASH_CONCURRENCY`): never the loop's default executor.

### `SERVICE_PRINCIPAL_ID`

```
SERVICE_PRINCIPAL_ID: UUID
```

*(no docstring)*

### `subject_commit_check`

```
def subject_commit_check(request: Request) -> Callable[[], Awaitable[None]]
```

A `before_commit` check: is the request's bearer STILL valid now?

Returns a no-argument coroutine function that re-reads the caller's revocation and block marks LIVE (no request memo, no staleness budget) and raises `401` (revoked / blocked) or `503` (fail-closed cache outage). A request without a bearer gets a no-op (its guards decide).

Guarantee (ADR-0003 "Guarantees, precisely"): every change that withdraws authority — a tenancy role change / removal, an identity role revocation, `block_user`, a password change — writes the mark BEFORE it commits (`revoke_user_tokens_on_commit(require_immediate=True)` / the block mark) and reads nothing our action writes. A mark seen rolls our transaction back; no mark seen means the history is SERIALIZABLE with our action first. It is NOT strictly serializable / linearizable: a withdrawal that writes its mark and commits between this read and our COMMIT is not seen — the window is narrowed to read → COMMIT, not closed (a fence making the withdrawal wait for in-flight actions would close it; not implemented). On a revocation-store outage `AUTH_REVOCATION_FAIL_CLOSED` decides: closed (the prod default) → 503; open (the default outside prod) → the check ACCEPTS, like the request-start check. Register it with `await before_commit(subject_commit_check(request))` inside a UoW you open yourself; request routes are re-checked automatically (writes, a GET whose unit wrote) or with `at_commit()`.

### `unblock_user`

```
async def unblock_user(
    user_id: Any,
    *,
    reason: str,
    actor_id: Any = None,
    database: str | None = None,
) -> None
```

Lift an emergency block (audited `auth.user.unblocked`, same rules).

The `blocked` value is replaced only AFTER the UoW commits (`on_commit`), by compare-and-set with `after:<now + skew>` (with the revocation TTL) — never deleted, so every token issued before the unblock stays revoked and a concurrent writer is never overwritten blindly. A rolled-back unblock leaves the user blocked, never unblocked without an audit row. Fail-safe: if the post-commit swap fails (cache outage) the user stays blocked — logged as an error; run the unblock again.

### `x25519_private_pem`

```
def x25519_private_pem(key: x25519.X25519PrivateKey) -> str
```

*(no docstring)*

### `x25519_public_pem`

```
def x25519_public_pem(key: x25519.X25519PrivateKey | x25519.X25519PublicKey) -> str
```

*(no docstring)*

## Examples and related guides

- [Revoke sessions](<https://sandloom.dev/docs/modules/http/session-revocation/index.md>)
- [Python API overview](<https://sandloom.dev/docs/reference/sdk/index.md>)
