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 +1 -0
- api_tokens/audit.py +52 -0
- api_tokens/constants.py +76 -0
- api_tokens/contracts/__init__.py +11 -0
- api_tokens/contracts/schemas.py +70 -0
- api_tokens/deps.py +66 -0
- api_tokens/endpoints/__init__.py +0 -0
- api_tokens/endpoints/admin_api.py +98 -0
- api_tokens/endpoints/api.py +109 -0
- api_tokens/endpoints/views.py +96 -0
- api_tokens/locales/en.json +104 -0
- api_tokens/models.py +58 -0
- api_tokens/module.py +109 -0
- api_tokens/package.json +19 -0
- api_tokens/pages/Admin.tsx +92 -0
- api_tokens/pages/Index.tsx +95 -0
- api_tokens/pages/api.ts +42 -0
- api_tokens/pages/components/CreateTokenDialog.tsx +186 -0
- api_tokens/pages/components/CreatedTokenDialog.tsx +64 -0
- api_tokens/pages/components/RevokeTokenDialog.tsx +29 -0
- api_tokens/pages/components/TokenTable.tsx +117 -0
- api_tokens/pages/components/useTokenActions.ts +82 -0
- api_tokens/pages/types.ts +40 -0
- api_tokens/py.typed +0 -0
- api_tokens/resolver.py +157 -0
- api_tokens/scopes.py +58 -0
- api_tokens/service.py +175 -0
- api_tokens/services.py +14 -0
- api_tokens/settings.py +17 -0
- api_tokens/tokens.py +83 -0
- simple_module_api_tokens-0.0.37.dist-info/METADATA +70 -0
- simple_module_api_tokens-0.0.37.dist-info/RECORD +35 -0
- simple_module_api_tokens-0.0.37.dist-info/WHEEL +4 -0
- simple_module_api_tokens-0.0.37.dist-info/entry_points.txt +2 -0
- simple_module_api_tokens-0.0.37.dist-info/licenses/LICENSE +19 -0
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
|
+
)
|
api_tokens/constants.py
ADDED
|
@@ -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
|
+
)
|