simple-module-api-tokens 0.0.37__tar.gz

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.
Files changed (47) hide show
  1. simple_module_api_tokens-0.0.37/.gitignore +67 -0
  2. simple_module_api_tokens-0.0.37/LICENSE +19 -0
  3. simple_module_api_tokens-0.0.37/PKG-INFO +70 -0
  4. simple_module_api_tokens-0.0.37/README.md +38 -0
  5. simple_module_api_tokens-0.0.37/api_tokens/__init__.py +1 -0
  6. simple_module_api_tokens-0.0.37/api_tokens/audit.py +52 -0
  7. simple_module_api_tokens-0.0.37/api_tokens/constants.py +76 -0
  8. simple_module_api_tokens-0.0.37/api_tokens/contracts/__init__.py +11 -0
  9. simple_module_api_tokens-0.0.37/api_tokens/contracts/schemas.py +70 -0
  10. simple_module_api_tokens-0.0.37/api_tokens/deps.py +66 -0
  11. simple_module_api_tokens-0.0.37/api_tokens/endpoints/__init__.py +0 -0
  12. simple_module_api_tokens-0.0.37/api_tokens/endpoints/admin_api.py +98 -0
  13. simple_module_api_tokens-0.0.37/api_tokens/endpoints/api.py +109 -0
  14. simple_module_api_tokens-0.0.37/api_tokens/endpoints/views.py +96 -0
  15. simple_module_api_tokens-0.0.37/api_tokens/locales/en.json +104 -0
  16. simple_module_api_tokens-0.0.37/api_tokens/models.py +58 -0
  17. simple_module_api_tokens-0.0.37/api_tokens/module.py +109 -0
  18. simple_module_api_tokens-0.0.37/api_tokens/pages/Admin.tsx +92 -0
  19. simple_module_api_tokens-0.0.37/api_tokens/pages/Index.tsx +95 -0
  20. simple_module_api_tokens-0.0.37/api_tokens/pages/api.ts +42 -0
  21. simple_module_api_tokens-0.0.37/api_tokens/pages/components/CreateTokenDialog.tsx +186 -0
  22. simple_module_api_tokens-0.0.37/api_tokens/pages/components/CreatedTokenDialog.tsx +64 -0
  23. simple_module_api_tokens-0.0.37/api_tokens/pages/components/RevokeTokenDialog.tsx +29 -0
  24. simple_module_api_tokens-0.0.37/api_tokens/pages/components/TokenTable.tsx +117 -0
  25. simple_module_api_tokens-0.0.37/api_tokens/pages/components/useTokenActions.ts +82 -0
  26. simple_module_api_tokens-0.0.37/api_tokens/pages/types.ts +40 -0
  27. simple_module_api_tokens-0.0.37/api_tokens/py.typed +0 -0
  28. simple_module_api_tokens-0.0.37/api_tokens/resolver.py +157 -0
  29. simple_module_api_tokens-0.0.37/api_tokens/scopes.py +58 -0
  30. simple_module_api_tokens-0.0.37/api_tokens/service.py +175 -0
  31. simple_module_api_tokens-0.0.37/api_tokens/services.py +14 -0
  32. simple_module_api_tokens-0.0.37/api_tokens/settings.py +17 -0
  33. simple_module_api_tokens-0.0.37/api_tokens/tokens.py +83 -0
  34. simple_module_api_tokens-0.0.37/package.json +19 -0
  35. simple_module_api_tokens-0.0.37/pyproject.toml +66 -0
  36. simple_module_api_tokens-0.0.37/tests/_api_tokens_support.py +87 -0
  37. simple_module_api_tokens-0.0.37/tests/conftest.py +49 -0
  38. simple_module_api_tokens-0.0.37/tests/test_api_tokens_endpoints.py +144 -0
  39. simple_module_api_tokens-0.0.37/tests/test_api_tokens_format.py +112 -0
  40. simple_module_api_tokens-0.0.37/tests/test_api_tokens_hardening.py +207 -0
  41. simple_module_api_tokens-0.0.37/tests/test_api_tokens_identity.py +91 -0
  42. simple_module_api_tokens-0.0.37/tests/test_api_tokens_resolver.py +177 -0
  43. simple_module_api_tokens-0.0.37/tests/test_api_tokens_route_guard.py +122 -0
  44. simple_module_api_tokens-0.0.37/tests/test_api_tokens_scopes.py +127 -0
  45. simple_module_api_tokens-0.0.37/tests/test_api_tokens_service_keys.py +85 -0
  46. simple_module_api_tokens-0.0.37/tests/test_api_tokens_signup.py +85 -0
  47. simple_module_api_tokens-0.0.37/tsconfig.json +10 -0
@@ -0,0 +1,67 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ *.egg
9
+
10
+ # UV
11
+ uv.lock
12
+
13
+ # Node
14
+ node_modules/
15
+
16
+ # IDE
17
+ .idea/
18
+ .vscode/
19
+ *.swp
20
+ *.swo
21
+
22
+ # Environment
23
+ .env
24
+
25
+ # Database
26
+ *.db
27
+ *.sqlite3
28
+
29
+ # Module-managed runtime state (e.g. uploaded dataset files,
30
+ # default storage_dir for SM_DATASETS_STORAGE_DIR).
31
+ var/
32
+
33
+ # file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH).
34
+ uploads/
35
+
36
+ # Vite
37
+ host/static/dist/
38
+
39
+ # VitePress (docs)
40
+ docs/.vitepress/cache/
41
+ docs/.vitepress/dist/
42
+
43
+ # Auto-generated frontend module manifest (regenerated by the host at boot
44
+ # or via `make gen-pages`).
45
+ host/client_app/modules.manifest.json
46
+ host/client_app/modules.generated.ts
47
+ host/client_app/modules.generated.css
48
+ host/client_app/modules.assets.json
49
+
50
+ # Worktrees
51
+ .worktrees/
52
+
53
+ # Performance profiles
54
+ .memray/
55
+ .benchmarks/
56
+
57
+ # OS
58
+ .DS_Store
59
+ Thumbs.db
60
+
61
+ .playwright-cli/*
62
+ .playwright-mcp/*
63
+ host/client_app/.playwright-cli/*
64
+ .superpowers/
65
+ .qa/
66
+ .verify/
67
+ /openapi.json
@@ -0,0 +1,19 @@
1
+ MIT License
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.5
2
+ Name: simple_module_api_tokens
3
+ Version: 0.0.37
4
+ Summary: Personal access tokens and service API keys for simple_module
5
+ Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
+ Project-URL: Repository, https://github.com/antosubash/simple_module_python
7
+ Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
8
+ Project-URL: Changelog, https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md
9
+ Author-email: Anto Subash <antosubash@live.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: api-keys,api-tokens,fastapi,personal-access-tokens,simple-module
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: FastAPI
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: simple-module-auth==0.0.37
25
+ Requires-Dist: simple-module-core==0.0.37
26
+ Requires-Dist: simple-module-db==0.0.37
27
+ Requires-Dist: simple-module-hosting==0.0.37
28
+ Requires-Dist: simple-module-inertia==0.0.37
29
+ Requires-Dist: simple-module-settings==0.0.37
30
+ Requires-Dist: simple-module-users==0.0.37
31
+ Description-Content-Type: text/markdown
32
+
33
+ # simple_module_api_tokens
34
+
35
+ Personal access tokens and service API keys for
36
+ [simple_module](https://github.com/antosubash/simple_module_python) apps.
37
+
38
+ - `smpat_…` tokens act for their owner, `smsk_…` service keys act for no one,
39
+ with roles chosen at creation (never `admin`) and a pinned tenant.
40
+ - Every token carries **scopes**, which cap what it can do on every request,
41
+ even when its owner is an admin. Demoting the owner shrinks the token at once.
42
+ - Only `sha256(token)` is stored, and the secret is shown once. Revocation
43
+ applies on the next request.
44
+ - Tokens can only be created, listed and revoked from a signed-in browser
45
+ session, with a CSRF token.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install simple_module_api_tokens
51
+ ```
52
+
53
+ Add `simple_module_api_tokens` to your host's dependencies and generate a
54
+ migration. It needs the `auth`, `users` and `settings` modules.
55
+
56
+ ## Usage
57
+
58
+ ```bash
59
+ curl -H "Authorization: Bearer smpat_…" https://app.example.com/api/feature_flags/
60
+ ```
61
+
62
+ | Route | Page | Purpose |
63
+ |---|---|---|
64
+ | `GET /api-tokens/` | `ApiTokens/Index` | Your own tokens (`api_tokens.use`) |
65
+ | `GET /admin/api-tokens/` | `ApiTokens/Admin` | All tokens and service keys (`api_tokens.manage`) |
66
+
67
+ Settings: `max_lifetime_days` (default 365), DB-backed.
68
+
69
+ See [docs/modules/api_tokens.md](https://github.com/antosubash/simple_module_python/blob/main/docs/modules/api_tokens.md)
70
+ for the resolution order, the scope cap and the tenancy rules.
@@ -0,0 +1,38 @@
1
+ # simple_module_api_tokens
2
+
3
+ Personal access tokens and service API keys for
4
+ [simple_module](https://github.com/antosubash/simple_module_python) apps.
5
+
6
+ - `smpat_…` tokens act for their owner, `smsk_…` service keys act for no one,
7
+ with roles chosen at creation (never `admin`) and a pinned tenant.
8
+ - Every token carries **scopes**, which cap what it can do on every request,
9
+ even when its owner is an admin. Demoting the owner shrinks the token at once.
10
+ - Only `sha256(token)` is stored, and the secret is shown once. Revocation
11
+ applies on the next request.
12
+ - Tokens can only be created, listed and revoked from a signed-in browser
13
+ session, with a CSRF token.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install simple_module_api_tokens
19
+ ```
20
+
21
+ Add `simple_module_api_tokens` to your host's dependencies and generate a
22
+ migration. It needs the `auth`, `users` and `settings` modules.
23
+
24
+ ## Usage
25
+
26
+ ```bash
27
+ curl -H "Authorization: Bearer smpat_…" https://app.example.com/api/feature_flags/
28
+ ```
29
+
30
+ | Route | Page | Purpose |
31
+ |---|---|---|
32
+ | `GET /api-tokens/` | `ApiTokens/Index` | Your own tokens (`api_tokens.use`) |
33
+ | `GET /admin/api-tokens/` | `ApiTokens/Admin` | All tokens and service keys (`api_tokens.manage`) |
34
+
35
+ Settings: `max_lifetime_days` (default 365), DB-backed.
36
+
37
+ See [docs/modules/api_tokens.md](https://github.com/antosubash/simple_module_python/blob/main/docs/modules/api_tokens.md)
38
+ for the resolution order, the scope cap and the tenancy rules.
@@ -0,0 +1 @@
1
+ """ApiTokens module."""
@@ -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
@@ -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)]
@@ -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)