simple-module-api-tokens 0.0.37__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
api_tokens/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """ApiTokens module."""
api_tokens/audit.py ADDED
@@ -0,0 +1,52 @@
1
+ """How an api_tokens row is named, and reached, from the audit log.
2
+
3
+ Create and revoke are recorded by ``AuditMixin`` (the hash and
4
+ ``last_used_at`` are excluded on the model). A token is named by its own
5
+ name and display prefix — never by anything secret.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+
12
+ from simple_module_core.audit_links import AuditLink
13
+ from simple_module_db import ALL_TENANTS_OPTION
14
+ from sqlalchemy import select
15
+ from sqlalchemy.ext.asyncio import AsyncSession
16
+
17
+ from api_tokens.constants import ADMIN_URL, AUDIT_LINK_LABEL, AUDIT_LINK_LABEL_KEY, QP_TOKEN
18
+ from api_tokens.models import ApiToken
19
+
20
+
21
+ async def resolve_token_labels(db: AsyncSession, ids: list[str]) -> dict[str, str]:
22
+ """Map token id -> ``name (prefix…)`` for every id that still resolves.
23
+
24
+ Read across tenants like the rest of the audit log: the audit screen is a
25
+ platform view, and the label carries nothing the row itself does not.
26
+ """
27
+ parsed: dict[uuid.UUID, str] = {}
28
+ for raw in ids:
29
+ try:
30
+ parsed[uuid.UUID(raw)] = raw
31
+ except (ValueError, AttributeError, TypeError):
32
+ continue
33
+ if not parsed:
34
+ return {}
35
+ stmt = (
36
+ select(ApiToken.id, ApiToken.name, ApiToken.prefix)
37
+ .where(ApiToken.id.in_(list(parsed)))
38
+ .execution_options(**{ALL_TENANTS_OPTION: True})
39
+ )
40
+ rows = (await db.execute(stmt)).all()
41
+ return {parsed.get(tid, str(tid)): f"{name} ({prefix}…)" for tid, name, prefix in rows}
42
+
43
+
44
+ def build_token_audit_link() -> AuditLink:
45
+ return AuditLink(
46
+ entity_type=ApiToken.__name__,
47
+ url_template=f"{ADMIN_URL}?{QP_TOKEN}={{id}}",
48
+ label=AUDIT_LINK_LABEL,
49
+ label_key=AUDIT_LINK_LABEL_KEY,
50
+ table_name=ApiToken.__tablename__,
51
+ label_resolver=resolve_token_labels,
52
+ )
@@ -0,0 +1,76 @@
1
+ """Stable identifiers for the api_tokens module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+ from typing import Final
7
+
8
+ from auth.contracts.tokens import PAT_PREFIX, SERVICE_KEY_PREFIX
9
+
10
+ MODULE_NAME: Final = "ApiTokens"
11
+ MODULE_PACKAGE: Final = "api_tokens"
12
+ LOCALE_NAMESPACE: Final = "api_tokens"
13
+
14
+ # Modules this one needs registered first: Auth owns the resolver chain,
15
+ # Users owns the accounts a personal token acts for, Settings stores
16
+ # ``max_lifetime_days``.
17
+ MODULE_AUTH: Final = "Auth"
18
+ MODULE_USERS: Final = "Users"
19
+ MODULE_SETTINGS: Final = "Settings"
20
+
21
+
22
+ class TokenKind(StrEnum):
23
+ PERSONAL = "personal"
24
+ SERVICE = "service"
25
+
26
+
27
+ KIND_PREFIX: Final = {TokenKind.PERSONAL: PAT_PREFIX, TokenKind.SERVICE: SERVICE_KEY_PREFIX}
28
+
29
+ # Secret: 32 random bytes in base62, left-padded to a fixed width so a
30
+ # well-formed token can be recognised without touching the database.
31
+ SECRET_BYTES: Final = 32
32
+ SECRET_LENGTH: Final = 43 # ceil(256 / log2(62))
33
+ DISPLAY_PREFIX_LENGTH: Final = 8
34
+
35
+ # Prefix is load-bearing on SQLite, which has no per-module schemas.
36
+ TABLE_TOKEN: Final = "api_tokens_token"
37
+ IX_TOKEN_HASH: Final = "ix_api_tokens_token_hash_tenant"
38
+ NAME_MAX_LENGTH: Final = 100
39
+ MAX_SCOPES: Final = 200
40
+
41
+ # How stale ``last_used_at`` may get before a request rewrites it. Bounds
42
+ # the write load to one conditional UPDATE per token per window.
43
+ LAST_USED_WRITE_INTERVAL_SECONDS: Final = 300
44
+
45
+ PERM_GROUP: Final = "API tokens"
46
+ PERM_USE: Final = "api_tokens.use"
47
+ PERM_MANAGE: Final = "api_tokens.manage"
48
+ USER_ROLE: Final = "user"
49
+
50
+ # Roles a service key may never hold: ``admin`` would be the wildcard on a
51
+ # credential with no person behind it.
52
+ FORBIDDEN_SERVICE_ROLES: Final = frozenset({"admin"})
53
+
54
+ API_PREFIX: Final = "/api/api_tokens"
55
+ VIEW_PREFIX: Final = "/api-tokens"
56
+ ADMIN_VIEW_PREFIX: Final = "/admin/api-tokens"
57
+ # Trailing slash: both index routes are registered at "/" under their prefix.
58
+ USER_URL: Final = f"{VIEW_PREFIX}/"
59
+ ADMIN_URL: Final = f"{ADMIN_VIEW_PREFIX}/"
60
+
61
+ PAGE_INDEX: Final = "ApiTokens/Index"
62
+ PAGE_ADMIN: Final = "ApiTokens/Admin"
63
+
64
+ MENU_LABEL: Final = "API tokens"
65
+ MENU_LABEL_KEY: Final = "api_tokens.nav.api_tokens"
66
+ MENU_USER_ORDER: Final = 980
67
+ MENU_ADMIN_ORDER: Final = 130
68
+ MENU_ICON: Final = "key"
69
+
70
+ AUDIT_LINK_LABEL: Final = "API token"
71
+ AUDIT_LINK_LABEL_KEY: Final = "api_tokens.audit.token"
72
+ QP_TOKEN: Final = "token"
73
+
74
+ # Expiry presets offered by the pages, in days; the server accepts any whole
75
+ # number of days up to ``max_lifetime_days``.
76
+ EXPIRY_PRESETS_DAYS: Final = (7, 30, 90, 365)
@@ -0,0 +1,11 @@
1
+ """api_tokens contracts — public interface for other modules."""
2
+
3
+ from api_tokens.contracts.schemas import (
4
+ CreatedToken,
5
+ PersonalTokenCreate,
6
+ RevokedCount,
7
+ ServiceKeyCreate,
8
+ TokenView,
9
+ )
10
+
11
+ __all__ = ["CreatedToken", "PersonalTokenCreate", "RevokedCount", "ServiceKeyCreate", "TokenView"]
@@ -0,0 +1,70 @@
1
+ """SQLModel DTOs for the api_tokens module — its public surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+
8
+ from pydantic import ConfigDict, field_validator
9
+ from sqlmodel import Field, SQLModel
10
+
11
+ from api_tokens.constants import MAX_SCOPES, NAME_MAX_LENGTH
12
+
13
+
14
+ class TokenView(SQLModel):
15
+ """A token as lists show it. Never carries the secret or its hash."""
16
+
17
+ model_config = ConfigDict(from_attributes=True)
18
+
19
+ id: uuid.UUID
20
+ kind: str
21
+ name: str
22
+ prefix: str
23
+ scopes: list[str]
24
+ roles: list[str] = []
25
+ owner_user_id: uuid.UUID | None = None
26
+ owner_email: str | None = None
27
+ tenant_id: str | None = None
28
+ created_at: datetime | None = None
29
+ created_by: str | None = None
30
+ expires_at: datetime
31
+ last_used_at: datetime | None = None
32
+ revoked_at: datetime | None = None
33
+
34
+
35
+ class CreatedToken(SQLModel):
36
+ """The one response that carries the plaintext token."""
37
+
38
+ token: str
39
+ item: TokenView
40
+
41
+
42
+ class _CreateBase(SQLModel):
43
+ name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH)
44
+ scopes: list[str] = Field(min_length=1, max_length=MAX_SCOPES)
45
+ # Bounded above by the ``max_lifetime_days`` setting, checked in the service.
46
+ expires_in_days: int = Field(ge=1)
47
+
48
+ @field_validator("name")
49
+ @classmethod
50
+ def _strip_name(cls, value: str) -> str:
51
+ stripped = value.strip()
52
+ if not stripped:
53
+ raise ValueError("name must not be blank")
54
+ return stripped
55
+
56
+
57
+ class PersonalTokenCreate(_CreateBase):
58
+ """Create a personal access token for the signed-in user."""
59
+
60
+
61
+ class ServiceKeyCreate(_CreateBase):
62
+ """Create a service key. ``roles`` may not include ``admin``."""
63
+
64
+ roles: list[str] = Field(default_factory=list, max_length=50)
65
+
66
+
67
+ class RevokedCount(SQLModel):
68
+ """How many tokens a bulk revoke ended."""
69
+
70
+ revoked: int
api_tokens/deps.py ADDED
@@ -0,0 +1,66 @@
1
+ """FastAPI dependencies for the api_tokens module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Annotated
7
+
8
+ from auth.contracts.schemas import UserContext
9
+ from auth.contracts.tokens import AUTH_KIND_SESSION
10
+ from fastapi import Depends, HTTPException, Request
11
+ from simple_module_db.deps import get_db
12
+ from simple_module_hosting.i18n_deps import TranslatorDep
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+ from api_tokens.service import ApiTokenService, TokenError
16
+ from api_tokens.settings import ApiTokensSettings
17
+
18
+
19
+ async def get_api_token_service(db: AsyncSession = Depends(get_db)) -> ApiTokenService:
20
+ return ApiTokenService(db)
21
+
22
+
23
+ def get_settings(request: Request) -> ApiTokensSettings:
24
+ return request.app.state.api_tokens.settings
25
+
26
+
27
+ async def require_session_principal(request: Request, t: TranslatorDep) -> UserContext:
28
+ """The signed-in person — refusing any token-authenticated request.
29
+
30
+ Tokens may not mint, list or revoke tokens: a leaked token must not be
31
+ able to make itself permanent by creating a sibling, or hide by revoking
32
+ the one an administrator would look for. That covers a provider's own
33
+ bearer credential too (``auth_kind == "bearer"``): a fifteen-minute access
34
+ token must not become a year-long PAT. Only a cookie session qualifies, so
35
+ any request carrying an ``Authorization`` header is refused outright.
36
+ """
37
+ user = getattr(request.state, "user", None)
38
+ if user is None:
39
+ raise HTTPException(status_code=401, detail=t.t("auth.errors.not_authenticated"))
40
+ if (
41
+ getattr(user, "auth_kind", None) != AUTH_KIND_SESSION
42
+ or getattr(user, "scopes", None) is not None
43
+ or "authorization" in request.headers
44
+ ):
45
+ raise HTTPException(status_code=403, detail=t.t("api_tokens.errors.session_required"))
46
+ return user
47
+
48
+
49
+ def user_uuid(user: UserContext, t) -> uuid.UUID:
50
+ """The signed-in user's id as a UUID; 403 for a principal that is not an account."""
51
+ try:
52
+ return uuid.UUID(str(user.id))
53
+ except (TypeError, ValueError):
54
+ raise HTTPException(
55
+ status_code=403, detail=t.t("api_tokens.errors.session_required")
56
+ ) from None
57
+
58
+
59
+ def http_error(t, exc: TokenError) -> HTTPException:
60
+ """Translate a service refusal into a 422 the pages can show as-is."""
61
+ return HTTPException(status_code=422, detail=t.t(exc.key, **exc.params))
62
+
63
+
64
+ ApiTokenServiceDep = Annotated[ApiTokenService, Depends(get_api_token_service)]
65
+ SettingsDep = Annotated[ApiTokensSettings, Depends(get_settings)]
66
+ SessionPrincipal = Annotated[UserContext, Depends(require_session_principal)]
File without changes
@@ -0,0 +1,98 @@
1
+ """JSON API for administrators: every token in the tenant, and service keys.
2
+
3
+ Session-only and CSRF-checked like the personal API, and gated on
4
+ ``api_tokens.manage``. Reads and revokes are tenant-scoped (fail-closed): an
5
+ administrator acts on the active tenant, never one named in the URL.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+
12
+ from fastapi import APIRouter, Depends, HTTPException, Request
13
+ from simple_module_db import TenantIsolationError
14
+ from simple_module_hosting.csrf import RequiresCsrf
15
+ from simple_module_hosting.i18n_deps import TranslatorDep
16
+ from simple_module_hosting.permissions import RequiresPermission
17
+
18
+ from api_tokens.constants import FORBIDDEN_SERVICE_ROLES, PERM_MANAGE, TokenKind
19
+ from api_tokens.contracts.schemas import CreatedToken, ServiceKeyCreate, TokenView
20
+ from api_tokens.deps import ApiTokenServiceDep, SettingsDep, http_error, require_session_principal
21
+ from api_tokens.scopes import assignable_roles, assignable_scopes, check_subset
22
+ from api_tokens.service import ApiTokenService, TokenError
23
+
24
+ router = APIRouter(
25
+ prefix="/admin",
26
+ dependencies=[
27
+ Depends(require_session_principal),
28
+ Depends(RequiresCsrf()),
29
+ Depends(RequiresPermission(PERM_MANAGE)),
30
+ ],
31
+ )
32
+
33
+
34
+ async def tenant_token_views(service: ApiTokenService) -> list[TokenView]:
35
+ tokens = await service.list_tenant()
36
+ emails = await service.owner_emails(tokens)
37
+ return [
38
+ service.to_view(t, emails.get(t.owner_user_id) if t.owner_user_id else None) for t in tokens
39
+ ]
40
+
41
+
42
+ @router.get("/tokens", response_model=list[TokenView])
43
+ async def list_all(service: ApiTokenServiceDep, t: TranslatorDep) -> list[TokenView]:
44
+ try:
45
+ return await tenant_token_views(service)
46
+ except TenantIsolationError:
47
+ raise HTTPException(
48
+ status_code=409, detail=t.t("api_tokens.errors.tenant_required")
49
+ ) from None
50
+
51
+
52
+ @router.post("/service-keys", response_model=CreatedToken, status_code=201)
53
+ async def create_service_key(
54
+ body: ServiceKeyCreate,
55
+ request: Request,
56
+ service: ApiTokenServiceDep,
57
+ settings: SettingsDep,
58
+ t: TranslatorDep,
59
+ ) -> CreatedToken:
60
+ try:
61
+ forbidden = sorted(set(body.roles) & FORBIDDEN_SERVICE_ROLES)
62
+ if forbidden:
63
+ raise TokenError("api_tokens.errors.role_forbidden", role=forbidden[0])
64
+ roles = check_subset(
65
+ body.roles, assignable_roles(request), "api_tokens.errors.role_not_assignable"
66
+ )
67
+ scopes = check_subset(
68
+ body.scopes, await assignable_scopes(request), "api_tokens.errors.scope_not_held"
69
+ )
70
+ token, plaintext = await service.create(
71
+ kind=TokenKind.SERVICE,
72
+ name=body.name,
73
+ scopes=scopes,
74
+ roles=roles,
75
+ expires_in_days=body.expires_in_days,
76
+ max_lifetime_days=settings.max_lifetime_days,
77
+ )
78
+ except TokenError as exc:
79
+ raise http_error(t, exc) from None
80
+ except TenantIsolationError:
81
+ raise HTTPException(
82
+ status_code=409, detail=t.t("api_tokens.errors.tenant_required")
83
+ ) from None
84
+ return CreatedToken(token=plaintext, item=service.to_view(token))
85
+
86
+
87
+ @router.post("/tokens/{token_id}/revoke", response_model=TokenView)
88
+ async def revoke_any(
89
+ token_id: uuid.UUID, service: ApiTokenServiceDep, t: TranslatorDep
90
+ ) -> TokenView:
91
+ try:
92
+ token = await service.get_in_tenant(token_id)
93
+ except TenantIsolationError:
94
+ token = None
95
+ if token is None:
96
+ raise HTTPException(status_code=404, detail=t.t("api_tokens.errors.not_found"))
97
+ await service.revoke(token)
98
+ return service.to_view(token)
@@ -0,0 +1,109 @@
1
+ """JSON API for a user's own personal access tokens.
2
+
3
+ Session-only (``require_session_principal``) and CSRF-checked on every
4
+ mutation: the pages call these with the session cookie, and a token may never
5
+ manage tokens.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+
12
+ from fastapi import APIRouter, Depends, HTTPException, Request
13
+ from simple_module_db import TenantIsolationError
14
+ from simple_module_hosting.csrf import RequiresCsrf
15
+ from simple_module_hosting.i18n_deps import TranslatorDep
16
+ from simple_module_hosting.permissions import RequiresPermission
17
+
18
+ from api_tokens.constants import PERM_USE, TokenKind
19
+ from api_tokens.contracts.schemas import (
20
+ CreatedToken,
21
+ PersonalTokenCreate,
22
+ RevokedCount,
23
+ TokenView,
24
+ )
25
+ from api_tokens.deps import (
26
+ ApiTokenServiceDep,
27
+ SessionPrincipal,
28
+ SettingsDep,
29
+ http_error,
30
+ require_session_principal,
31
+ user_uuid,
32
+ )
33
+ from api_tokens.scopes import assignable_scopes, check_subset
34
+ from api_tokens.service import TokenError
35
+
36
+ router = APIRouter(
37
+ prefix="/tokens",
38
+ dependencies=[
39
+ Depends(require_session_principal),
40
+ Depends(RequiresCsrf()),
41
+ Depends(RequiresPermission(PERM_USE)),
42
+ ],
43
+ )
44
+
45
+
46
+ @router.get("/", response_model=list[TokenView])
47
+ async def list_tokens(
48
+ user: SessionPrincipal, service: ApiTokenServiceDep, t: TranslatorDep
49
+ ) -> list[TokenView]:
50
+ tokens = await service.list_for_owner(user_uuid(user, t))
51
+ return [service.to_view(token) for token in tokens]
52
+
53
+
54
+ @router.post("/", response_model=CreatedToken, status_code=201)
55
+ async def create_token(
56
+ body: PersonalTokenCreate,
57
+ request: Request,
58
+ user: SessionPrincipal,
59
+ service: ApiTokenServiceDep,
60
+ settings: SettingsDep,
61
+ t: TranslatorDep,
62
+ ) -> CreatedToken:
63
+ owner = user_uuid(user, t)
64
+ if not await service.has_usable_account(owner):
65
+ # No local account the resolver could load (e.g. Keycloak): refuse
66
+ # instead of minting a token that can never authenticate.
67
+ raise HTTPException(status_code=400, detail=t.t("api_tokens.errors.account_required"))
68
+ try:
69
+ scopes = check_subset(
70
+ body.scopes, await assignable_scopes(request), "api_tokens.errors.scope_not_held"
71
+ )
72
+ token, plaintext = await service.create(
73
+ kind=TokenKind.PERSONAL,
74
+ name=body.name,
75
+ scopes=scopes,
76
+ expires_in_days=body.expires_in_days,
77
+ max_lifetime_days=settings.max_lifetime_days,
78
+ owner_user_id=owner,
79
+ )
80
+ except TokenError as exc:
81
+ raise http_error(t, exc) from None
82
+ except TenantIsolationError:
83
+ raise HTTPException(
84
+ status_code=409, detail=t.t("api_tokens.errors.tenant_required")
85
+ ) from None
86
+ return CreatedToken(token=plaintext, item=service.to_view(token))
87
+
88
+
89
+ @router.post("/revoke-all", response_model=RevokedCount)
90
+ async def revoke_all_tokens(
91
+ user: SessionPrincipal, service: ApiTokenServiceDep, t: TranslatorDep
92
+ ) -> RevokedCount:
93
+ """Revoke every personal token the signed-in user holds — the panic button.
94
+
95
+ Tokens deliberately survive a password change and "sign out everywhere"
96
+ (see docs/modules/api_tokens.md, Security model); this is how to end them.
97
+ """
98
+ return RevokedCount(revoked=await service.revoke_all_for_owner(user_uuid(user, t)))
99
+
100
+
101
+ @router.post("/{token_id}/revoke", response_model=TokenView)
102
+ async def revoke_token(
103
+ token_id: uuid.UUID, user: SessionPrincipal, service: ApiTokenServiceDep, t: TranslatorDep
104
+ ) -> TokenView:
105
+ token = await service.get_owned(token_id, user_uuid(user, t))
106
+ if token is None:
107
+ raise HTTPException(status_code=404, detail=t.t("api_tokens.errors.not_found"))
108
+ await service.revoke(token, cross_tenant=True)
109
+ return service.to_view(token)
@@ -0,0 +1,96 @@
1
+ """Inertia pages: ``/api-tokens/`` (your own tokens), ``/admin/api-tokens/`` (all).
2
+
3
+ Both pages only render; every change goes through the JSON API with the CSRF
4
+ token handed out here, because the one response carrying a new token's
5
+ secret has to reach the page as data, not as a redirect.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from fastapi import APIRouter, Depends, Request
11
+ from simple_module_db import TenantIsolationError
12
+ from simple_module_hosting.csrf import get_csrf_token
13
+ from simple_module_hosting.i18n_deps import TranslatorDep
14
+ from simple_module_hosting.inertia_deps import InertiaDep
15
+ from simple_module_hosting.permissions import RequiresPermission
16
+ from simple_module_inertia import InertiaResponse
17
+
18
+ from api_tokens.constants import (
19
+ API_PREFIX,
20
+ EXPIRY_PRESETS_DAYS,
21
+ PAGE_ADMIN,
22
+ PAGE_INDEX,
23
+ PERM_MANAGE,
24
+ PERM_USE,
25
+ )
26
+ from api_tokens.deps import (
27
+ ApiTokenServiceDep,
28
+ SessionPrincipal,
29
+ SettingsDep,
30
+ require_session_principal,
31
+ user_uuid,
32
+ )
33
+ from api_tokens.endpoints.admin_api import tenant_token_views
34
+ from api_tokens.scopes import assignable_roles, assignable_scopes
35
+
36
+ router = APIRouter(
37
+ dependencies=[Depends(require_session_principal), Depends(RequiresPermission(PERM_USE))]
38
+ )
39
+ admin_router = APIRouter(
40
+ dependencies=[Depends(require_session_principal), Depends(RequiresPermission(PERM_MANAGE))]
41
+ )
42
+
43
+
44
+ def _form_props(request: Request, max_days: int) -> dict:
45
+ return {
46
+ "max_lifetime_days": max_days,
47
+ "expiry_presets": [d for d in EXPIRY_PRESETS_DAYS if d <= max_days] or [max_days],
48
+ "csrf_token": get_csrf_token(request),
49
+ "api_url": API_PREFIX,
50
+ }
51
+
52
+
53
+ @router.get("/", response_model=None)
54
+ async def index(
55
+ request: Request,
56
+ inertia: InertiaDep,
57
+ user: SessionPrincipal,
58
+ service: ApiTokenServiceDep,
59
+ settings: SettingsDep,
60
+ t: TranslatorDep,
61
+ ) -> InertiaResponse:
62
+ tokens = await service.list_for_owner(user_uuid(user, t))
63
+ return await inertia.render(
64
+ PAGE_INDEX,
65
+ {
66
+ "tokens": [service.to_view(tok).model_dump(mode="json") for tok in tokens],
67
+ "scopes": await assignable_scopes(request),
68
+ **_form_props(request, settings.max_lifetime_days),
69
+ },
70
+ )
71
+
72
+
73
+ @admin_router.get("/", response_model=None)
74
+ async def admin_index(
75
+ request: Request,
76
+ inertia: InertiaDep,
77
+ service: ApiTokenServiceDep,
78
+ settings: SettingsDep,
79
+ ) -> InertiaResponse:
80
+ try:
81
+ views = await tenant_token_views(service)
82
+ tenant_required = False
83
+ except TenantIsolationError:
84
+ # Strict tenancy with no active tenant: nothing to list, and nowhere
85
+ # to create a service key. The page says so instead of a 500.
86
+ views, tenant_required = [], True
87
+ return await inertia.render(
88
+ PAGE_ADMIN,
89
+ {
90
+ "tokens": [v.model_dump(mode="json") for v in views],
91
+ "scopes": await assignable_scopes(request),
92
+ "roles": assignable_roles(request),
93
+ "tenant_required": tenant_required,
94
+ **_form_props(request, settings.max_lifetime_days),
95
+ },
96
+ )