diff --git a/CHANGELOG.md b/CHANGELOG.md index ac2210466..14610b0e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/module-layout.md b/docs/module-layout.md index b8eed1136..ee59fb857 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -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 | diff --git a/host/tests/test_module_layout.py b/host/tests/test_module_layout.py index f39e9880d..2cb3bbc7c 100644 --- a/host/tests/test_module_layout.py +++ b/host/tests/test_module_layout.py @@ -33,6 +33,7 @@ ) # 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"}), @@ -40,12 +41,6 @@ # 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 diff --git a/modules/api_tokens/api_tokens/constants.py b/modules/api_tokens/api_tokens/constants.py index 9df6c2bb9..083c19855 100644 --- a/modules/api_tokens/api_tokens/constants.py +++ b/modules/api_tokens/api_tokens/constants.py @@ -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") diff --git a/modules/api_tokens/api_tokens/contracts/schemas.py b/modules/api_tokens/api_tokens/contracts/schemas.py index 3f72ce505..337cf5b84 100644 --- a/modules/api_tokens/api_tokens/contracts/schemas.py +++ b/modules/api_tokens/api_tokens/contracts/schemas.py @@ -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): diff --git a/modules/api_tokens/api_tokens/core/__init__.py b/modules/api_tokens/api_tokens/core/__init__.py new file mode 100644 index 000000000..139d32e7c --- /dev/null +++ b/modules/api_tokens/api_tokens/core/__init__.py @@ -0,0 +1,4 @@ +"""What every api_tokens feature builds on. + +Constants, settings and the tenant and user cleanup hooks. +""" diff --git a/modules/api_tokens/api_tokens/core/constants.py b/modules/api_tokens/api_tokens/core/constants.py new file mode 100644 index 000000000..9b7def73b --- /dev/null +++ b/modules/api_tokens/api_tokens/core/constants.py @@ -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" diff --git a/modules/api_tokens/api_tokens/services.py b/modules/api_tokens/api_tokens/core/services.py similarity index 84% rename from modules/api_tokens/api_tokens/services.py rename to modules/api_tokens/api_tokens/core/services.py index 7cf67f492..62d807753 100644 --- a/modules/api_tokens/api_tokens/services.py +++ b/modules/api_tokens/api_tokens/core/services.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from api_tokens.settings import ApiTokensSettings +from api_tokens.core.settings import ApiTokensSettings @dataclass diff --git a/modules/api_tokens/api_tokens/settings.py b/modules/api_tokens/api_tokens/core/settings.py similarity index 100% rename from modules/api_tokens/api_tokens/settings.py rename to modules/api_tokens/api_tokens/core/settings.py diff --git a/modules/api_tokens/api_tokens/tenant_cleanup.py b/modules/api_tokens/api_tokens/core/tenant_cleanup.py similarity index 100% rename from modules/api_tokens/api_tokens/tenant_cleanup.py rename to modules/api_tokens/api_tokens/core/tenant_cleanup.py diff --git a/modules/api_tokens/api_tokens/user_cleanup.py b/modules/api_tokens/api_tokens/core/user_cleanup.py similarity index 92% rename from modules/api_tokens/api_tokens/user_cleanup.py rename to modules/api_tokens/api_tokens/core/user_cleanup.py index 82d696eb7..e10bab30b 100644 --- a/modules/api_tokens/api_tokens/user_cleanup.py +++ b/modules/api_tokens/api_tokens/core/user_cleanup.py @@ -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 diff --git a/modules/api_tokens/api_tokens/credentials/__init__.py b/modules/api_tokens/api_tokens/credentials/__init__.py new file mode 100644 index 000000000..af29a80e4 --- /dev/null +++ b/modules/api_tokens/api_tokens/credentials/__init__.py @@ -0,0 +1,5 @@ +"""Personal access tokens and service keys. + +The token format, scopes, owners, the service, the bearer resolver and audit +naming. +""" diff --git a/modules/api_tokens/api_tokens/audit.py b/modules/api_tokens/api_tokens/credentials/audit.py similarity index 94% rename from modules/api_tokens/api_tokens/audit.py rename to modules/api_tokens/api_tokens/credentials/audit.py index d8fd64dbe..dd7fa09cb 100644 --- a/modules/api_tokens/api_tokens/audit.py +++ b/modules/api_tokens/api_tokens/credentials/audit.py @@ -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 diff --git a/modules/api_tokens/api_tokens/credentials/format.py b/modules/api_tokens/api_tokens/credentials/format.py new file mode 100644 index 000000000..e2933bc3e --- /dev/null +++ b/modules/api_tokens/api_tokens/credentials/format.py @@ -0,0 +1,83 @@ +"""Token format, generation and hashing — no I/O. + +A token is ````: ``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"] diff --git a/modules/api_tokens/api_tokens/owner.py b/modules/api_tokens/api_tokens/credentials/owner.py similarity index 100% rename from modules/api_tokens/api_tokens/owner.py rename to modules/api_tokens/api_tokens/credentials/owner.py diff --git a/modules/api_tokens/api_tokens/credentials/resolver.py b/modules/api_tokens/api_tokens/credentials/resolver.py new file mode 100644 index 000000000..bf0a6849c --- /dev/null +++ b/modules/api_tokens/api_tokens/credentials/resolver.py @@ -0,0 +1,160 @@ +"""The principal resolver: ``Authorization: Bearer smpat_…`` / ``smsk_…`` → principal. + +Appended to ``app.state.auth.principal_resolvers`` at startup. The ``users`` +and ``keycloak`` providers return ``None`` for these prefixes, which is what +hands the request to this resolver. + +Per request: a regex check, one indexed lookup by hash (no cache, so a revoke +is effective on the very next request), and for a personal token one read of +the owner with their roles through the app's user directory. Everything that +is not a usable token — unknown, revoked, expired, an inactive or disabled +owner, an owner whose roles the directory cannot read — is ``None``, which the +middleware turns into a 401. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime, timedelta +from typing import Any + +from auth.contracts.schemas import UserContext +from auth.contracts.tokens import AUTH_KIND_PAT, AUTH_KIND_SERVICE, is_api_token, parse_bearer +from simple_module_core.tenancy import is_tenant_role +from simple_module_core.user_directory import UserDirectory, get_user_directory +from simple_module_db import ALL_TENANTS_OPTION, independent_session +from sqlalchemy import or_, select, update +from sqlmodel import col +from starlette.requests import Request + +from api_tokens.core.constants import ( + FORBIDDEN_SERVICE_ROLES, + LAST_USED_WRITE_INTERVAL_SECONDS, + PERM_USE, + TokenKind, +) +from api_tokens.credentials.format import hash_token, is_well_formed +from api_tokens.credentials.owner import usable_owner +from api_tokens.models import ApiToken + +logger = logging.getLogger(__name__) + +SERVICE_PRINCIPAL_PREFIX = "svc:" +"""Service principals are ``svc:``: not a user UUID, so they hold no +direct grants and can never be mistaken for an account.""" + + +def _bearer(request: Request) -> str | None: + credential = parse_bearer(request.headers.get("authorization")) + if credential is None or not is_api_token(credential) or not is_well_formed(credential): + return None + return credential + + +def _usable(token: ApiToken, now: datetime) -> bool: + return token.revoked_at is None and token.expires_at > now + + +async def _personal(db: Any, directory: UserDirectory, token: ApiToken) -> UserContext | None: + if token.owner_user_id is None: + return None + owner_id = str(token.owner_user_id) + user = await usable_owner(directory, db, owner_id) + if user is None: + return None + principal = UserContext( + id=owner_id, + email=user.email, + name=user.label, + roles=list(user.roles or ()), + auth_kind=AUTH_KIND_PAT, + scopes=list(token.scopes), + ) + # Pinned to the tenant it was created in, like a service key: the tenant + # resolver acts for this tenant only (and only while the owner is still an + # active member of it), never one named by a header, subdomain or session. + principal.tenant_id = token.tenant_id + # A personal token lives only as long as its owner may hold tokens: losing + # ``api_tokens.use`` (which also takes away listing and revoking them) + # retires every token they own, checked against their live, tenant-aware + # permissions on each request (``lacks_required_permission``). + principal.required_permission = PERM_USE + return principal + + +def _service(token: ApiToken) -> UserContext: + return UserContext( + id=f"{SERVICE_PRINCIPAL_PREFIX}{token.id}", + email="", + name=token.name, + # Refused at creation; dropped here too so a hand-edited row cannot + # turn a service key into an admin — or a tenant owner: ``tenant:*`` + # roles come only from a membership, which a service key never has. + roles=[ + r for r in token.roles if r not in FORBIDDEN_SERVICE_ROLES and not is_tenant_role(r) + ], + tenant_id=token.tenant_id, + auth_kind=AUTH_KIND_SERVICE, + scopes=list(token.scopes), + ) + + +async def touch_last_used(app: Any, token: ApiToken, now: datetime) -> None: + """Record use at most once per interval, in its own short transaction. + + The ``WHERE`` repeats the staleness test, so concurrent requests on one + token race to a single write rather than one each. ``updated_at`` is set + to itself: it would otherwise be bumped by its ``onupdate`` and stop + meaning "last edited". A failure here never fails the request. + """ + cutoff = now - timedelta(seconds=LAST_USED_WRITE_INTERVAL_SECONDS) + if token.last_used_at is not None and token.last_used_at > cutoff: + return + stmt = ( + update(ApiToken) + .where( + col(ApiToken.id) == token.id, + or_(ApiToken.last_used_at.is_(None), ApiToken.last_used_at <= cutoff), + ) + .values(last_used_at=now, updated_at=ApiToken.updated_at) + .execution_options(**{ALL_TENANTS_OPTION: True}) + ) + try: + async with independent_session(app) as db: + await db.execute(stmt) + except Exception: + logger.warning("api_tokens: could not record last use of %s", token.id, exc_info=True) + + +async def resolve_api_token(request: Request) -> UserContext | None: + """``PrincipalResolver`` for ``api_tokens`` credentials. Never raises.""" + credential = _bearer(request) + if credential is None: + return None + now = datetime.now(UTC) + app = request.app + try: + async with app.state.sm.db.session_factory() as db: + stmt = ( + select(ApiToken) + .where(col(ApiToken.token_hash) == hash_token(credential)) + .execution_options(**{ALL_TENANTS_OPTION: True}) + ) + token = (await db.execute(stmt)).scalar_one_or_none() + if token is None or not _usable(token, now): + return None + if token.kind == TokenKind.PERSONAL: + principal = await _personal(db, get_user_directory(app), token) + elif token.kind == TokenKind.SERVICE: + principal = _service(token) + else: + principal = None + except Exception: + logger.exception("api_tokens: token lookup failed; treating as unauthenticated") + return None + if principal is not None: + await touch_last_used(app, token, now) + return principal + + +__all__ = ["SERVICE_PRINCIPAL_PREFIX", "resolve_api_token", "touch_last_used"] diff --git a/modules/api_tokens/api_tokens/scopes.py b/modules/api_tokens/api_tokens/credentials/scopes.py similarity index 94% rename from modules/api_tokens/api_tokens/scopes.py rename to modules/api_tokens/api_tokens/credentials/scopes.py index d295b8ddc..196645239 100644 --- a/modules/api_tokens/api_tokens/scopes.py +++ b/modules/api_tokens/api_tokens/credentials/scopes.py @@ -16,8 +16,8 @@ from simple_module_core.tenancy import is_tenant_role from simple_module_hosting.permissions import ensure_resolved_permissions -from api_tokens.constants import FORBIDDEN_SERVICE_ROLES -from api_tokens.service import TokenError +from api_tokens.core.constants import FORBIDDEN_SERVICE_ROLES +from api_tokens.credentials.service import TokenError def _registry(request: Request): diff --git a/modules/api_tokens/api_tokens/credentials/service.py b/modules/api_tokens/api_tokens/credentials/service.py new file mode 100644 index 000000000..4636b88fc --- /dev/null +++ b/modules/api_tokens/api_tokens/credentials/service.py @@ -0,0 +1,190 @@ +"""Creating, listing and revoking API tokens. + +Authorisation decisions — who may create what, which scopes a creator may +hand out — are made by the caller (``api_tokens.credentials.scopes`` and the endpoints); +this layer enforces only the invariants every token must satisfy wherever it +came from: a non-empty scope list, no ``admin`` role on a service key, an +expiry within ``max_lifetime_days``. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Sequence +from datetime import timedelta + +from simple_module_core.datetimes import utcnow as _utcnow +from simple_module_core.user_directory import NullUserDirectory, UserDirectory +from simple_module_db import ALL_TENANTS_OPTION, all_tenants +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import col + +from api_tokens.contracts.schemas import TokenView +from api_tokens.core.constants import FORBIDDEN_SERVICE_ROLES, TokenKind +from api_tokens.credentials.format import generate +from api_tokens.credentials.owner import usable_owner +from api_tokens.models import ApiToken + + +class TokenError(Exception): + """A request the service refuses. ``key`` is an ``api_tokens.errors.*`` key.""" + + def __init__(self, key: str, **params: object) -> None: + super().__init__(key) + self.key = key + self.params = params + + +class ApiTokenService: + def __init__(self, db: AsyncSession, directory: UserDirectory | None = None) -> None: + self.db = db + # Owners are read through the app's user directory (GH #501); without + # one, nobody has a usable account and owners show unnamed. + self.directory: UserDirectory = directory or NullUserDirectory() + + async def create( + self, + *, + kind: TokenKind, + name: str, + scopes: Sequence[str], + expires_in_days: int, + max_lifetime_days: int, + owner_user_id: uuid.UUID | None = None, + roles: Sequence[str] = (), + ) -> tuple[ApiToken, str]: + """Persist a new token; return it with its plaintext (shown once).""" + if not scopes: + raise TokenError("api_tokens.errors.scopes_required") + if not 1 <= expires_in_days <= max_lifetime_days: + raise TokenError("api_tokens.errors.lifetime_too_long", days=max_lifetime_days) + if kind is TokenKind.PERSONAL: + if owner_user_id is None or roles: + raise TokenError("api_tokens.errors.invalid_kind") + elif owner_user_id is not None: + raise TokenError("api_tokens.errors.invalid_kind") + forbidden = sorted(set(roles) & FORBIDDEN_SERVICE_ROLES) + if forbidden: + raise TokenError("api_tokens.errors.role_forbidden", role=forbidden[0]) + + new = generate(kind) + token = ApiToken( + kind=kind, + name=name, + prefix=new.prefix, + token_hash=new.token_hash, + owner_user_id=owner_user_id, + scopes=sorted(set(scopes)), + roles=sorted(set(roles)), + expires_at=_utcnow() + timedelta(days=expires_in_days), + ) + self.db.add(token) + await self.db.flush() + return token, new.plaintext + + async def has_usable_account(self, user_id: uuid.UUID) -> bool: + """Whether *user_id* is an active account the resolver can turn into a principal. + + That takes a directory entry that is active **and** whose roles are + known: under a provider that keeps roles in the IdP (Keycloak), a token + minted for the id would be dead on arrival, since the resolver refuses + a personal token whose owner's roles it cannot read. + """ + return await usable_owner(self.directory, self.db, str(user_id)) is not None + + async def revoke_all_for_owner(self, owner_user_id: uuid.UUID) -> int: + """Revoke every live personal token *owner_user_id* holds, in any tenant.""" + revoked = 0 + offset = 0 + now = _utcnow() + while tokens := await self.list_for_owner(owner_user_id, offset=offset): + live = [t for t in tokens if t.revoked_at is None] + for token in live: + token.revoked_at = now + if live: + with all_tenants(): + await self.db.flush() + revoked += len(live) + offset += len(tokens) + return revoked + + async def list_for_owner( + self, owner_user_id: uuid.UUID, *, limit: int = 100, offset: int = 0 + ) -> list[ApiToken]: + """A page of personal tokens *owner_user_id* holds, in any tenant. + + Across tenants on purpose: a token is its owner's, and one created + while another organisation was active must still be listable — and + revocable — from wherever the owner is now. + """ + stmt = ( + select(ApiToken) + .where( + col(ApiToken.owner_user_id) == owner_user_id, + col(ApiToken.kind) == TokenKind.PERSONAL, + ) + .order_by(ApiToken.created_at.desc(), ApiToken.id.desc()) + .limit(limit) + .offset(offset) + .execution_options(**{ALL_TENANTS_OPTION: True}) + ) + return list((await self.db.execute(stmt)).scalars()) + + async def list_tenant(self, *, limit: int = 100, offset: int = 0) -> list[ApiToken]: + """A page of tokens in the active tenant (tenant-filtered, fail-closed).""" + stmt = ( + select(ApiToken) + .order_by(ApiToken.created_at.desc(), ApiToken.id.desc()) + .limit(limit) + .offset(offset) + ) + return list((await self.db.execute(stmt)).scalars()) + + async def get_owned(self, token_id: uuid.UUID, owner_user_id: uuid.UUID) -> ApiToken | None: + stmt = ( + select(ApiToken) + .where( + col(ApiToken.id) == token_id, + col(ApiToken.owner_user_id) == owner_user_id, + col(ApiToken.kind) == TokenKind.PERSONAL, + ) + .execution_options(**{ALL_TENANTS_OPTION: True}) + ) + return (await self.db.execute(stmt)).scalar_one_or_none() + + async def get_in_tenant(self, token_id: uuid.UUID) -> ApiToken | None: + stmt = select(ApiToken).where(col(ApiToken.id) == token_id) + return (await self.db.execute(stmt)).scalar_one_or_none() + + async def revoke(self, token: ApiToken, *, cross_tenant: bool = False) -> ApiToken: + """Mark *token* revoked. Idempotent: a second revoke keeps the first time. + + ``cross_tenant`` is for an owner revoking their own token from another + tenant; the flush runs inside ``all_tenants()`` so the tenant guard + lets the row be written. Flushed here, not at commit, for that reason. + """ + if token.revoked_at is None: + token.revoked_at = _utcnow() + if cross_tenant: + with all_tenants(): + await self.db.flush() + else: + await self.db.flush() + return token + + async def owner_emails(self, tokens: Sequence[ApiToken]) -> dict[uuid.UUID, str]: + ids = {t.owner_user_id for t in tokens if t.owner_user_id is not None} + if not ids: + return {} + found = await self.directory.get_many(self.db, [str(i) for i in ids]) + return {i: found[str(i)].email for i in ids if str(i) in found} + + @staticmethod + def to_view(token: ApiToken, owner_email: str | None = None) -> TokenView: + view = TokenView.model_validate(token) + view.owner_email = owner_email + return view + + +__all__ = ["ApiTokenService", "TokenError"] diff --git a/modules/api_tokens/api_tokens/models.py b/modules/api_tokens/api_tokens/models.py index 5e26ffbc3..c5c4293d9 100644 --- a/modules/api_tokens/api_tokens/models.py +++ b/modules/api_tokens/api_tokens/models.py @@ -20,7 +20,7 @@ from sqlalchemy import Index from sqlmodel import Field -from api_tokens.constants import IX_TOKEN_HASH, NAME_MAX_LENGTH, TABLE_TOKEN, TokenKind +from api_tokens.core.constants import IX_TOKEN_HASH, NAME_MAX_LENGTH, TABLE_TOKEN, TokenKind Base = create_module_base("api_tokens") diff --git a/modules/api_tokens/api_tokens/module.py b/modules/api_tokens/api_tokens/module.py index 581c8532f..6144ccd2f 100644 --- a/modules/api_tokens/api_tokens/module.py +++ b/modules/api_tokens/api_tokens/module.py @@ -12,7 +12,7 @@ from simple_module_core.module_settings import register_module_settings from simple_module_core.tenancy import TenantRole, tenant_role -from api_tokens import constants as c +from api_tokens.core import constants as c if TYPE_CHECKING: from fastapi import APIRouter, FastAPI @@ -40,15 +40,15 @@ def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> from simple_module_core.tenant_events import TenantDeleted from simple_module_core.user_events import UserDeleted - from api_tokens.tenant_cleanup import purge_tenant_data - from api_tokens.user_cleanup import purge_user_tokens + from api_tokens.core.tenant_cleanup import purge_tenant_data + from api_tokens.core.user_cleanup import purge_user_tokens - bus.subscribe_durable(TenantDeleted, purge_tenant_data) - bus.subscribe_durable(UserDeleted, purge_user_tokens) + bus.subscribe_durable(TenantDeleted, purge_tenant_data, name=c.TENANT_CLEANUP_HANDLER) + bus.subscribe_durable(UserDeleted, purge_user_tokens, name=c.USER_CLEANUP_HANDLER) def register_settings(self, app: FastAPI) -> None: - from api_tokens.services import ApiTokensServices - from api_tokens.settings import ApiTokensSettings + from api_tokens.core.services import ApiTokensServices + from api_tokens.core.settings import ApiTokensSettings register_module_settings( app, @@ -58,16 +58,16 @@ def register_settings(self, app: FastAPI) -> None: ) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: - from api_tokens.endpoints.admin_api import router as admin_api - from api_tokens.endpoints.api import router as api - from api_tokens.endpoints.views import router as views + from api_tokens.web.endpoints.admin_api import router as admin_api + from api_tokens.web.endpoints.api import router as api + from api_tokens.web.endpoints.views import router as views api_router.include_router(api) api_router.include_router(admin_api) view_router.include_router(views) def register_admin_routes(self, admin_router: APIRouter) -> None: - from api_tokens.endpoints.views import admin_router as admin_views + from api_tokens.web.endpoints.views import admin_router as admin_views admin_router.include_router(admin_views) @@ -108,7 +108,7 @@ def register_permissions(self, registry: PermissionRegistry) -> None: registry.map_role(tenant_role(role), [c.PERM_USE]) def register_audit_links(self, registry: AuditLinkRegistry) -> None: - from api_tokens.audit import build_token_audit_link + from api_tokens.credentials.audit import build_token_audit_link registry.register(build_token_audit_link()) @@ -117,7 +117,7 @@ def locale_dirs(self) -> dict[str, Path]: return {c.LOCALE_NAMESPACE: base} async def on_startup(self, app: FastAPI) -> None: - from api_tokens.resolver import resolve_api_token + from api_tokens.credentials.resolver import resolve_api_token resolvers = app.state.auth.principal_resolvers if resolve_api_token not in resolvers: diff --git a/modules/api_tokens/api_tokens/pages/Admin.tsx b/modules/api_tokens/api_tokens/pages/Admin.tsx index 0182b22b7..c26d20f71 100644 --- a/modules/api_tokens/api_tokens/pages/Admin.tsx +++ b/modules/api_tokens/api_tokens/pages/Admin.tsx @@ -7,14 +7,14 @@ import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout'; import { apiResult } from '@simple-module-py/ui/lib/api'; import { Building2, Plus } from 'lucide-react'; import { useState } from 'react'; +import { CreatedTokenDialog } from '../ui/components/CreatedTokenDialog'; +import { CreateTokenDialog } from '../ui/components/CreateTokenDialog'; +import { RevokeTokenDialog } from '../ui/components/RevokeTokenDialog'; +import { TokenPagination } from '../ui/components/TokenPagination'; +import { TokenTable } from '../ui/components/TokenTable'; +import { useTokenActions } from '../ui/components/useTokenActions'; +import type { CreatePayload, TokenFormProps } from '../ui/types'; import { api, csrfHeaders } from './api'; -import { CreatedTokenDialog } from './components/CreatedTokenDialog'; -import { CreateTokenDialog } from './components/CreateTokenDialog'; -import { RevokeTokenDialog } from './components/RevokeTokenDialog'; -import { TokenPagination } from './components/TokenPagination'; -import { TokenTable } from './components/TokenTable'; -import { useTokenActions } from './components/useTokenActions'; -import type { CreatePayload, TokenFormProps } from './types'; interface Props extends TokenFormProps { roles: string[]; diff --git a/modules/api_tokens/api_tokens/pages/Index.tsx b/modules/api_tokens/api_tokens/pages/Index.tsx index 0da9707c0..8646056dc 100644 --- a/modules/api_tokens/api_tokens/pages/Index.tsx +++ b/modules/api_tokens/api_tokens/pages/Index.tsx @@ -7,14 +7,14 @@ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedL import { apiResult } from '@simple-module-py/ui/lib/api'; import { Ban, Plus } from 'lucide-react'; import { useState } from 'react'; +import { CreatedTokenDialog } from '../ui/components/CreatedTokenDialog'; +import { CreateTokenDialog } from '../ui/components/CreateTokenDialog'; +import { RevokeTokenDialog } from '../ui/components/RevokeTokenDialog'; +import { TokenPagination } from '../ui/components/TokenPagination'; +import { TokenTable } from '../ui/components/TokenTable'; +import { useTokenActions } from '../ui/components/useTokenActions'; +import { type CreatePayload, type TokenFormProps, tokenStatus } from '../ui/types'; import { api, csrfHeaders } from './api'; -import { CreatedTokenDialog } from './components/CreatedTokenDialog'; -import { CreateTokenDialog } from './components/CreateTokenDialog'; -import { RevokeTokenDialog } from './components/RevokeTokenDialog'; -import { TokenPagination } from './components/TokenPagination'; -import { TokenTable } from './components/TokenTable'; -import { useTokenActions } from './components/useTokenActions'; -import { type CreatePayload, type TokenFormProps, tokenStatus } from './types'; /** Your own personal access tokens: list, create (secret shown once), revoke. */ function Index(props: TokenFormProps) { diff --git a/modules/api_tokens/api_tokens/resolver.py b/modules/api_tokens/api_tokens/resolver.py index 66ebd1558..f8b3c3d3c 100644 --- a/modules/api_tokens/api_tokens/resolver.py +++ b/modules/api_tokens/api_tokens/resolver.py @@ -1,160 +1,11 @@ -"""The principal resolver: ``Authorization: Bearer smpat_…`` / ``smsk_…`` → principal. - -Appended to ``app.state.auth.principal_resolvers`` at startup. The ``users`` -and ``keycloak`` providers return ``None`` for these prefixes, which is what -hands the request to this resolver. - -Per request: a regex check, one indexed lookup by hash (no cache, so a revoke -is effective on the very next request), and for a personal token one read of -the owner with their roles through the app's user directory. Everything that -is not a usable token — unknown, revoked, expired, an inactive or disabled -owner, an owner whose roles the directory cannot read — is ``None``, which the -middleware turns into a 401. -""" +"""Moved to :mod:`api_tokens.credentials.resolver`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -import logging -from datetime import UTC, datetime, timedelta -from typing import Any - -from auth.contracts.schemas import UserContext -from auth.contracts.tokens import AUTH_KIND_PAT, AUTH_KIND_SERVICE, is_api_token, parse_bearer -from simple_module_core.tenancy import is_tenant_role -from simple_module_core.user_directory import UserDirectory, get_user_directory -from simple_module_db import ALL_TENANTS_OPTION, independent_session -from sqlalchemy import or_, select, update -from sqlmodel import col -from starlette.requests import Request - -from api_tokens.constants import ( - FORBIDDEN_SERVICE_ROLES, - LAST_USED_WRITE_INTERVAL_SECONDS, - PERM_USE, - TokenKind, -) -from api_tokens.models import ApiToken -from api_tokens.owner import usable_owner -from api_tokens.tokens import hash_token, is_well_formed - -logger = logging.getLogger(__name__) - -SERVICE_PRINCIPAL_PREFIX = "svc:" -"""Service principals are ``svc:``: not a user UUID, so they hold no -direct grants and can never be mistaken for an account.""" - - -def _bearer(request: Request) -> str | None: - credential = parse_bearer(request.headers.get("authorization")) - if credential is None or not is_api_token(credential) or not is_well_formed(credential): - return None - return credential - - -def _usable(token: ApiToken, now: datetime) -> bool: - return token.revoked_at is None and token.expires_at > now - - -async def _personal(db: Any, directory: UserDirectory, token: ApiToken) -> UserContext | None: - if token.owner_user_id is None: - return None - owner_id = str(token.owner_user_id) - user = await usable_owner(directory, db, owner_id) - if user is None: - return None - principal = UserContext( - id=owner_id, - email=user.email, - name=user.label, - roles=list(user.roles or ()), - auth_kind=AUTH_KIND_PAT, - scopes=list(token.scopes), - ) - # Pinned to the tenant it was created in, like a service key: the tenant - # resolver acts for this tenant only (and only while the owner is still an - # active member of it), never one named by a header, subdomain or session. - principal.tenant_id = token.tenant_id - # A personal token lives only as long as its owner may hold tokens: losing - # ``api_tokens.use`` (which also takes away listing and revoking them) - # retires every token they own, checked against their live, tenant-aware - # permissions on each request (``lacks_required_permission``). - principal.required_permission = PERM_USE - return principal - - -def _service(token: ApiToken) -> UserContext: - return UserContext( - id=f"{SERVICE_PRINCIPAL_PREFIX}{token.id}", - email="", - name=token.name, - # Refused at creation; dropped here too so a hand-edited row cannot - # turn a service key into an admin — or a tenant owner: ``tenant:*`` - # roles come only from a membership, which a service key never has. - roles=[ - r for r in token.roles if r not in FORBIDDEN_SERVICE_ROLES and not is_tenant_role(r) - ], - tenant_id=token.tenant_id, - auth_kind=AUTH_KIND_SERVICE, - scopes=list(token.scopes), - ) - - -async def touch_last_used(app: Any, token: ApiToken, now: datetime) -> None: - """Record use at most once per interval, in its own short transaction. - - The ``WHERE`` repeats the staleness test, so concurrent requests on one - token race to a single write rather than one each. ``updated_at`` is set - to itself: it would otherwise be bumped by its ``onupdate`` and stop - meaning "last edited". A failure here never fails the request. - """ - cutoff = now - timedelta(seconds=LAST_USED_WRITE_INTERVAL_SECONDS) - if token.last_used_at is not None and token.last_used_at > cutoff: - return - stmt = ( - update(ApiToken) - .where( - col(ApiToken.id) == token.id, - or_(ApiToken.last_used_at.is_(None), ApiToken.last_used_at <= cutoff), - ) - .values(last_used_at=now, updated_at=ApiToken.updated_at) - .execution_options(**{ALL_TENANTS_OPTION: True}) - ) - try: - async with independent_session(app) as db: - await db.execute(stmt) - except Exception: - logger.warning("api_tokens: could not record last use of %s", token.id, exc_info=True) - - -async def resolve_api_token(request: Request) -> UserContext | None: - """``PrincipalResolver`` for ``api_tokens`` credentials. Never raises.""" - credential = _bearer(request) - if credential is None: - return None - now = datetime.now(UTC) - app = request.app - try: - async with app.state.sm.db.session_factory() as db: - stmt = ( - select(ApiToken) - .where(col(ApiToken.token_hash) == hash_token(credential)) - .execution_options(**{ALL_TENANTS_OPTION: True}) - ) - token = (await db.execute(stmt)).scalar_one_or_none() - if token is None or not _usable(token, now): - return None - if token.kind == TokenKind.PERSONAL: - principal = await _personal(db, get_user_directory(app), token) - elif token.kind == TokenKind.SERVICE: - principal = _service(token) - else: - principal = None - except Exception: - logger.exception("api_tokens: token lookup failed; treating as unauthenticated") - return None - if principal is not None: - await touch_last_used(app, token, now) - return principal +import importlib +import sys +from simple_module_core.deprecation import deprecated_module -__all__ = ["SERVICE_PRINCIPAL_PREFIX", "resolve_api_token", "touch_last_used"] +deprecated_module("api_tokens.resolver", remove_in="0.0.46", use="api_tokens.credentials.resolver") +sys.modules[__name__] = importlib.import_module("api_tokens.credentials.resolver") diff --git a/modules/api_tokens/api_tokens/service.py b/modules/api_tokens/api_tokens/service.py index e7ca8f062..abe0f89ee 100644 --- a/modules/api_tokens/api_tokens/service.py +++ b/modules/api_tokens/api_tokens/service.py @@ -1,190 +1,11 @@ -"""Creating, listing and revoking API tokens. - -Authorisation decisions — who may create what, which scopes a creator may -hand out — are made by the caller (``api_tokens.scopes`` and the endpoints); -this layer enforces only the invariants every token must satisfy wherever it -came from: a non-empty scope list, no ``admin`` role on a service key, an -expiry within ``max_lifetime_days``. -""" +"""Moved to :mod:`api_tokens.credentials.service`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -import uuid -from collections.abc import Sequence -from datetime import timedelta - -from simple_module_core.datetimes import utcnow as _utcnow -from simple_module_core.user_directory import NullUserDirectory, UserDirectory -from simple_module_db import ALL_TENANTS_OPTION, all_tenants -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import col - -from api_tokens.constants import FORBIDDEN_SERVICE_ROLES, TokenKind -from api_tokens.contracts.schemas import TokenView -from api_tokens.models import ApiToken -from api_tokens.owner import usable_owner -from api_tokens.tokens import generate - - -class TokenError(Exception): - """A request the service refuses. ``key`` is an ``api_tokens.errors.*`` key.""" - - def __init__(self, key: str, **params: object) -> None: - super().__init__(key) - self.key = key - self.params = params - - -class ApiTokenService: - def __init__(self, db: AsyncSession, directory: UserDirectory | None = None) -> None: - self.db = db - # Owners are read through the app's user directory (GH #501); without - # one, nobody has a usable account and owners show unnamed. - self.directory: UserDirectory = directory or NullUserDirectory() - - async def create( - self, - *, - kind: TokenKind, - name: str, - scopes: Sequence[str], - expires_in_days: int, - max_lifetime_days: int, - owner_user_id: uuid.UUID | None = None, - roles: Sequence[str] = (), - ) -> tuple[ApiToken, str]: - """Persist a new token; return it with its plaintext (shown once).""" - if not scopes: - raise TokenError("api_tokens.errors.scopes_required") - if not 1 <= expires_in_days <= max_lifetime_days: - raise TokenError("api_tokens.errors.lifetime_too_long", days=max_lifetime_days) - if kind is TokenKind.PERSONAL: - if owner_user_id is None or roles: - raise TokenError("api_tokens.errors.invalid_kind") - elif owner_user_id is not None: - raise TokenError("api_tokens.errors.invalid_kind") - forbidden = sorted(set(roles) & FORBIDDEN_SERVICE_ROLES) - if forbidden: - raise TokenError("api_tokens.errors.role_forbidden", role=forbidden[0]) - - new = generate(kind) - token = ApiToken( - kind=kind, - name=name, - prefix=new.prefix, - token_hash=new.token_hash, - owner_user_id=owner_user_id, - scopes=sorted(set(scopes)), - roles=sorted(set(roles)), - expires_at=_utcnow() + timedelta(days=expires_in_days), - ) - self.db.add(token) - await self.db.flush() - return token, new.plaintext - - async def has_usable_account(self, user_id: uuid.UUID) -> bool: - """Whether *user_id* is an active account the resolver can turn into a principal. - - That takes a directory entry that is active **and** whose roles are - known: under a provider that keeps roles in the IdP (Keycloak), a token - minted for the id would be dead on arrival, since the resolver refuses - a personal token whose owner's roles it cannot read. - """ - return await usable_owner(self.directory, self.db, str(user_id)) is not None - - async def revoke_all_for_owner(self, owner_user_id: uuid.UUID) -> int: - """Revoke every live personal token *owner_user_id* holds, in any tenant.""" - revoked = 0 - offset = 0 - now = _utcnow() - while tokens := await self.list_for_owner(owner_user_id, offset=offset): - live = [t for t in tokens if t.revoked_at is None] - for token in live: - token.revoked_at = now - if live: - with all_tenants(): - await self.db.flush() - revoked += len(live) - offset += len(tokens) - return revoked - - async def list_for_owner( - self, owner_user_id: uuid.UUID, *, limit: int = 100, offset: int = 0 - ) -> list[ApiToken]: - """A page of personal tokens *owner_user_id* holds, in any tenant. - - Across tenants on purpose: a token is its owner's, and one created - while another organisation was active must still be listable — and - revocable — from wherever the owner is now. - """ - stmt = ( - select(ApiToken) - .where( - col(ApiToken.owner_user_id) == owner_user_id, - col(ApiToken.kind) == TokenKind.PERSONAL, - ) - .order_by(ApiToken.created_at.desc(), ApiToken.id.desc()) - .limit(limit) - .offset(offset) - .execution_options(**{ALL_TENANTS_OPTION: True}) - ) - return list((await self.db.execute(stmt)).scalars()) - - async def list_tenant(self, *, limit: int = 100, offset: int = 0) -> list[ApiToken]: - """A page of tokens in the active tenant (tenant-filtered, fail-closed).""" - stmt = ( - select(ApiToken) - .order_by(ApiToken.created_at.desc(), ApiToken.id.desc()) - .limit(limit) - .offset(offset) - ) - return list((await self.db.execute(stmt)).scalars()) - - async def get_owned(self, token_id: uuid.UUID, owner_user_id: uuid.UUID) -> ApiToken | None: - stmt = ( - select(ApiToken) - .where( - col(ApiToken.id) == token_id, - col(ApiToken.owner_user_id) == owner_user_id, - col(ApiToken.kind) == TokenKind.PERSONAL, - ) - .execution_options(**{ALL_TENANTS_OPTION: True}) - ) - return (await self.db.execute(stmt)).scalar_one_or_none() - - async def get_in_tenant(self, token_id: uuid.UUID) -> ApiToken | None: - stmt = select(ApiToken).where(col(ApiToken.id) == token_id) - return (await self.db.execute(stmt)).scalar_one_or_none() - - async def revoke(self, token: ApiToken, *, cross_tenant: bool = False) -> ApiToken: - """Mark *token* revoked. Idempotent: a second revoke keeps the first time. - - ``cross_tenant`` is for an owner revoking their own token from another - tenant; the flush runs inside ``all_tenants()`` so the tenant guard - lets the row be written. Flushed here, not at commit, for that reason. - """ - if token.revoked_at is None: - token.revoked_at = _utcnow() - if cross_tenant: - with all_tenants(): - await self.db.flush() - else: - await self.db.flush() - return token - - async def owner_emails(self, tokens: Sequence[ApiToken]) -> dict[uuid.UUID, str]: - ids = {t.owner_user_id for t in tokens if t.owner_user_id is not None} - if not ids: - return {} - found = await self.directory.get_many(self.db, [str(i) for i in ids]) - return {i: found[str(i)].email for i in ids if str(i) in found} - - @staticmethod - def to_view(token: ApiToken, owner_email: str | None = None) -> TokenView: - view = TokenView.model_validate(token) - view.owner_email = owner_email - return view +import importlib +import sys +from simple_module_core.deprecation import deprecated_module -__all__ = ["ApiTokenService", "TokenError"] +deprecated_module("api_tokens.service", remove_in="0.0.46", use="api_tokens.credentials.service") +sys.modules[__name__] = importlib.import_module("api_tokens.credentials.service") diff --git a/modules/api_tokens/api_tokens/tokens.py b/modules/api_tokens/api_tokens/tokens.py index 4585f4e79..b0ae44a97 100644 --- a/modules/api_tokens/api_tokens/tokens.py +++ b/modules/api_tokens/api_tokens/tokens.py @@ -1,83 +1,11 @@ -"""Token format, generation and hashing — no I/O. - -A token is ````: ``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. -""" +"""Moved to :mod:`api_tokens.credentials.format`; this alias goes in 0.0.46 (module layout).""" 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.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)) +import importlib +import sys +from simple_module_core.deprecation import deprecated_module -__all__ = ["NewToken", "base62", "display_prefix", "generate", "hash_token", "is_well_formed"] +deprecated_module("api_tokens.tokens", remove_in="0.0.46", use="api_tokens.credentials.format") +sys.modules[__name__] = importlib.import_module("api_tokens.credentials.format") diff --git a/modules/api_tokens/api_tokens/pages/components/CreateTokenDialog.tsx b/modules/api_tokens/api_tokens/ui/components/CreateTokenDialog.tsx similarity index 100% rename from modules/api_tokens/api_tokens/pages/components/CreateTokenDialog.tsx rename to modules/api_tokens/api_tokens/ui/components/CreateTokenDialog.tsx diff --git a/modules/api_tokens/api_tokens/pages/components/CreatedTokenDialog.tsx b/modules/api_tokens/api_tokens/ui/components/CreatedTokenDialog.tsx similarity index 100% rename from modules/api_tokens/api_tokens/pages/components/CreatedTokenDialog.tsx rename to modules/api_tokens/api_tokens/ui/components/CreatedTokenDialog.tsx diff --git a/modules/api_tokens/api_tokens/pages/components/RevokeTokenDialog.tsx b/modules/api_tokens/api_tokens/ui/components/RevokeTokenDialog.tsx similarity index 100% rename from modules/api_tokens/api_tokens/pages/components/RevokeTokenDialog.tsx rename to modules/api_tokens/api_tokens/ui/components/RevokeTokenDialog.tsx diff --git a/modules/api_tokens/api_tokens/pages/components/TokenPagination.tsx b/modules/api_tokens/api_tokens/ui/components/TokenPagination.tsx similarity index 100% rename from modules/api_tokens/api_tokens/pages/components/TokenPagination.tsx rename to modules/api_tokens/api_tokens/ui/components/TokenPagination.tsx diff --git a/modules/api_tokens/api_tokens/pages/components/TokenTable.tsx b/modules/api_tokens/api_tokens/ui/components/TokenTable.tsx similarity index 100% rename from modules/api_tokens/api_tokens/pages/components/TokenTable.tsx rename to modules/api_tokens/api_tokens/ui/components/TokenTable.tsx diff --git a/modules/api_tokens/api_tokens/pages/components/useTokenActions.ts b/modules/api_tokens/api_tokens/ui/components/useTokenActions.ts similarity index 100% rename from modules/api_tokens/api_tokens/pages/components/useTokenActions.ts rename to modules/api_tokens/api_tokens/ui/components/useTokenActions.ts diff --git a/modules/api_tokens/api_tokens/pages/types.ts b/modules/api_tokens/api_tokens/ui/types.ts similarity index 100% rename from modules/api_tokens/api_tokens/pages/types.ts rename to modules/api_tokens/api_tokens/ui/types.ts diff --git a/modules/api_tokens/api_tokens/web/__init__.py b/modules/api_tokens/api_tokens/web/__init__.py new file mode 100644 index 000000000..4c23ab2e1 --- /dev/null +++ b/modules/api_tokens/api_tokens/web/__init__.py @@ -0,0 +1 @@ +"""The HTTP side: request deps and the REST, admin and Inertia endpoints.""" diff --git a/modules/api_tokens/api_tokens/deps.py b/modules/api_tokens/api_tokens/web/deps.py similarity index 95% rename from modules/api_tokens/api_tokens/deps.py rename to modules/api_tokens/api_tokens/web/deps.py index b935a12b8..b4d7284d1 100644 --- a/modules/api_tokens/api_tokens/deps.py +++ b/modules/api_tokens/api_tokens/web/deps.py @@ -13,8 +13,8 @@ from simple_module_hosting.i18n_deps import TranslatorDep from sqlalchemy.ext.asyncio import AsyncSession -from api_tokens.service import ApiTokenService, TokenError -from api_tokens.settings import ApiTokensSettings +from api_tokens.core.settings import ApiTokensSettings +from api_tokens.credentials.service import ApiTokenService, TokenError async def get_api_token_service( diff --git a/modules/api_tokens/api_tokens/endpoints/__init__.py b/modules/api_tokens/api_tokens/web/endpoints/__init__.py similarity index 100% rename from modules/api_tokens/api_tokens/endpoints/__init__.py rename to modules/api_tokens/api_tokens/web/endpoints/__init__.py diff --git a/modules/api_tokens/api_tokens/endpoints/admin_api.py b/modules/api_tokens/api_tokens/web/endpoints/admin_api.py similarity index 90% rename from modules/api_tokens/api_tokens/endpoints/admin_api.py rename to modules/api_tokens/api_tokens/web/endpoints/admin_api.py index 8dab72055..c6c7ad40e 100644 --- a/modules/api_tokens/api_tokens/endpoints/admin_api.py +++ b/modules/api_tokens/api_tokens/web/endpoints/admin_api.py @@ -15,11 +15,16 @@ from simple_module_hosting.i18n_deps import TranslatorDep from simple_module_hosting.permissions import RequiresPermission -from api_tokens.constants import FORBIDDEN_SERVICE_ROLES, PERM_MANAGE, TokenKind from api_tokens.contracts.schemas import CreatedToken, ServiceKeyCreate, TokenView -from api_tokens.deps import ApiTokenServiceDep, SettingsDep, http_error, require_session_principal -from api_tokens.scopes import assignable_roles, assignable_scopes, check_subset -from api_tokens.service import ApiTokenService, TokenError +from api_tokens.core.constants import FORBIDDEN_SERVICE_ROLES, PERM_MANAGE, TokenKind +from api_tokens.credentials.scopes import assignable_roles, assignable_scopes, check_subset +from api_tokens.credentials.service import ApiTokenService, TokenError +from api_tokens.web.deps import ( + ApiTokenServiceDep, + SettingsDep, + http_error, + require_session_principal, +) router = APIRouter( prefix="/admin", diff --git a/modules/api_tokens/api_tokens/endpoints/api.py b/modules/api_tokens/api_tokens/web/endpoints/api.py similarity index 94% rename from modules/api_tokens/api_tokens/endpoints/api.py rename to modules/api_tokens/api_tokens/web/endpoints/api.py index e6d49c7ff..0fffb1eb0 100644 --- a/modules/api_tokens/api_tokens/endpoints/api.py +++ b/modules/api_tokens/api_tokens/web/endpoints/api.py @@ -15,14 +15,16 @@ from simple_module_hosting.i18n_deps import TranslatorDep from simple_module_hosting.permissions import RequiresPermission -from api_tokens.constants import PERM_USE, TokenKind from api_tokens.contracts.schemas import ( CreatedToken, PersonalTokenCreate, RevokedCount, TokenView, ) -from api_tokens.deps import ( +from api_tokens.core.constants import PERM_USE, TokenKind +from api_tokens.credentials.scopes import assignable_scopes, check_subset +from api_tokens.credentials.service import TokenError +from api_tokens.web.deps import ( ApiTokenServiceDep, SessionPrincipal, SettingsDep, @@ -30,8 +32,6 @@ require_session_principal, user_uuid, ) -from api_tokens.scopes import assignable_scopes, check_subset -from api_tokens.service import TokenError router = APIRouter( prefix="/tokens", diff --git a/modules/api_tokens/api_tokens/endpoints/views.py b/modules/api_tokens/api_tokens/web/endpoints/views.py similarity index 93% rename from modules/api_tokens/api_tokens/endpoints/views.py rename to modules/api_tokens/api_tokens/web/endpoints/views.py index 7ab8c7b22..4e0dc36b5 100644 --- a/modules/api_tokens/api_tokens/endpoints/views.py +++ b/modules/api_tokens/api_tokens/web/endpoints/views.py @@ -15,22 +15,22 @@ from simple_module_hosting.permissions import RequiresPermission from simple_module_inertia import InertiaResponse -from api_tokens.constants import ( +from api_tokens.core.constants import ( EXPIRY_PRESETS_DAYS, PAGE_ADMIN, PAGE_INDEX, PERM_MANAGE, PERM_USE, ) -from api_tokens.deps import ( +from api_tokens.credentials.scopes import assignable_roles, assignable_scopes +from api_tokens.web.deps import ( ApiTokenServiceDep, SessionPrincipal, SettingsDep, require_session_principal, user_uuid, ) -from api_tokens.endpoints.admin_api import tenant_token_views -from api_tokens.scopes import assignable_roles, assignable_scopes +from api_tokens.web.endpoints.admin_api import tenant_token_views router = APIRouter( dependencies=[Depends(require_session_principal), Depends(RequiresPermission(PERM_USE))] diff --git a/modules/api_tokens/tests-js/api_tokens_CreatedTokenDialog.test.tsx b/modules/api_tokens/tests-js/api_tokens_CreatedTokenDialog.test.tsx index 30879b1c3..cb9ffc0ac 100644 --- a/modules/api_tokens/tests-js/api_tokens_CreatedTokenDialog.test.tsx +++ b/modules/api_tokens/tests-js/api_tokens_CreatedTokenDialog.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; configureI18n({ locale: 'en', messages: {} }); -import { CreatedTokenDialog } from '../api_tokens/pages/components/CreatedTokenDialog'; +import { CreatedTokenDialog } from '../api_tokens/ui/components/CreatedTokenDialog'; const SECRET = 'smpy_pat_0123456789abcdef'; const c = keys.api_tokens.created; diff --git a/modules/api_tokens/tests-js/fixtures.ts b/modules/api_tokens/tests-js/fixtures.ts index a06bf49cd..67c814fd4 100644 --- a/modules/api_tokens/tests-js/fixtures.ts +++ b/modules/api_tokens/tests-js/fixtures.ts @@ -1,5 +1,5 @@ import { vi } from 'vitest'; -import type { ApiToken } from '../api_tokens/pages/types'; +import type { ApiToken } from '../api_tokens/ui/types'; export function token(over: Partial = {}): ApiToken { return { diff --git a/modules/api_tokens/tests/test_api_tokens_tenant_cleanup.py b/modules/api_tokens/tests/core/cleanup/test_api_tokens_tenant_cleanup.py similarity index 100% rename from modules/api_tokens/tests/test_api_tokens_tenant_cleanup.py rename to modules/api_tokens/tests/core/cleanup/test_api_tokens_tenant_cleanup.py diff --git a/modules/api_tokens/tests/test_api_tokens_user_cleanup.py b/modules/api_tokens/tests/core/cleanup/test_api_tokens_user_cleanup.py similarity index 97% rename from modules/api_tokens/tests/test_api_tokens_user_cleanup.py rename to modules/api_tokens/tests/core/cleanup/test_api_tokens_user_cleanup.py index 89510147e..85f28d3d1 100644 --- a/modules/api_tokens/tests/test_api_tokens_user_cleanup.py +++ b/modules/api_tokens/tests/core/cleanup/test_api_tokens_user_cleanup.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta import pytest -from api_tokens.constants import TokenKind +from api_tokens.core.constants import TokenKind from api_tokens.models import ApiToken from api_tokens.module import ApiTokensModule from simple_module_core.events import EventBus diff --git a/modules/api_tokens/tests/test_api_tokens_format.py b/modules/api_tokens/tests/credentials/format/test_api_tokens_format.py similarity index 95% rename from modules/api_tokens/tests/test_api_tokens_format.py rename to modules/api_tokens/tests/credentials/format/test_api_tokens_format.py index 9aeeed204..5883e1aa0 100644 --- a/modules/api_tokens/tests/test_api_tokens_format.py +++ b/modules/api_tokens/tests/credentials/format/test_api_tokens_format.py @@ -5,8 +5,14 @@ import hashlib from types import SimpleNamespace -from api_tokens.constants import SECRET_LENGTH, TokenKind -from api_tokens.tokens import base62, display_prefix, generate, hash_token, is_well_formed +from api_tokens.core.constants import SECRET_LENGTH, TokenKind +from api_tokens.credentials.format import ( + base62, + display_prefix, + generate, + hash_token, + is_well_formed, +) from auth.contracts.schemas import UserContext from auth.contracts.tokens import is_api_token from simple_module_core.permissions import is_admin_principal diff --git a/modules/api_tokens/tests/test_api_tokens_hardening.py b/modules/api_tokens/tests/credentials/resolver/test_api_tokens_hardening.py similarity index 97% rename from modules/api_tokens/tests/test_api_tokens_hardening.py rename to modules/api_tokens/tests/credentials/resolver/test_api_tokens_hardening.py index a4095e018..9508c849a 100644 --- a/modules/api_tokens/tests/test_api_tokens_hardening.py +++ b/modules/api_tokens/tests/credentials/resolver/test_api_tokens_hardening.py @@ -149,9 +149,9 @@ async def test_session_with_an_authorization_header_cannot_mint(actor): def test_service_key_drops_stored_tenant_and_admin_roles(): from datetime import UTC, datetime, timedelta - from api_tokens.constants import TokenKind + from api_tokens.core.constants import TokenKind + from api_tokens.credentials.resolver import _service from api_tokens.models import ApiToken - from api_tokens.resolver import _service row = ApiToken( id=uuid.uuid4(), @@ -168,7 +168,7 @@ def test_service_key_drops_stored_tenant_and_admin_roles(): async def test_no_pat_for_an_account_the_resolver_cannot_load(app, actor, monkeypatch): - from api_tokens.service import ApiTokenService + from api_tokens.credentials.service import ApiTokenService async def missing(self, user_id) -> bool: return False @@ -185,7 +185,7 @@ async def missing(self, user_id) -> bool: async def test_has_usable_account(app): - from api_tokens.service import ApiTokenService + from api_tokens.credentials.service import ApiTokenService from simple_module_core.user_directory import get_user_directory user_id = await create_user(app, f"u-{uuid.uuid4().hex[:6]}@example.com") diff --git a/modules/api_tokens/tests/test_api_tokens_identity.py b/modules/api_tokens/tests/credentials/resolver/test_api_tokens_identity.py similarity index 100% rename from modules/api_tokens/tests/test_api_tokens_identity.py rename to modules/api_tokens/tests/credentials/resolver/test_api_tokens_identity.py diff --git a/modules/api_tokens/tests/test_api_tokens_resolver.py b/modules/api_tokens/tests/credentials/resolver/test_api_tokens_resolver.py similarity index 96% rename from modules/api_tokens/tests/test_api_tokens_resolver.py rename to modules/api_tokens/tests/credentials/resolver/test_api_tokens_resolver.py index ccb90ad82..57d31ba99 100644 --- a/modules/api_tokens/tests/test_api_tokens_resolver.py +++ b/modules/api_tokens/tests/credentials/resolver/test_api_tokens_resolver.py @@ -7,8 +7,8 @@ import pytest from _api_tokens_support import FLAGS_URL, create_pat, load_token, update_token -from api_tokens.constants import TokenKind -from api_tokens.tokens import generate +from api_tokens.core.constants import TokenKind +from api_tokens.credentials.format import generate from starlette.requests import Request @@ -90,7 +90,7 @@ async def test_disabled_owner_is_refused(app, actor, bearer): async def test_token_survives_a_fresh_resolver_state(app, actor): """Nothing is cached in-process: a restart only needs the database row.""" - from api_tokens.resolver import resolve_api_token + from api_tokens.credentials.resolver import resolve_api_token async with actor(admin=True) as a: token = (await create_pat(a.client, ["feature_flags.view"]))["token"] @@ -156,7 +156,7 @@ async def spy(scope, token): async def test_personal_token_tenant_answer_varies_on_authorization(app, actor): """The tenant chosen for a PAT depended on its Authorization header, not a cookie.""" - from api_tokens.resolver import resolve_api_token + from api_tokens.credentials.resolver import resolve_api_token from tenants.resolver import resolve_tenant async with actor(admin=True) as a: diff --git a/modules/api_tokens/tests/test_api_tokens_scopes.py b/modules/api_tokens/tests/credentials/scopes/test_api_tokens_scopes.py similarity index 100% rename from modules/api_tokens/tests/test_api_tokens_scopes.py rename to modules/api_tokens/tests/credentials/scopes/test_api_tokens_scopes.py diff --git a/modules/api_tokens/tests/test_api_tokens_directory.py b/modules/api_tokens/tests/credentials/service/test_api_tokens_directory.py similarity index 94% rename from modules/api_tokens/tests/test_api_tokens_directory.py rename to modules/api_tokens/tests/credentials/service/test_api_tokens_directory.py index 5c00f3756..7e99e2a39 100644 --- a/modules/api_tokens/tests/test_api_tokens_directory.py +++ b/modules/api_tokens/tests/credentials/service/test_api_tokens_directory.py @@ -5,10 +5,10 @@ import uuid from datetime import UTC, datetime, timedelta -from api_tokens.constants import PERM_USE, TokenKind +from api_tokens.core.constants import PERM_USE, TokenKind +from api_tokens.credentials.resolver import _personal +from api_tokens.credentials.service import ApiTokenService from api_tokens.models import ApiToken -from api_tokens.resolver import _personal -from api_tokens.service import ApiTokenService from auth.contracts.tokens import AUTH_KIND_PAT from simple_module_core.user_directory import DirectoryUser, NullUserDirectory diff --git a/modules/api_tokens/tests/test_api_tokens_service_keys.py b/modules/api_tokens/tests/credentials/service/test_api_tokens_service_keys.py similarity index 98% rename from modules/api_tokens/tests/test_api_tokens_service_keys.py rename to modules/api_tokens/tests/credentials/service/test_api_tokens_service_keys.py index 05eba3465..94a1552a7 100644 --- a/modules/api_tokens/tests/test_api_tokens_service_keys.py +++ b/modules/api_tokens/tests/credentials/service/test_api_tokens_service_keys.py @@ -56,7 +56,7 @@ async def test_only_managers_create_service_keys(actor): async def test_service_principal_is_pinned_to_its_tenant(app, actor): - from api_tokens.resolver import resolve_api_token + from api_tokens.credentials.resolver import resolve_api_token from tenants.resolver import resolve_tenant async with actor(admin=True) as a: diff --git a/modules/api_tokens/tests/test_api_tokens_signup.py b/modules/api_tokens/tests/credentials/service/test_api_tokens_signup.py similarity index 98% rename from modules/api_tokens/tests/test_api_tokens_signup.py rename to modules/api_tokens/tests/credentials/service/test_api_tokens_signup.py index 7e3fabd42..cbff69a96 100644 --- a/modules/api_tokens/tests/test_api_tokens_signup.py +++ b/modules/api_tokens/tests/credentials/service/test_api_tokens_signup.py @@ -59,7 +59,7 @@ async def test_registration_attaches_the_user_role(app, client): async def test_registered_user_can_open_the_tokens_page(app, client): - from api_tokens.constants import USER_URL + from api_tokens.core.constants import USER_URL user_id = await _register(app, client) async with session_client(app, {"user_id": user_id}) as c: diff --git a/modules/api_tokens/tests/test_api_tokens_endpoints.py b/modules/api_tokens/tests/web/endpoints/test_api_tokens_endpoints.py similarity index 100% rename from modules/api_tokens/tests/test_api_tokens_endpoints.py rename to modules/api_tokens/tests/web/endpoints/test_api_tokens_endpoints.py diff --git a/modules/api_tokens/tests/test_api_tokens_pagination.py b/modules/api_tokens/tests/web/endpoints/test_api_tokens_pagination.py similarity index 96% rename from modules/api_tokens/tests/test_api_tokens_pagination.py rename to modules/api_tokens/tests/web/endpoints/test_api_tokens_pagination.py index a47a8a1ef..e82e149b2 100644 --- a/modules/api_tokens/tests/test_api_tokens_pagination.py +++ b/modules/api_tokens/tests/web/endpoints/test_api_tokens_pagination.py @@ -4,8 +4,8 @@ import pytest from _api_tokens_support import INERTIA_HEADERS, PAT_URL, create_pat -from api_tokens.constants import TokenKind -from api_tokens.service import ApiTokenService +from api_tokens.core.constants import TokenKind +from api_tokens.credentials.service import ApiTokenService from simple_module_db.tenancy import tenant_context diff --git a/modules/api_tokens/tests/test_api_tokens_route_guard.py b/modules/api_tokens/tests/web/endpoints/test_api_tokens_route_guard.py similarity index 100% rename from modules/api_tokens/tests/test_api_tokens_route_guard.py rename to modules/api_tokens/tests/web/endpoints/test_api_tokens_route_guard.py diff --git a/modules/keycloak/tests/identity/provider/test_keycloak_api_tokens.py b/modules/keycloak/tests/identity/provider/test_keycloak_api_tokens.py index c478f1bf2..7dce32f45 100644 --- a/modules/keycloak/tests/identity/provider/test_keycloak_api_tokens.py +++ b/modules/keycloak/tests/identity/provider/test_keycloak_api_tokens.py @@ -10,10 +10,10 @@ from datetime import UTC, datetime, timedelta -from api_tokens.constants import TokenKind +from api_tokens.core.constants import TokenKind +from api_tokens.credentials.format import generate +from api_tokens.credentials.resolver import SERVICE_PRINCIPAL_PREFIX, resolve_api_token from api_tokens.models import ApiToken -from api_tokens.resolver import SERVICE_PRINCIPAL_PREFIX, resolve_api_token -from api_tokens.tokens import generate from auth.contracts.tokens import AUTH_KIND_SERVICE from keycloak.models import KeycloakUserCache from keycloak_test_modules import keycloak_modules_enabled diff --git a/modules/users/tests/demo/test_users_demo_credentials.py b/modules/users/tests/demo/test_users_demo_credentials.py index 9e5ecd274..ff20947a2 100644 --- a/modules/users/tests/demo/test_users_demo_credentials.py +++ b/modules/users/tests/demo/test_users_demo_credentials.py @@ -2,8 +2,8 @@ import httpx import pytest -from api_tokens.constants import TokenKind -from api_tokens.service import ApiTokenService +from api_tokens.core.constants import TokenKind +from api_tokens.credentials.service import ApiTokenService from simple_module_test import forge_session_cookie from users.auth_local.token_api import _create_token_pair from users.demo import ensure_demo_users diff --git a/modules/users/tests/test_users_admin_effective_permissions.py b/modules/users/tests/test_users_admin_effective_permissions.py index f7766362c..24f3c03e2 100644 --- a/modules/users/tests/test_users_admin_effective_permissions.py +++ b/modules/users/tests/test_users_admin_effective_permissions.py @@ -2,8 +2,8 @@ import httpx import pytest -from api_tokens.constants import TokenKind -from api_tokens.service import ApiTokenService +from api_tokens.core.constants import TokenKind +from api_tokens.credentials.service import ApiTokenService from simple_module_test import forge_session_cookie from sqlalchemy import select from test_api_admin import _make_user