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.
Files changed (34) hide show
  1. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/PKG-INFO +1 -1
  2. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/pyproject.toml +1 -1
  3. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/__init__.py +6 -0
  4. {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
  5. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/controllers/__init__.py +2 -0
  6. {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
  7. qodalis_cli_server_plugin_users-1.0.0b7/qodalis_cli_users/controllers/tokens_controller.py +140 -0
  8. {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
  9. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/models.py +48 -0
  10. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/permissions.py +2 -0
  11. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/server_protection.py +4 -1
  12. {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
  13. {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
  14. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/shell_access.py +20 -0
  15. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/stores/__init__.py +4 -0
  16. qodalis_cli_server_plugin_users-1.0.0b7/qodalis_cli_users/stores/in_memory_api_token_store.py +46 -0
  17. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/stores/interfaces.py +38 -0
  18. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/validation.py +5 -2
  19. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/.gitignore +0 -0
  20. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/LICENSE +0 -0
  21. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/_http.py +0 -0
  22. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/controllers/_routing.py +0 -0
  23. {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
  24. {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
  25. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/errors.py +0 -0
  26. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/middleware/__init__.py +0 -0
  27. {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
  28. {qodalis_cli_server_plugin_users-1.0.0b6 → qodalis_cli_server_plugin_users-1.0.0b7}/qodalis_cli_users/services/__init__.py +0 -0
  29. {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
  30. {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
  31. {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
  32. {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
  33. {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
  34. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: qodalis-cli-server-plugin-users
3
- Version: 1.0.0b6
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
@@ -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.0b6"
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
- assert ctx.session is not None
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
@@ -23,6 +23,8 @@ SESSIONS_READ = "sessions:read"
23
23
  SESSIONS_REVOKE = "sessions:revoke"
24
24
  AUDIT_READ = "audit:read"
25
25
  AUDIT_CLEAR = "audit:clear"
26
+ TOKENS_READ = "tokens:read"
27
+ TOKENS_REVOKE = "tokens:revoke"
26
28
 
27
29
  ADMIN_GROUP = "admin"
28
30
  USERS_GROUP = "users"
@@ -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; raises 401 otherwise."""
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
- EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+$")
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.match(value):
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