qodalis-cli-server-plugin-users 1.0.0b6__tar.gz → 1.0.0b7__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.
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/PKG-INFO +1 -1
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/pyproject.toml +1 -1
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/__init__.py +6 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/cli_users_builder.py +21 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/controllers/__init__.py +2 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/controllers/sessions_controller.py +5 -1
- qodalis_cli_server_plugin_users-1.0.0b7/qodalis_cli_users/controllers/tokens_controller.py +140 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/controllers/users_controller.py +3 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/models.py +48 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/permissions.py +2 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/server_protection.py +4 -1
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/services/auth_service.py +91 -1
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/services/users_service.py +3 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/shell_access.py +20 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/stores/__init__.py +4 -0
- qodalis_cli_server_plugin_users-1.0.0b7/qodalis_cli_users/stores/in_memory_api_token_store.py +46 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/stores/interfaces.py +38 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/validation.py +5 -2
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/.gitignore +0 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/LICENSE +0 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/_http.py +0 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/controllers/_routing.py +0 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/controllers/audit_controller.py +0 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/controllers/groups_controller.py +0 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/errors.py +0 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/middleware/__init__.py +0 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/middleware/auth_middleware.py +0 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/services/__init__.py +0 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/services/password_hasher.py +0 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/stores/in_memory_audit_store.py +0 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/stores/in_memory_credential_store.py +0 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/stores/in_memory_group_store.py +0 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/stores/in_memory_session_store.py +0 -0
- {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/stores/in_memory_user_store.py +0 -0
{qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: qodalis-cli-server-plugin-users
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.0b7
|
|
4
4
|
Summary: User management plugin for Qodalis CLI servers — authentication, sessions, groups, and audit log
|
|
5
5
|
Project-URL: Homepage, https://github.com/qodalis-solutions/cli-server-python
|
|
6
6
|
Project-URL: Repository, https://github.com/qodalis-solutions/cli-server-python
|
{qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/pyproject.toml
RENAMED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "qodalis-cli-server-plugin-users"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.0b7"
|
|
8
8
|
description = "User management plugin for Qodalis CLI servers — authentication, sessions, groups, and audit log"
|
|
9
9
|
readme = { text = "", content-type = "text/plain" }
|
|
10
10
|
license = "MIT"
|
|
@@ -10,6 +10,7 @@ from typing import Any, Callable, Optional
|
|
|
10
10
|
from .cli_users_builder import CliUsersBuilder, CliUsersPlugin
|
|
11
11
|
from .errors import UsersApiError
|
|
12
12
|
from .models import (
|
|
13
|
+
ApiToken,
|
|
13
14
|
AuditEntry,
|
|
14
15
|
AuditQueryOptions,
|
|
15
16
|
Credentials,
|
|
@@ -30,12 +31,14 @@ from .services.auth_service import (
|
|
|
30
31
|
from .services.users_service import UsersService
|
|
31
32
|
from .server_protection import ServerProtectionMiddleware
|
|
32
33
|
from .shell_access import UsersShellAccess
|
|
34
|
+
from .stores.in_memory_api_token_store import InMemoryApiTokenStore
|
|
33
35
|
from .stores.in_memory_audit_store import InMemoryAuditStore
|
|
34
36
|
from .stores.in_memory_credential_store import InMemoryCredentialStore
|
|
35
37
|
from .stores.in_memory_group_store import InMemoryGroupStore
|
|
36
38
|
from .stores.in_memory_session_store import InMemorySessionStore
|
|
37
39
|
from .stores.in_memory_user_store import InMemoryUserStore
|
|
38
40
|
from .stores.interfaces import (
|
|
41
|
+
IApiTokenStore,
|
|
39
42
|
IAuditStore,
|
|
40
43
|
ICredentialStore,
|
|
41
44
|
IGroupStore,
|
|
@@ -44,6 +47,7 @@ from .stores.interfaces import (
|
|
|
44
47
|
)
|
|
45
48
|
|
|
46
49
|
__all__ = [
|
|
50
|
+
"ApiToken",
|
|
47
51
|
"AuditEntry",
|
|
48
52
|
"AuditQueryOptions",
|
|
49
53
|
"AuthContext",
|
|
@@ -55,11 +59,13 @@ __all__ = [
|
|
|
55
59
|
"ExternalAuthAdapter",
|
|
56
60
|
"ExternalAuthResult",
|
|
57
61
|
"Group",
|
|
62
|
+
"IApiTokenStore",
|
|
58
63
|
"IAuditStore",
|
|
59
64
|
"ICredentialStore",
|
|
60
65
|
"IGroupStore",
|
|
61
66
|
"ISessionStore",
|
|
62
67
|
"IUserStore",
|
|
68
|
+
"InMemoryApiTokenStore",
|
|
63
69
|
"InMemoryAuditStore",
|
|
64
70
|
"InMemoryCredentialStore",
|
|
65
71
|
"InMemoryGroupStore",
|
|
@@ -30,6 +30,7 @@ from fastapi import APIRouter
|
|
|
30
30
|
from .controllers.audit_controller import create_audit_router
|
|
31
31
|
from .controllers.groups_controller import create_groups_router
|
|
32
32
|
from .controllers.sessions_controller import create_sessions_router
|
|
33
|
+
from .controllers.tokens_controller import create_tokens_router
|
|
33
34
|
from .controllers.users_controller import create_users_router
|
|
34
35
|
from .middleware.auth_middleware import create_auth_dependencies
|
|
35
36
|
from .models import ExternalAuthAdapter
|
|
@@ -48,12 +49,14 @@ from .services.auth_service import (
|
|
|
48
49
|
BroadcastFn,
|
|
49
50
|
)
|
|
50
51
|
from .services.users_service import UsersService
|
|
52
|
+
from .stores.in_memory_api_token_store import InMemoryApiTokenStore
|
|
51
53
|
from .stores.in_memory_audit_store import InMemoryAuditStore
|
|
52
54
|
from .stores.in_memory_credential_store import InMemoryCredentialStore
|
|
53
55
|
from .stores.in_memory_group_store import InMemoryGroupStore
|
|
54
56
|
from .stores.in_memory_session_store import InMemorySessionStore
|
|
55
57
|
from .stores.in_memory_user_store import InMemoryUserStore
|
|
56
58
|
from .stores.interfaces import (
|
|
59
|
+
IApiTokenStore,
|
|
57
60
|
IAuditStore,
|
|
58
61
|
ICredentialStore,
|
|
59
62
|
IGroupStore,
|
|
@@ -85,6 +88,7 @@ class CliUsersPlugin:
|
|
|
85
88
|
credential_store: ICredentialStore
|
|
86
89
|
group_store: IGroupStore
|
|
87
90
|
audit_store: IAuditStore
|
|
91
|
+
api_token_store: Optional[IApiTokenStore] = None
|
|
88
92
|
default_admin_password: Optional[str] = None
|
|
89
93
|
cleanup_interval_ms: int = DEFAULT_CLEANUP_INTERVAL_MS
|
|
90
94
|
#: Protect every CLI server route/socket when mounted (B1).
|
|
@@ -219,6 +223,8 @@ class CliUsersBuilder:
|
|
|
219
223
|
self._credential_store: ICredentialStore | None = None
|
|
220
224
|
self._audit_store: IAuditStore | None = None
|
|
221
225
|
self._group_store: IGroupStore | None = None
|
|
226
|
+
self._api_token_store: IApiTokenStore | None = None
|
|
227
|
+
self._allow_non_expiring_tokens = False
|
|
222
228
|
self._auth_adapter: ExternalAuthAdapter | None = None
|
|
223
229
|
self._default_admin_password: str | None = None
|
|
224
230
|
self._session_timeout_ms = DEFAULT_SESSION_TIMEOUT_MS
|
|
@@ -253,6 +259,17 @@ class CliUsersBuilder:
|
|
|
253
259
|
self._group_store = store
|
|
254
260
|
return self
|
|
255
261
|
|
|
262
|
+
def set_api_token_store(self, store: IApiTokenStore) -> CliUsersBuilder:
|
|
263
|
+
"""Replace the API token store (default in-memory)."""
|
|
264
|
+
self._api_token_store = store
|
|
265
|
+
return self
|
|
266
|
+
|
|
267
|
+
def set_allow_non_expiring_tokens(self, allow: bool = True) -> CliUsersBuilder:
|
|
268
|
+
"""Allow API tokens that never expire (``expiresInDays: null``;
|
|
269
|
+
default ``False``)."""
|
|
270
|
+
self._allow_non_expiring_tokens = allow
|
|
271
|
+
return self
|
|
272
|
+
|
|
256
273
|
def set_auth_adapter(self, adapter: ExternalAuthAdapter | None) -> CliUsersBuilder:
|
|
257
274
|
"""Delegate authentication to the host app (see README)."""
|
|
258
275
|
self._auth_adapter = adapter
|
|
@@ -341,9 +358,11 @@ class CliUsersBuilder:
|
|
|
341
358
|
rate_limit_window_ms=self._rate_limit_window_ms,
|
|
342
359
|
trust_proxy=self._trust_proxy,
|
|
343
360
|
allowed_origins=self._allowed_origins,
|
|
361
|
+
allow_non_expiring_tokens=self._allow_non_expiring_tokens,
|
|
344
362
|
),
|
|
345
363
|
broadcast_fn=broadcast_fn,
|
|
346
364
|
auth_adapter=self._auth_adapter,
|
|
365
|
+
api_token_store=self._api_token_store or InMemoryApiTokenStore(),
|
|
347
366
|
)
|
|
348
367
|
users_service = UsersService(auth_service)
|
|
349
368
|
|
|
@@ -358,6 +377,7 @@ class CliUsersBuilder:
|
|
|
358
377
|
credential_store=auth_service.credential_store,
|
|
359
378
|
group_store=auth_service.group_store,
|
|
360
379
|
audit_store=auth_service.audit_store,
|
|
380
|
+
api_token_store=auth_service.api_token_store,
|
|
361
381
|
default_admin_password=self._default_admin_password,
|
|
362
382
|
cleanup_interval_ms=self._cleanup_interval_ms,
|
|
363
383
|
protect_server=self._protect_server,
|
|
@@ -382,4 +402,5 @@ class CliUsersBuilder:
|
|
|
382
402
|
auth_service, users_service, get_auth, require_permission,
|
|
383
403
|
))
|
|
384
404
|
router.include_router(create_audit_router(auth_service, require_permission))
|
|
405
|
+
router.include_router(create_tokens_router(auth_service, get_auth))
|
|
385
406
|
return plugin
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
from .audit_controller import create_audit_router
|
|
4
4
|
from .groups_controller import create_groups_router
|
|
5
5
|
from .sessions_controller import create_sessions_router
|
|
6
|
+
from .tokens_controller import create_tokens_router
|
|
6
7
|
from .users_controller import create_users_router
|
|
7
8
|
|
|
8
9
|
__all__ = [
|
|
9
10
|
"create_audit_router",
|
|
10
11
|
"create_groups_router",
|
|
11
12
|
"create_sessions_router",
|
|
13
|
+
"create_tokens_router",
|
|
12
14
|
"create_users_router",
|
|
13
15
|
]
|
|
@@ -21,6 +21,7 @@ from ..services.auth_service import AuthContext, AuthService
|
|
|
21
21
|
from ._routing import collection, new_router
|
|
22
22
|
|
|
23
23
|
MANAGED_BY_HOST = "Authentication is managed by the host application"
|
|
24
|
+
NOT_A_SESSION = "not a session"
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def create_sessions_router(
|
|
@@ -63,6 +64,8 @@ def create_sessions_router(
|
|
|
63
64
|
|
|
64
65
|
@router.delete("/current", status_code=204)
|
|
65
66
|
async def logout(ctx: AuthContext = Depends(get_auth_pending)):
|
|
67
|
+
if ctx.api_token is not None:
|
|
68
|
+
raise not_found(NOT_A_SESSION)
|
|
66
69
|
if ctx.token:
|
|
67
70
|
await auth_service.logout(ctx.token)
|
|
68
71
|
return Response(status_code=204)
|
|
@@ -74,7 +77,8 @@ def create_sessions_router(
|
|
|
74
77
|
|
|
75
78
|
@router.get("/current")
|
|
76
79
|
async def current_session(ctx: AuthContext = Depends(get_auth_pending)):
|
|
77
|
-
|
|
80
|
+
if ctx.session is None:
|
|
81
|
+
raise not_found(NOT_A_SESSION)
|
|
78
82
|
return auth_service.session_payload(ctx.session, ctx.user, ctx.permissions)
|
|
79
83
|
|
|
80
84
|
@collection(router, "GET")
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""API tokens controller — ``/tokens``.
|
|
2
|
+
|
|
3
|
+
- ``GET /tokens`` own tokens (authenticated)
|
|
4
|
+
- ``GET /tokens?all=true`` every user's tokens (``tokens:read``)
|
|
5
|
+
- ``POST /tokens`` create a token (authenticated, session only)
|
|
6
|
+
- ``DELETE /tokens/{id}`` revoke (owner, or ``tokens:revoke``)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, Depends, Response
|
|
16
|
+
|
|
17
|
+
from ..errors import UsersApiError, bad_request, conflict, forbidden, not_found
|
|
18
|
+
from ..models import ApiToken, CreateApiTokenRequest, now_ms
|
|
19
|
+
from ..permissions import TOKENS_READ, TOKENS_REVOKE
|
|
20
|
+
from ..services.auth_service import AuthContext, AuthService, hash_token
|
|
21
|
+
from ..validation import sanitize, validate_permissions
|
|
22
|
+
from ._routing import collection, new_router
|
|
23
|
+
|
|
24
|
+
TOKEN_NAME_RE = re.compile(r"^[A-Za-z0-9._ -]{1,64}$")
|
|
25
|
+
TOKEN_NAME_MAX = 64
|
|
26
|
+
MAX_TOKENS_PER_USER = 20
|
|
27
|
+
DEFAULT_EXPIRES_IN_DAYS = 90
|
|
28
|
+
MAX_EXPIRES_IN_DAYS = 365
|
|
29
|
+
DAY_MS = 24 * 60 * 60 * 1000
|
|
30
|
+
|
|
31
|
+
API_TOKENS_CANNOT_CREATE = "forbidden: API tokens cannot create tokens"
|
|
32
|
+
MANAGED_BY_HOST = "Authentication is managed by the host application"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _validate_name(raw: Any) -> str:
|
|
36
|
+
if not isinstance(raw, str) or not TOKEN_NAME_RE.match(raw):
|
|
37
|
+
raise bad_request("name must be 1-64 characters of [A-Za-z0-9._ -]")
|
|
38
|
+
return raw
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _expires_at(body: CreateApiTokenRequest, allow_never: bool, now: int) -> int | None:
|
|
42
|
+
if "expiresInDays" not in body.model_fields_set:
|
|
43
|
+
return now + DEFAULT_EXPIRES_IN_DAYS * DAY_MS
|
|
44
|
+
days = body.expiresInDays
|
|
45
|
+
if days is None:
|
|
46
|
+
if not allow_never:
|
|
47
|
+
raise bad_request("non-expiring tokens are not allowed")
|
|
48
|
+
return None
|
|
49
|
+
if isinstance(days, float) and days.is_integer():
|
|
50
|
+
days = int(days)
|
|
51
|
+
if isinstance(days, bool) or not isinstance(days, int) or not 1 <= days <= MAX_EXPIRES_IN_DAYS:
|
|
52
|
+
raise bad_request(f"expiresInDays must be an integer between 1 and {MAX_EXPIRES_IN_DAYS}")
|
|
53
|
+
return now + days * DAY_MS
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _scope(ctx: AuthContext, raw: Any) -> list[str] | None:
|
|
57
|
+
"""``None`` (omitted/null) = unscoped; ``[]`` = an empty scope. Field
|
|
58
|
+
errors (400) are raised before the coverage check (403)."""
|
|
59
|
+
if raw is None:
|
|
60
|
+
return None
|
|
61
|
+
if not isinstance(raw, list) or not all(isinstance(p, str) and p for p in raw):
|
|
62
|
+
raise bad_request("permissions must be an array of strings")
|
|
63
|
+
perms = validate_permissions(raw) or []
|
|
64
|
+
for perm in perms:
|
|
65
|
+
if not ctx.can(perm):
|
|
66
|
+
raise forbidden(f"forbidden: cannot grant {sanitize(perm, 128)}")
|
|
67
|
+
return perms
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def create_tokens_router(
|
|
71
|
+
auth_service: AuthService,
|
|
72
|
+
get_auth: Callable,
|
|
73
|
+
) -> APIRouter:
|
|
74
|
+
router = new_router("/tokens", "tokens")
|
|
75
|
+
store = auth_service.api_token_store
|
|
76
|
+
|
|
77
|
+
@collection(router, "GET")
|
|
78
|
+
async def list_tokens(all: str | None = None, ctx: AuthContext = Depends(get_auth)): # noqa: A002
|
|
79
|
+
if all not in (None, "true", "false"):
|
|
80
|
+
raise bad_request("all must be true or false")
|
|
81
|
+
all = all == "true" # noqa: A001
|
|
82
|
+
if all and not ctx.can(TOKENS_READ):
|
|
83
|
+
raise forbidden(f"forbidden: missing permission {TOKENS_READ}")
|
|
84
|
+
tokens = await store.get_tokens(None if all else ctx.user.id)
|
|
85
|
+
tokens.sort(key=lambda t: t.created_at)
|
|
86
|
+
return [await auth_service.api_token_view(t) for t in tokens]
|
|
87
|
+
|
|
88
|
+
@collection(router, "POST", status_code=201)
|
|
89
|
+
async def create_token(body: CreateApiTokenRequest, ctx: AuthContext = Depends(get_auth)):
|
|
90
|
+
# A leaked token must not be able to mint more tokens.
|
|
91
|
+
if ctx.api_token is not None:
|
|
92
|
+
raise forbidden(API_TOKENS_CANNOT_CREATE)
|
|
93
|
+
if ctx.session is None or ctx.external:
|
|
94
|
+
raise UsersApiError(501, MANAGED_BY_HOST)
|
|
95
|
+
name = _validate_name(body.name)
|
|
96
|
+
now = now_ms()
|
|
97
|
+
expires_at = _expires_at(body, auth_service.options.allow_non_expiring_tokens, now)
|
|
98
|
+
permissions = _scope(ctx, body.permissions)
|
|
99
|
+
existing = await store.get_tokens(ctx.user.id)
|
|
100
|
+
if any(t.name.lower() == name.lower() for t in existing):
|
|
101
|
+
raise conflict(f"token '{name}' already exists")
|
|
102
|
+
# Expired tokens stay listed but do not count toward the limit.
|
|
103
|
+
if sum(1 for t in existing if not t.is_expired(now)) >= MAX_TOKENS_PER_USER:
|
|
104
|
+
raise bad_request(f"a user may have at most {MAX_TOKENS_PER_USER} tokens")
|
|
105
|
+
token = auth_service.generate_api_token()
|
|
106
|
+
record = await store.create_token(ApiToken(
|
|
107
|
+
id=uuid.uuid4().hex,
|
|
108
|
+
user_id=ctx.user.id,
|
|
109
|
+
name=name,
|
|
110
|
+
token_hash=hash_token(token),
|
|
111
|
+
created_at=now,
|
|
112
|
+
expires_at=expires_at,
|
|
113
|
+
permissions=permissions,
|
|
114
|
+
))
|
|
115
|
+
await auth_service.audit(
|
|
116
|
+
"token_created", ctx.user.name,
|
|
117
|
+
details=f"{record.name} ({record.id})",
|
|
118
|
+
actor=ctx.user.name, actor_id=ctx.user.id, user_id=ctx.user.id,
|
|
119
|
+
metadata={"tokenId": record.id, "tokenName": record.name},
|
|
120
|
+
)
|
|
121
|
+
return {"token": token, "apiToken": record.to_view(ctx.user.name)}
|
|
122
|
+
|
|
123
|
+
@router.delete("/{token_id}", status_code=204)
|
|
124
|
+
async def revoke_token(token_id: str, ctx: AuthContext = Depends(get_auth)):
|
|
125
|
+
record = await store.get_token(token_id)
|
|
126
|
+
if record is None or (record.user_id != ctx.user.id and not ctx.can(TOKENS_REVOKE)):
|
|
127
|
+
raise not_found("token not found")
|
|
128
|
+
await store.delete_token(record.id)
|
|
129
|
+
owner = await auth_service.user_store.get_user(record.user_id)
|
|
130
|
+
# The audit also notifies change listeners: sockets opened with this
|
|
131
|
+
# token are re-checked and closed (4401 "session ended").
|
|
132
|
+
await auth_service.audit(
|
|
133
|
+
"token_revoked", owner.name if owner else record.user_id,
|
|
134
|
+
details=f"{record.name} ({record.id})",
|
|
135
|
+
actor=ctx.user.name, actor_id=ctx.user.id, user_id=record.user_id,
|
|
136
|
+
metadata={"tokenId": record.id, "tokenName": record.name},
|
|
137
|
+
)
|
|
138
|
+
return Response(status_code=204)
|
|
139
|
+
|
|
140
|
+
return router
|
|
@@ -20,6 +20,7 @@ from ..services.users_service import UsersService
|
|
|
20
20
|
from ._routing import collection, new_router
|
|
21
21
|
|
|
22
22
|
MANAGED_BY_HOST = "Authentication is managed by the host application"
|
|
23
|
+
API_TOKENS_CANNOT_CHANGE_PASSWORDS = "forbidden: API tokens cannot change passwords"
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
def create_users_router(
|
|
@@ -148,6 +149,8 @@ def create_users_router(
|
|
|
148
149
|
request: Request,
|
|
149
150
|
ctx: AuthContext = Depends(get_auth_pending),
|
|
150
151
|
):
|
|
152
|
+
if ctx.api_token is not None:
|
|
153
|
+
raise forbidden(API_TOKENS_CANNOT_CHANGE_PASSWORDS)
|
|
151
154
|
target = await user_store.get_user(id_or_name)
|
|
152
155
|
is_self = ctx.is_self(target)
|
|
153
156
|
# While a password change is pending only the own password may change.
|
|
@@ -142,6 +142,46 @@ class Session:
|
|
|
142
142
|
return result
|
|
143
143
|
|
|
144
144
|
|
|
145
|
+
@dataclass
|
|
146
|
+
class ApiToken:
|
|
147
|
+
"""A long-lived API token (``qcli_pat_…``).
|
|
148
|
+
|
|
149
|
+
Stores keep only ``token_hash`` (SHA-256 hex of the token), never the
|
|
150
|
+
token itself. ``permissions`` is an optional scope: ``None`` means the
|
|
151
|
+
token carries the user's full (live) permissions.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
id: str = ""
|
|
155
|
+
user_id: str = ""
|
|
156
|
+
#: 1-64 characters of ``[A-Za-z0-9._ -]``, unique per user (case-insensitive).
|
|
157
|
+
name: str = ""
|
|
158
|
+
token_hash: str = ""
|
|
159
|
+
created_at: int = field(default_factory=now_ms)
|
|
160
|
+
#: Epoch ms, or ``None`` when the token never expires.
|
|
161
|
+
expires_at: int | None = None
|
|
162
|
+
#: Updated at most once per 60 seconds.
|
|
163
|
+
last_used_at: int | None = None
|
|
164
|
+
permissions: list[str] | None = None
|
|
165
|
+
|
|
166
|
+
def is_expired(self, now: int | None = None) -> bool:
|
|
167
|
+
return self.expires_at is not None and (now if now is not None else now_ms()) >= self.expires_at
|
|
168
|
+
|
|
169
|
+
def to_view(self, user_name: str) -> dict[str, Any]:
|
|
170
|
+
"""``GET /tokens`` shape — never includes the token or its hash."""
|
|
171
|
+
result: dict[str, Any] = {
|
|
172
|
+
"id": self.id,
|
|
173
|
+
"name": self.name,
|
|
174
|
+
"userId": self.user_id,
|
|
175
|
+
"userName": user_name,
|
|
176
|
+
"createdAt": self.created_at,
|
|
177
|
+
"expiresAt": self.expires_at,
|
|
178
|
+
"lastUsedAt": self.last_used_at,
|
|
179
|
+
}
|
|
180
|
+
if self.permissions is not None:
|
|
181
|
+
result["permissions"] = list(self.permissions)
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
145
185
|
@dataclass
|
|
146
186
|
class AuditEntry:
|
|
147
187
|
id: str = field(default_factory=_new_id)
|
|
@@ -251,6 +291,14 @@ class VerifyPasswordRequest(_Body):
|
|
|
251
291
|
password: str
|
|
252
292
|
|
|
253
293
|
|
|
294
|
+
class CreateApiTokenRequest(_Body):
|
|
295
|
+
name: Any = None
|
|
296
|
+
#: Integer 1-365 (default 90); ``null`` = never expires (when allowed).
|
|
297
|
+
#: Typed ``Any`` so missing, ``null`` and wrong types can be told apart.
|
|
298
|
+
expiresInDays: Any = None # noqa: N815
|
|
299
|
+
permissions: Any = None
|
|
300
|
+
|
|
301
|
+
|
|
254
302
|
class CreateGroupRequest(_Body):
|
|
255
303
|
name: str
|
|
256
304
|
description: str | None = None
|
|
@@ -38,7 +38,7 @@ PUBLIC_PATHS: list[tuple[re.Pattern[str], frozenset[str] | None]] = [
|
|
|
38
38
|
|
|
39
39
|
#: Routes of the users plugin itself — they authenticate in their own
|
|
40
40
|
#: dependencies (with CSRF and password-change gating).
|
|
41
|
-
SELF_PROTECTED = re.compile(r"^/api/v1/qcli/(sessions|users|groups|audit)(/.*)?$")
|
|
41
|
+
SELF_PROTECTED = re.compile(r"^/api/v1/qcli/(sessions|users|groups|audit|tokens)(/.*)?$")
|
|
42
42
|
|
|
43
43
|
#: HTTP permission rules, first match wins: (path, methods or None, permission).
|
|
44
44
|
HTTP_RULES: list[Rule] = [
|
|
@@ -48,6 +48,9 @@ HTTP_RULES: list[Rule] = [
|
|
|
48
48
|
(re.compile(r"^/api/(v1/)?qcli/jobs(/.*)?$"), frozenset({"GET", "HEAD"}), "jobs:read"),
|
|
49
49
|
(re.compile(r"^/api/(v1/)?qcli/jobs(/.*)?$"), None, "jobs:manage"),
|
|
50
50
|
(re.compile(r"^/api/(v1/)?qcli/execute(/stream)?/?$"), None, "commands:execute"),
|
|
51
|
+
# MCP endpoint (tools run commands; each tool also needs the processor's
|
|
52
|
+
# required permissions).
|
|
53
|
+
(re.compile(r"^/api/qcli/mcp/?$"), None, "commands:execute"),
|
|
51
54
|
]
|
|
52
55
|
|
|
53
56
|
WS_RULES: list[tuple[re.Pattern[str], str | None]] = [
|
|
@@ -18,6 +18,7 @@ from typing import Any, Awaitable, Callable, Optional, Union
|
|
|
18
18
|
from .._http import forwarded_client_ip
|
|
19
19
|
from ..errors import UsersApiError, forbidden, unauthorized
|
|
20
20
|
from ..models import (
|
|
21
|
+
ApiToken,
|
|
21
22
|
AuditEntry,
|
|
22
23
|
ExternalAuthResult,
|
|
23
24
|
Session,
|
|
@@ -27,6 +28,7 @@ from ..models import (
|
|
|
27
28
|
)
|
|
28
29
|
from ..permissions import ADMIN_GROUP, USERS_GROUP, has_permission, merge_permissions, permission_matches
|
|
29
30
|
from ..stores.interfaces import (
|
|
31
|
+
IApiTokenStore,
|
|
30
32
|
IAuditStore,
|
|
31
33
|
ICredentialStore,
|
|
32
34
|
IGroupStore,
|
|
@@ -58,6 +60,12 @@ INSUFFICIENT_PRIVILEGES = "insufficient privileges for this user"
|
|
|
58
60
|
|
|
59
61
|
_MAX_TRACKED_KEYS = 10_000
|
|
60
62
|
|
|
63
|
+
#: Prefix of API tokens (``qcli_pat_`` + 32 random bytes, base64url, no
|
|
64
|
+
#: padding): bearer tokens with it are looked up in the API token store.
|
|
65
|
+
API_TOKEN_PREFIX = "qcli_pat_"
|
|
66
|
+
#: ``last_used_at`` is written at most once per this interval.
|
|
67
|
+
API_TOKEN_LAST_USED_INTERVAL_MS = 60 * 1000
|
|
68
|
+
|
|
61
69
|
#: Receives the JSON-encoded event; may be sync or async.
|
|
62
70
|
BroadcastFn = Callable[[str], Union[Awaitable[None], None]]
|
|
63
71
|
#: ``(user_id, json_message)`` — delivers to sockets of that user only.
|
|
@@ -89,8 +97,18 @@ class AuthContext:
|
|
|
89
97
|
groups: list[str]
|
|
90
98
|
permissions: list[str]
|
|
91
99
|
session: Session | None = None
|
|
100
|
+
#: The raw session bearer token (``None`` for API tokens and adapters).
|
|
92
101
|
token: str | None = None
|
|
93
102
|
external: bool = False
|
|
103
|
+
#: Set when authenticated by an API token (then ``session`` is ``None``).
|
|
104
|
+
api_token: ApiToken | None = None
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def api_token_id(self) -> str | None:
|
|
108
|
+
return self.api_token.id if self.api_token is not None else None
|
|
109
|
+
|
|
110
|
+
#: Server auth-context name of :attr:`api_token_id`.
|
|
111
|
+
token_id = api_token_id
|
|
94
112
|
|
|
95
113
|
def can(self, permission: str) -> bool:
|
|
96
114
|
return has_permission(self.permissions, permission)
|
|
@@ -123,6 +141,7 @@ class _Ticket:
|
|
|
123
141
|
user_id: str
|
|
124
142
|
expires_at: int
|
|
125
143
|
external_ctx: AuthContext | None = None
|
|
144
|
+
api_token_id: str | None = None
|
|
126
145
|
|
|
127
146
|
|
|
128
147
|
@dataclass
|
|
@@ -143,6 +162,8 @@ class AuthServiceOptions:
|
|
|
143
162
|
#: always accepted.
|
|
144
163
|
allowed_origins: list[str] | None = None
|
|
145
164
|
ticket_ttl_ms: int = TICKET_TTL_MS
|
|
165
|
+
#: Allow API tokens that never expire (``expiresInDays: null``).
|
|
166
|
+
allow_non_expiring_tokens: bool = False
|
|
146
167
|
|
|
147
168
|
|
|
148
169
|
class AuthService:
|
|
@@ -160,6 +181,7 @@ class AuthService:
|
|
|
160
181
|
options: AuthServiceOptions | None = None,
|
|
161
182
|
broadcast_fn: BroadcastFn | None = None,
|
|
162
183
|
auth_adapter: Optional[Callable[[Any], Any]] = None,
|
|
184
|
+
api_token_store: IApiTokenStore | None = None,
|
|
163
185
|
) -> None:
|
|
164
186
|
if group_store is None:
|
|
165
187
|
from ..stores.in_memory_group_store import InMemoryGroupStore
|
|
@@ -169,6 +191,10 @@ class AuthService:
|
|
|
169
191
|
self.credential_store = credential_store
|
|
170
192
|
self.audit_store = audit_store
|
|
171
193
|
self.group_store = group_store
|
|
194
|
+
if api_token_store is None:
|
|
195
|
+
from ..stores.in_memory_api_token_store import InMemoryApiTokenStore
|
|
196
|
+
api_token_store = InMemoryApiTokenStore()
|
|
197
|
+
self.api_token_store = api_token_store
|
|
172
198
|
self.options = options or AuthServiceOptions()
|
|
173
199
|
self.broadcast_fn = broadcast_fn
|
|
174
200
|
#: Set when mounted on a CLI server: user-scoped event delivery.
|
|
@@ -514,6 +540,8 @@ class AuthService:
|
|
|
514
540
|
return True
|
|
515
541
|
if ctx.external:
|
|
516
542
|
return True
|
|
543
|
+
if ctx.api_token is not None:
|
|
544
|
+
return await self.is_api_token_active(ctx.api_token.id, ctx.user.id)
|
|
517
545
|
if ctx.session is None:
|
|
518
546
|
return False
|
|
519
547
|
session = await self.session_store.get_session_by_id(ctx.session.id)
|
|
@@ -555,7 +583,10 @@ class AuthService:
|
|
|
555
583
|
)
|
|
556
584
|
|
|
557
585
|
async def authenticate_token(self, token: str) -> AuthContext:
|
|
558
|
-
"""Resolve a bearer token to the live user;
|
|
586
|
+
"""Resolve a bearer token (session or API token) to the live user;
|
|
587
|
+
raises 401 otherwise."""
|
|
588
|
+
if token.startswith(API_TOKEN_PREFIX):
|
|
589
|
+
return await self.authenticate_api_token(token)
|
|
559
590
|
token_hash = hash_token(token)
|
|
560
591
|
session = await self.session_store.get_session(token_hash)
|
|
561
592
|
if not session:
|
|
@@ -566,6 +597,55 @@ class AuthService:
|
|
|
566
597
|
ctx.token = token
|
|
567
598
|
return ctx
|
|
568
599
|
|
|
600
|
+
# ------------------------------------------------------------------
|
|
601
|
+
# API tokens
|
|
602
|
+
# ------------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
@staticmethod
|
|
605
|
+
def generate_api_token() -> str:
|
|
606
|
+
"""``qcli_pat_`` + 32 random bytes, base64url without padding."""
|
|
607
|
+
return API_TOKEN_PREFIX + secrets.token_urlsafe(32)
|
|
608
|
+
|
|
609
|
+
async def context_for_api_token(self, record: ApiToken) -> AuthContext:
|
|
610
|
+
if record.is_expired():
|
|
611
|
+
raise unauthorized("invalid or expired token")
|
|
612
|
+
user = await self.user_store.get_user(record.user_id)
|
|
613
|
+
if not user or user.id != record.user_id or user.disabled:
|
|
614
|
+
raise unauthorized("invalid or expired token")
|
|
615
|
+
groups = list(user.groups or [])
|
|
616
|
+
permissions = await self.effective_permissions(groups)
|
|
617
|
+
if record.permissions is not None:
|
|
618
|
+
# Scoped: keep only scope entries the user still holds.
|
|
619
|
+
permissions = [p for p in record.permissions if has_permission(permissions, p)]
|
|
620
|
+
return AuthContext(user=user, groups=groups, permissions=permissions, api_token=record)
|
|
621
|
+
|
|
622
|
+
async def authenticate_api_token(self, token: str) -> AuthContext:
|
|
623
|
+
"""Resolve an API token to its live user and (scoped) permissions."""
|
|
624
|
+
record = await self.api_token_store.get_token_by_hash(hash_token(token))
|
|
625
|
+
if record is None:
|
|
626
|
+
raise unauthorized("invalid or expired token")
|
|
627
|
+
ctx = await self.context_for_api_token(record)
|
|
628
|
+
now = now_ms()
|
|
629
|
+
if record.last_used_at is None or now - record.last_used_at >= API_TOKEN_LAST_USED_INTERVAL_MS:
|
|
630
|
+
await self.api_token_store.update_last_used(record.id, now)
|
|
631
|
+
record.last_used_at = now
|
|
632
|
+
return ctx
|
|
633
|
+
|
|
634
|
+
async def is_api_token_active(self, token_id: str, user_id: str) -> bool:
|
|
635
|
+
"""Token not revoked/expired and its user neither disabled nor deleted."""
|
|
636
|
+
record = await self.api_token_store.get_token(token_id)
|
|
637
|
+
if record is None or record.user_id != user_id:
|
|
638
|
+
return False
|
|
639
|
+
try:
|
|
640
|
+
await self.context_for_api_token(record)
|
|
641
|
+
except UsersApiError:
|
|
642
|
+
return False
|
|
643
|
+
return True
|
|
644
|
+
|
|
645
|
+
async def api_token_view(self, record: ApiToken) -> dict[str, Any]:
|
|
646
|
+
user = await self.user_store.get_user(record.user_id)
|
|
647
|
+
return record.to_view(user.name if user else "")
|
|
648
|
+
|
|
569
649
|
async def authenticate_external(self, request: Any) -> AuthContext:
|
|
570
650
|
assert self.auth_adapter is not None
|
|
571
651
|
result = self.auth_adapter(request)
|
|
@@ -616,6 +696,7 @@ class AuthService:
|
|
|
616
696
|
user_id=ctx.user.id,
|
|
617
697
|
expires_at=now + self.options.ticket_ttl_ms,
|
|
618
698
|
external_ctx=ctx if ctx.external else None,
|
|
699
|
+
api_token_id=ctx.api_token_id,
|
|
619
700
|
)
|
|
620
701
|
return ticket, self.options.ticket_ttl_ms // 1000
|
|
621
702
|
|
|
@@ -633,6 +714,14 @@ class AuthService:
|
|
|
633
714
|
return None
|
|
634
715
|
if entry.external_ctx is not None:
|
|
635
716
|
return entry.external_ctx
|
|
717
|
+
if entry.api_token_id is not None:
|
|
718
|
+
record = await self.api_token_store.get_token(entry.api_token_id)
|
|
719
|
+
if record is None or record.user_id != entry.user_id:
|
|
720
|
+
return None
|
|
721
|
+
try:
|
|
722
|
+
return await self.context_for_api_token(record)
|
|
723
|
+
except UsersApiError:
|
|
724
|
+
return None
|
|
636
725
|
if entry.session_id is None:
|
|
637
726
|
return None
|
|
638
727
|
session = await self.session_store.get_session_by_id(entry.session_id)
|
|
@@ -776,6 +865,7 @@ class AuthService:
|
|
|
776
865
|
|
|
777
866
|
|
|
778
867
|
__all__ = [
|
|
868
|
+
"API_TOKEN_PREFIX",
|
|
779
869
|
"AuthContext",
|
|
780
870
|
"AuthService",
|
|
781
871
|
"AuthServiceOptions",
|
|
@@ -209,7 +209,10 @@ class UsersService:
|
|
|
209
209
|
await self.credentials.delete_credentials(target.id)
|
|
210
210
|
self.auth.reset_failures(target.id)
|
|
211
211
|
actor = _actor(ctx)
|
|
212
|
+
# Sessions first (sends session-kicked), then the user's API tokens.
|
|
212
213
|
await self.auth.revoke_user_sessions(target.id, "user_deleted", **actor)
|
|
214
|
+
await self.auth.api_token_store.delete_user_tokens(target.id)
|
|
215
|
+
self.auth.notify_change()
|
|
213
216
|
await self.auth.audit("user_deleted", target.name, user_id=target.id, **actor)
|
|
214
217
|
|
|
215
218
|
async def change_password(
|
|
@@ -71,6 +71,8 @@ class UsersShellAccess:
|
|
|
71
71
|
ctx = principal.context
|
|
72
72
|
if ctx.external:
|
|
73
73
|
return await self._adapter_active(principal)
|
|
74
|
+
if ctx.api_token is not None:
|
|
75
|
+
return await self._api_token_active(principal)
|
|
74
76
|
if principal.session_id is None:
|
|
75
77
|
return False
|
|
76
78
|
session = await self.auth.session_store.get_session_by_id(principal.session_id)
|
|
@@ -83,6 +85,24 @@ class UsersShellAccess:
|
|
|
83
85
|
return PERMISSION_REMOVED
|
|
84
86
|
return True
|
|
85
87
|
|
|
88
|
+
async def _api_token_active(self, principal: UsersShellPrincipal) -> bool | str:
|
|
89
|
+
"""API token identities: the token must still exist and be unexpired,
|
|
90
|
+
its user enabled; the (scoped) permissions are re-evaluated."""
|
|
91
|
+
token = principal.context.api_token
|
|
92
|
+
assert token is not None
|
|
93
|
+
record = await self.auth.api_token_store.get_token(token.id)
|
|
94
|
+
if record is None or record.user_id != principal.user_id:
|
|
95
|
+
return False
|
|
96
|
+
try:
|
|
97
|
+
fresh = await self.auth.context_for_api_token(record)
|
|
98
|
+
except Exception:
|
|
99
|
+
return False
|
|
100
|
+
if fresh.must_change_password:
|
|
101
|
+
return False
|
|
102
|
+
if not fresh.can(SHELL_PERMISSION):
|
|
103
|
+
return PERMISSION_REMOVED
|
|
104
|
+
return True
|
|
105
|
+
|
|
86
106
|
async def _adapter_active(self, principal: UsersShellPrincipal) -> bool | str:
|
|
87
107
|
"""Adapter identities have no stored session: ask the external auth
|
|
88
108
|
adapter again with the shell's WebSocket (its headers and cookies).
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Users plugin store implementations."""
|
|
2
2
|
|
|
3
3
|
from .interfaces import (
|
|
4
|
+
IApiTokenStore,
|
|
4
5
|
IUserStore,
|
|
5
6
|
ISessionStore,
|
|
6
7
|
ICredentialStore,
|
|
@@ -12,8 +13,11 @@ from .in_memory_session_store import InMemorySessionStore
|
|
|
12
13
|
from .in_memory_credential_store import InMemoryCredentialStore
|
|
13
14
|
from .in_memory_audit_store import InMemoryAuditStore
|
|
14
15
|
from .in_memory_group_store import InMemoryGroupStore
|
|
16
|
+
from .in_memory_api_token_store import InMemoryApiTokenStore
|
|
15
17
|
|
|
16
18
|
__all__ = [
|
|
19
|
+
"IApiTokenStore",
|
|
20
|
+
"InMemoryApiTokenStore",
|
|
17
21
|
"IAuditStore",
|
|
18
22
|
"ICredentialStore",
|
|
19
23
|
"IGroupStore",
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""In-memory implementation of IApiTokenStore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
|
|
7
|
+
from ..models import ApiToken
|
|
8
|
+
from .interfaces import IApiTokenStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InMemoryApiTokenStore(IApiTokenStore):
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._tokens: dict[str, ApiToken] = {} # keyed by id
|
|
14
|
+
|
|
15
|
+
async def create_token(self, token: ApiToken) -> ApiToken:
|
|
16
|
+
self._tokens[token.id] = copy.deepcopy(token)
|
|
17
|
+
return copy.deepcopy(token)
|
|
18
|
+
|
|
19
|
+
async def get_token_by_hash(self, token_hash: str) -> ApiToken | None:
|
|
20
|
+
for token in self._tokens.values():
|
|
21
|
+
if token.token_hash == token_hash:
|
|
22
|
+
return copy.deepcopy(token)
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
async def get_token(self, token_id: str) -> ApiToken | None:
|
|
26
|
+
token = self._tokens.get(token_id)
|
|
27
|
+
return copy.deepcopy(token) if token else None
|
|
28
|
+
|
|
29
|
+
async def get_tokens(self, user_id: str | None = None) -> list[ApiToken]:
|
|
30
|
+
return [
|
|
31
|
+
copy.deepcopy(t) for t in self._tokens.values()
|
|
32
|
+
if user_id is None or t.user_id == user_id
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
async def delete_token(self, token_id: str) -> None:
|
|
36
|
+
self._tokens.pop(token_id, None)
|
|
37
|
+
|
|
38
|
+
async def delete_user_tokens(self, user_id: str) -> None:
|
|
39
|
+
for token_id, token in list(self._tokens.items()):
|
|
40
|
+
if token.user_id == user_id:
|
|
41
|
+
del self._tokens[token_id]
|
|
42
|
+
|
|
43
|
+
async def update_last_used(self, token_id: str, last_used_at: int) -> None:
|
|
44
|
+
token = self._tokens.get(token_id)
|
|
45
|
+
if token:
|
|
46
|
+
token.last_used_at = last_used_at
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from abc import ABC, abstractmethod
|
|
11
11
|
from ..models import (
|
|
12
|
+
ApiToken,
|
|
12
13
|
AuditEntry,
|
|
13
14
|
AuditQueryOptions,
|
|
14
15
|
Credentials,
|
|
@@ -199,7 +200,44 @@ class IGroupStore(ABC):
|
|
|
199
200
|
...
|
|
200
201
|
|
|
201
202
|
|
|
203
|
+
class IApiTokenStore(ABC):
|
|
204
|
+
"""Persistence interface for API tokens.
|
|
205
|
+
|
|
206
|
+
``token_hash`` is the SHA-256 (hex) of the token — raw tokens never reach
|
|
207
|
+
the store.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
@abstractmethod
|
|
211
|
+
async def create_token(self, token: ApiToken) -> ApiToken:
|
|
212
|
+
...
|
|
213
|
+
|
|
214
|
+
@abstractmethod
|
|
215
|
+
async def get_token_by_hash(self, token_hash: str) -> ApiToken | None:
|
|
216
|
+
...
|
|
217
|
+
|
|
218
|
+
@abstractmethod
|
|
219
|
+
async def get_token(self, token_id: str) -> ApiToken | None:
|
|
220
|
+
...
|
|
221
|
+
|
|
222
|
+
@abstractmethod
|
|
223
|
+
async def get_tokens(self, user_id: str | None = None) -> list[ApiToken]:
|
|
224
|
+
"""Every token, or only the tokens of ``user_id``."""
|
|
225
|
+
|
|
226
|
+
@abstractmethod
|
|
227
|
+
async def delete_token(self, token_id: str) -> None:
|
|
228
|
+
...
|
|
229
|
+
|
|
230
|
+
@abstractmethod
|
|
231
|
+
async def delete_user_tokens(self, user_id: str) -> None:
|
|
232
|
+
...
|
|
233
|
+
|
|
234
|
+
@abstractmethod
|
|
235
|
+
async def update_last_used(self, token_id: str, last_used_at: int) -> None:
|
|
236
|
+
...
|
|
237
|
+
|
|
238
|
+
|
|
202
239
|
__all__ = [
|
|
240
|
+
"IApiTokenStore",
|
|
203
241
|
"IAuditStore",
|
|
204
242
|
"ICredentialStore",
|
|
205
243
|
"IGroupStore",
|
|
@@ -10,7 +10,10 @@ from .errors import bad_request
|
|
|
10
10
|
|
|
11
11
|
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@-]*$")
|
|
12
12
|
NAME_MAX = 64
|
|
13
|
-
|
|
13
|
+
# Email: dot-atom local part (max 64) @ DNS labels ending in a letters-only TLD, or localhost; same rule as web-cli and the other servers.
|
|
14
|
+
EMAIL_RE = re.compile(
|
|
15
|
+
r"(?=[^@]{1,64}@)[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:localhost|(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63})"
|
|
16
|
+
)
|
|
14
17
|
EMAIL_MAX = 254
|
|
15
18
|
PERMISSION_RE = re.compile(r"^(\*|[a-z0-9_.-]+(:([a-z0-9_.-]+|\*))*)$", re.IGNORECASE)
|
|
16
19
|
PERMISSION_MAX = 128
|
|
@@ -58,7 +61,7 @@ def validate_name(value: str, kind: str = "name") -> str:
|
|
|
58
61
|
def validate_email(value: str) -> str:
|
|
59
62
|
if not value:
|
|
60
63
|
raise bad_request("email is required")
|
|
61
|
-
if len(value) > EMAIL_MAX or has_control_chars(value) or not EMAIL_RE.
|
|
64
|
+
if len(value) > EMAIL_MAX or has_control_chars(value) or not EMAIL_RE.fullmatch(value):
|
|
62
65
|
raise bad_request("invalid email")
|
|
63
66
|
return value
|
|
64
67
|
|
{qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/.gitignore
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|