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.
- simple_module_api_tokens-0.0.37/.gitignore +67 -0
- simple_module_api_tokens-0.0.37/LICENSE +19 -0
- simple_module_api_tokens-0.0.37/PKG-INFO +70 -0
- simple_module_api_tokens-0.0.37/README.md +38 -0
- simple_module_api_tokens-0.0.37/api_tokens/__init__.py +1 -0
- simple_module_api_tokens-0.0.37/api_tokens/audit.py +52 -0
- simple_module_api_tokens-0.0.37/api_tokens/constants.py +76 -0
- simple_module_api_tokens-0.0.37/api_tokens/contracts/__init__.py +11 -0
- simple_module_api_tokens-0.0.37/api_tokens/contracts/schemas.py +70 -0
- simple_module_api_tokens-0.0.37/api_tokens/deps.py +66 -0
- simple_module_api_tokens-0.0.37/api_tokens/endpoints/__init__.py +0 -0
- simple_module_api_tokens-0.0.37/api_tokens/endpoints/admin_api.py +98 -0
- simple_module_api_tokens-0.0.37/api_tokens/endpoints/api.py +109 -0
- simple_module_api_tokens-0.0.37/api_tokens/endpoints/views.py +96 -0
- simple_module_api_tokens-0.0.37/api_tokens/locales/en.json +104 -0
- simple_module_api_tokens-0.0.37/api_tokens/models.py +58 -0
- simple_module_api_tokens-0.0.37/api_tokens/module.py +109 -0
- simple_module_api_tokens-0.0.37/api_tokens/pages/Admin.tsx +92 -0
- simple_module_api_tokens-0.0.37/api_tokens/pages/Index.tsx +95 -0
- simple_module_api_tokens-0.0.37/api_tokens/pages/api.ts +42 -0
- simple_module_api_tokens-0.0.37/api_tokens/pages/components/CreateTokenDialog.tsx +186 -0
- simple_module_api_tokens-0.0.37/api_tokens/pages/components/CreatedTokenDialog.tsx +64 -0
- simple_module_api_tokens-0.0.37/api_tokens/pages/components/RevokeTokenDialog.tsx +29 -0
- simple_module_api_tokens-0.0.37/api_tokens/pages/components/TokenTable.tsx +117 -0
- simple_module_api_tokens-0.0.37/api_tokens/pages/components/useTokenActions.ts +82 -0
- simple_module_api_tokens-0.0.37/api_tokens/pages/types.ts +40 -0
- simple_module_api_tokens-0.0.37/api_tokens/py.typed +0 -0
- simple_module_api_tokens-0.0.37/api_tokens/resolver.py +157 -0
- simple_module_api_tokens-0.0.37/api_tokens/scopes.py +58 -0
- simple_module_api_tokens-0.0.37/api_tokens/service.py +175 -0
- simple_module_api_tokens-0.0.37/api_tokens/services.py +14 -0
- simple_module_api_tokens-0.0.37/api_tokens/settings.py +17 -0
- simple_module_api_tokens-0.0.37/api_tokens/tokens.py +83 -0
- simple_module_api_tokens-0.0.37/package.json +19 -0
- simple_module_api_tokens-0.0.37/pyproject.toml +66 -0
- simple_module_api_tokens-0.0.37/tests/_api_tokens_support.py +87 -0
- simple_module_api_tokens-0.0.37/tests/conftest.py +49 -0
- simple_module_api_tokens-0.0.37/tests/test_api_tokens_endpoints.py +144 -0
- simple_module_api_tokens-0.0.37/tests/test_api_tokens_format.py +112 -0
- simple_module_api_tokens-0.0.37/tests/test_api_tokens_hardening.py +207 -0
- simple_module_api_tokens-0.0.37/tests/test_api_tokens_identity.py +91 -0
- simple_module_api_tokens-0.0.37/tests/test_api_tokens_resolver.py +177 -0
- simple_module_api_tokens-0.0.37/tests/test_api_tokens_route_guard.py +122 -0
- simple_module_api_tokens-0.0.37/tests/test_api_tokens_scopes.py +127 -0
- simple_module_api_tokens-0.0.37/tests/test_api_tokens_service_keys.py +85 -0
- simple_module_api_tokens-0.0.37/tests/test_api_tokens_signup.py +85 -0
- 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)]
|
|
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)
|