Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ All notable changes to this project are documented in this file. The format is b
- `feature_flags` follows the module layout: `feature_flags.core.{constants,service,invalidation,tenant_cleanup}`, `feature_flags.web.{deps,endpoints}`, and the page components and types move to `ui/`. `feature_flags.constants` still imports, with a `DeprecationWarning`, until 0.0.46.
- `keycloak` follows the module layout: `keycloak.core.{constants,settings,state}`, `keycloak.identity.{provider,oidc,renewal,directory,persistence,tokens,jwks}` and `keycloak.web.endpoints`. `keycloak.{constants,settings,provider,jwks,tokens}` still import, with a `DeprecationWarning`, until 0.0.46.
- `oidc` follows the module layout: `oidc.core.{constants,settings,presets,state,runtime}`, `oidc.identity.{provider,client,discovery,renewal,directory,persistence,tokens,jwks}` and `oidc.web.endpoints`. `oidc.{settings,provider,jwks}` still import, with a `DeprecationWarning`, until 0.0.46; other old `oidc.*` paths are gone.
- `api_tokens` follows the module layout: `api_tokens.core.{constants,settings,services,tenant_cleanup,user_cleanup}`, `api_tokens.credentials.{format,scopes,owner,service,resolver,audit}` (`tokens.py` is now `credentials/format.py`), `api_tokens.web.{deps,endpoints}`, and the page components and types move to `ui/`. `api_tokens.{constants,service,resolver,tokens}` still import, with a `DeprecationWarning`, until 0.0.46.

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion docs/module-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ when a later module PR moves files.

| Module | Root `.py` files | Flat `tests/test_*.py` | State |
| --- | ---: | ---: | --- |
| api_tokens | 15 | 13 | flat |
| api_tokens | 3 (+4 shims) | 0 | follows this layout |
| audit_log | 13 | 18 | flat |
| auth | 8 | 20 | flat |
| background_tasks | 49 | 83 | flat |
Expand Down
7 changes: 1 addition & 6 deletions host/tests/test_module_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,14 @@
)
# Add named domain areas here only when they contain several related features.
DOMAIN_AREAS: dict[str, frozenset[str]] = {
"api_tokens": frozenset({"credentials"}),
"keycloak": frozenset({"identity"}),
"mail": frozenset({"delivery"}),
"oidc": frozenset({"identity"}),
}

# Frozen inventory, not a computed baseline: moves must remove their old entries.
LEGACY_FLAT: dict[str, frozenset[str]] = {
"api_tokens": frozenset(
"""
audit.py constants.py deps.py endpoints owner.py resolver.py scopes.py service.py
services.py settings.py tenant_cleanup.py tokens.py user_cleanup.py
""".split()
),
"audit_log": frozenset(
"""
activity.py capture.py constants.py deps.py endpoints export.py filters.py
Expand Down
77 changes: 6 additions & 71 deletions modules/api_tokens/api_tokens/constants.py
Original file line number Diff line number Diff line change
@@ -1,76 +1,11 @@
"""Stable identifiers for the api_tokens module."""
"""Moved to :mod:`api_tokens.core.constants`; this alias goes in 0.0.46 (module layout)."""

from __future__ import annotations

from enum import StrEnum
from typing import Final
import importlib
import sys

from auth.contracts.tokens import PAT_PREFIX, SERVICE_KEY_PREFIX
from simple_module_core.deprecation import deprecated_module

MODULE_NAME: Final = "ApiTokens"
MODULE_PACKAGE: Final = "api_tokens"
LOCALE_NAMESPACE: Final = "api_tokens"

# Modules this one is ordered after: Auth owns the resolver chain (required);
# Users, when installed, owns the accounts a personal token acts for; Settings
# stores ``max_lifetime_days``.
MODULE_AUTH: Final = "Auth"
MODULE_USERS: Final = "Users"
MODULE_SETTINGS: Final = "Settings"


class TokenKind(StrEnum):
PERSONAL = "personal"
SERVICE = "service"


KIND_PREFIX: Final = {TokenKind.PERSONAL: PAT_PREFIX, TokenKind.SERVICE: SERVICE_KEY_PREFIX}

# Secret: 32 random bytes in base62, left-padded to a fixed width so a
# well-formed token can be recognised without touching the database.
SECRET_BYTES: Final = 32
SECRET_LENGTH: Final = 43 # ceil(256 / log2(62))
DISPLAY_PREFIX_LENGTH: Final = 8

# Prefix is load-bearing on SQLite, which has no per-module schemas.
TABLE_TOKEN: Final = "api_tokens_token"
IX_TOKEN_HASH: Final = "ix_api_tokens_token_hash_tenant"
NAME_MAX_LENGTH: Final = 100
MAX_SCOPES: Final = 200

# How stale ``last_used_at`` may get before a request rewrites it. Bounds
# the write load to one conditional UPDATE per token per window.
LAST_USED_WRITE_INTERVAL_SECONDS: Final = 300

PERM_GROUP: Final = "API tokens"
PERM_USE: Final = "api_tokens.use"
PERM_MANAGE: Final = "api_tokens.manage"
USER_ROLE: Final = "user"

# Roles a service key may never hold: ``admin`` would be the wildcard on a
# credential with no person behind it.
FORBIDDEN_SERVICE_ROLES: Final = frozenset({"admin"})

API_PREFIX: Final = "/api/api_tokens"
VIEW_PREFIX: Final = "/api-tokens"
ADMIN_VIEW_PREFIX: Final = "/admin/api-tokens"
# Trailing slash: both index routes are registered at "/" under their prefix.
USER_URL: Final = f"{VIEW_PREFIX}/"
ADMIN_URL: Final = f"{ADMIN_VIEW_PREFIX}/"

PAGE_INDEX: Final = "ApiTokens/Index"
PAGE_ADMIN: Final = "ApiTokens/Admin"

MENU_LABEL: Final = "API tokens"
MENU_LABEL_KEY: Final = "api_tokens.nav.api_tokens"
MENU_USER_ORDER: Final = 980
MENU_ADMIN_ORDER: Final = 130
MENU_ICON: Final = "key"

AUDIT_LINK_LABEL: Final = "API token"
AUDIT_LINK_LABEL_KEY: Final = "api_tokens.audit.token"
QP_TOKEN: Final = "token"

# Expiry presets offered by the pages, in days; the server accepts any whole
# number of days up to ``max_lifetime_days``.
EXPIRY_PRESETS_DAYS: Final = (7, 30, 90, 365)
deprecated_module("api_tokens.constants", remove_in="0.0.46", use="api_tokens.core.constants")
sys.modules[__name__] = importlib.import_module("api_tokens.core.constants")
2 changes: 1 addition & 1 deletion modules/api_tokens/api_tokens/contracts/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pydantic import ConfigDict, field_validator
from sqlmodel import Field, SQLModel

from api_tokens.constants import MAX_SCOPES, NAME_MAX_LENGTH
from api_tokens.core.constants import MAX_SCOPES, NAME_MAX_LENGTH


class TokenView(SQLModel):
Expand Down
4 changes: 4 additions & 0 deletions modules/api_tokens/api_tokens/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""What every api_tokens feature builds on.

Constants, settings and the tenant and user cleanup hooks.
"""
81 changes: 81 additions & 0 deletions modules/api_tokens/api_tokens/core/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Stable identifiers for the api_tokens module."""

from __future__ import annotations

from enum import StrEnum
from typing import Final

from auth.contracts.tokens import PAT_PREFIX, SERVICE_KEY_PREFIX

MODULE_NAME: Final = "ApiTokens"
MODULE_PACKAGE: Final = "api_tokens"
LOCALE_NAMESPACE: Final = "api_tokens"

# Modules this one is ordered after: Auth owns the resolver chain (required);
# Users, when installed, owns the accounts a personal token acts for; Settings
# stores ``max_lifetime_days``.
MODULE_AUTH: Final = "Auth"
MODULE_USERS: Final = "Users"
MODULE_SETTINGS: Final = "Settings"


class TokenKind(StrEnum):
PERSONAL = "personal"
SERVICE = "service"


KIND_PREFIX: Final = {TokenKind.PERSONAL: PAT_PREFIX, TokenKind.SERVICE: SERVICE_KEY_PREFIX}

# Secret: 32 random bytes in base62, left-padded to a fixed width so a
# well-formed token can be recognised without touching the database.
SECRET_BYTES: Final = 32
SECRET_LENGTH: Final = 43 # ceil(256 / log2(62))
DISPLAY_PREFIX_LENGTH: Final = 8

# Prefix is load-bearing on SQLite, which has no per-module schemas.
TABLE_TOKEN: Final = "api_tokens_token"
IX_TOKEN_HASH: Final = "ix_api_tokens_token_hash_tenant"
NAME_MAX_LENGTH: Final = 100
MAX_SCOPES: Final = 200

# How stale ``last_used_at`` may get before a request rewrites it. Bounds
# the write load to one conditional UPDATE per token per window.
LAST_USED_WRITE_INTERVAL_SECONDS: Final = 300

PERM_GROUP: Final = "API tokens"
PERM_USE: Final = "api_tokens.use"
PERM_MANAGE: Final = "api_tokens.manage"
USER_ROLE: Final = "user"

# Roles a service key may never hold: ``admin`` would be the wildcard on a
# credential with no person behind it.
FORBIDDEN_SERVICE_ROLES: Final = frozenset({"admin"})

API_PREFIX: Final = "/api/api_tokens"
VIEW_PREFIX: Final = "/api-tokens"
ADMIN_VIEW_PREFIX: Final = "/admin/api-tokens"
# Trailing slash: both index routes are registered at "/" under their prefix.
USER_URL: Final = f"{VIEW_PREFIX}/"
ADMIN_URL: Final = f"{ADMIN_VIEW_PREFIX}/"

PAGE_INDEX: Final = "ApiTokens/Index"
PAGE_ADMIN: Final = "ApiTokens/Admin"

MENU_LABEL: Final = "API tokens"
MENU_LABEL_KEY: Final = "api_tokens.nav.api_tokens"
MENU_USER_ORDER: Final = 980
MENU_ADMIN_ORDER: Final = 130
MENU_ICON: Final = "key"

AUDIT_LINK_LABEL: Final = "API token"
AUDIT_LINK_LABEL_KEY: Final = "api_tokens.audit.token"
QP_TOKEN: Final = "token"

# Expiry presets offered by the pages, in days; the server accepts any whole
# number of days up to ``max_lifetime_days``.
EXPIRY_PRESETS_DAYS: Final = (7, 30, 90, 365)

# Durable handler names key undelivered outbox rows, so they keep the import
# path the handlers had before the module-layout move (renaming strands rows).
TENANT_CLEANUP_HANDLER: Final = "api_tokens.tenant_cleanup.purge_tenant_data"
USER_CLEANUP_HANDLER: Final = "api_tokens.user_cleanup.purge_user_tokens"
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from dataclasses import dataclass

from api_tokens.settings import ApiTokensSettings
from api_tokens.core.settings import ApiTokensSettings


@dataclass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from simple_module_db.tenancy import all_tenants
from sqlmodel import col, delete

from api_tokens.constants import TokenKind
from api_tokens.core.constants import TokenKind
from api_tokens.models import ApiToken


Expand Down
5 changes: 5 additions & 0 deletions modules/api_tokens/api_tokens/credentials/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Personal access tokens and service keys.

The token format, scopes, owners, the service, the bearer resolver and audit
naming.
"""
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from api_tokens.constants import ADMIN_URL, AUDIT_LINK_LABEL, AUDIT_LINK_LABEL_KEY, QP_TOKEN
from api_tokens.core.constants import ADMIN_URL, AUDIT_LINK_LABEL, AUDIT_LINK_LABEL_KEY, QP_TOKEN
from api_tokens.models import ApiToken


Expand Down
83 changes: 83 additions & 0 deletions modules/api_tokens/api_tokens/credentials/format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Token format, generation and hashing — no I/O.

A token is ``<kind prefix><secret>``: ``smpat_`` for a personal access token,
``smsk_`` for a service key, then 32 bytes from :func:`secrets.token_bytes`
in base62, left-padded to a fixed width. Only ``sha256(token)`` is stored.

SHA-256 rather than a slow password hash is deliberate: the input carries 256
bits of entropy, so there is nothing to brute-force, and the hash is computed
on every authenticated request.
"""

from __future__ import annotations

import hashlib
import re
import secrets
from typing import NamedTuple

from auth.contracts.tokens import API_TOKEN_PREFIXES

from api_tokens.core.constants import (
DISPLAY_PREFIX_LENGTH,
KIND_PREFIX,
SECRET_BYTES,
SECRET_LENGTH,
TokenKind,
)

_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
_FORMAT = re.compile(
"^(?:"
+ "|".join(re.escape(p) for p in API_TOKEN_PREFIXES)
+ f")[0-9A-Za-z]{{{SECRET_LENGTH}}}$"
)


class NewToken(NamedTuple):
"""A freshly minted token. ``plaintext`` must reach the caller exactly once."""

plaintext: str
token_hash: str
prefix: str


def base62(data: bytes) -> str:
"""Encode *data* in base62, left-padded with ``0`` to :data:`SECRET_LENGTH`."""
number = int.from_bytes(data, "big")
out: list[str] = []
while number:
number, rem = divmod(number, 62)
out.append(_ALPHABET[rem])
return "".join(reversed(out)).rjust(SECRET_LENGTH, _ALPHABET[0])


def hash_token(plaintext: str) -> str:
"""The stored form of a token: lowercase hex SHA-256 of the whole string."""
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()


def is_well_formed(candidate: str) -> bool:
"""Whether *candidate* has the exact shape of a token we could have issued.

Checked before any database read, so junk in an ``Authorization`` header
costs a regex match and nothing else.
"""
return _FORMAT.fullmatch(candidate) is not None


def display_prefix(plaintext: str) -> str:
"""The non-secret slice shown in lists: the kind prefix plus 8 characters."""
for prefix in API_TOKEN_PREFIXES:
if plaintext.startswith(prefix):
return plaintext[: len(prefix) + DISPLAY_PREFIX_LENGTH]
raise ValueError("not an API token")


def generate(kind: TokenKind) -> NewToken:
"""Mint a token of *kind*."""
plaintext = KIND_PREFIX[kind] + base62(secrets.token_bytes(SECRET_BYTES))
return NewToken(plaintext, hash_token(plaintext), display_prefix(plaintext))


__all__ = ["NewToken", "base62", "display_prefix", "generate", "hash_token", "is_well_formed"]
Loading
Loading