qodalis-cli-server-plugin-users 1.0.0b6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qodalis_cli_server_plugin_users-1.0.0b6.dist-info/METADATA +21 -0
- qodalis_cli_server_plugin_users-1.0.0b6.dist-info/RECORD +32 -0
- qodalis_cli_server_plugin_users-1.0.0b6.dist-info/WHEEL +4 -0
- qodalis_cli_server_plugin_users-1.0.0b6.dist-info/licenses/LICENSE +21 -0
- qodalis_cli_users/__init__.py +134 -0
- qodalis_cli_users/_http.py +162 -0
- qodalis_cli_users/cli_users_builder.py +385 -0
- qodalis_cli_users/controllers/__init__.py +13 -0
- qodalis_cli_users/controllers/_routing.py +25 -0
- qodalis_cli_users/controllers/audit_controller.py +40 -0
- qodalis_cli_users/controllers/groups_controller.py +109 -0
- qodalis_cli_users/controllers/sessions_controller.py +114 -0
- qodalis_cli_users/controllers/users_controller.py +183 -0
- qodalis_cli_users/errors.py +118 -0
- qodalis_cli_users/middleware/__init__.py +5 -0
- qodalis_cli_users/middleware/auth_middleware.py +77 -0
- qodalis_cli_users/models.py +282 -0
- qodalis_cli_users/permissions.py +58 -0
- qodalis_cli_users/server_protection.py +209 -0
- qodalis_cli_users/services/__init__.py +6 -0
- qodalis_cli_users/services/auth_service.py +786 -0
- qodalis_cli_users/services/password_hasher.py +98 -0
- qodalis_cli_users/services/users_service.py +330 -0
- qodalis_cli_users/shell_access.py +125 -0
- qodalis_cli_users/stores/__init__.py +27 -0
- qodalis_cli_users/stores/in_memory_audit_store.py +50 -0
- qodalis_cli_users/stores/in_memory_credential_store.py +25 -0
- qodalis_cli_users/stores/in_memory_group_store.py +65 -0
- qodalis_cli_users/stores/in_memory_session_store.py +71 -0
- qodalis_cli_users/stores/in_memory_user_store.py +102 -0
- qodalis_cli_users/stores/interfaces.py +208 -0
- qodalis_cli_users/validation.py +100 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Fluent builder for configuring and creating the users plugin.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
users_plugin = (
|
|
6
|
+
CliUsersBuilder()
|
|
7
|
+
.set_default_admin_password("admin")
|
|
8
|
+
.set_session_timeout(30 * 60 * 1000)
|
|
9
|
+
.build()
|
|
10
|
+
)
|
|
11
|
+
# Protects every server route/socket and scopes user events to the
|
|
12
|
+
# user's own event sockets.
|
|
13
|
+
result.mount_plugin(users_plugin)
|
|
14
|
+
|
|
15
|
+
# in the app lifespan / startup hook (running event loop):
|
|
16
|
+
await users_plugin.startup() # seed + start session cleanup
|
|
17
|
+
...
|
|
18
|
+
await users_plugin.shutdown()
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import logging
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any, Optional
|
|
27
|
+
|
|
28
|
+
from fastapi import APIRouter
|
|
29
|
+
|
|
30
|
+
from .controllers.audit_controller import create_audit_router
|
|
31
|
+
from .controllers.groups_controller import create_groups_router
|
|
32
|
+
from .controllers.sessions_controller import create_sessions_router
|
|
33
|
+
from .controllers.users_controller import create_users_router
|
|
34
|
+
from .middleware.auth_middleware import create_auth_dependencies
|
|
35
|
+
from .models import ExternalAuthAdapter
|
|
36
|
+
from .server_protection import ServerProtectionMiddleware, _normalize_base
|
|
37
|
+
from .shell_access import UsersShellAccess
|
|
38
|
+
from .services.auth_service import (
|
|
39
|
+
DEFAULT_LOCKOUT_MS,
|
|
40
|
+
DEFAULT_MAX_LOGIN_ATTEMPTS,
|
|
41
|
+
DEFAULT_MAX_SESSION_LIFETIME_MS,
|
|
42
|
+
DEFAULT_MIN_PASSWORD_LENGTH,
|
|
43
|
+
DEFAULT_RATE_LIMIT_ATTEMPTS,
|
|
44
|
+
DEFAULT_RATE_LIMIT_WINDOW_MS,
|
|
45
|
+
DEFAULT_SESSION_TIMEOUT_MS,
|
|
46
|
+
AuthService,
|
|
47
|
+
AuthServiceOptions,
|
|
48
|
+
BroadcastFn,
|
|
49
|
+
)
|
|
50
|
+
from .services.users_service import UsersService
|
|
51
|
+
from .stores.in_memory_audit_store import InMemoryAuditStore
|
|
52
|
+
from .stores.in_memory_credential_store import InMemoryCredentialStore
|
|
53
|
+
from .stores.in_memory_group_store import InMemoryGroupStore
|
|
54
|
+
from .stores.in_memory_session_store import InMemorySessionStore
|
|
55
|
+
from .stores.in_memory_user_store import InMemoryUserStore
|
|
56
|
+
from .stores.interfaces import (
|
|
57
|
+
IAuditStore,
|
|
58
|
+
ICredentialStore,
|
|
59
|
+
IGroupStore,
|
|
60
|
+
ISessionStore,
|
|
61
|
+
IUserStore,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
PREFIX = "/api/v1/qcli"
|
|
67
|
+
DEFAULT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class CliUsersPlugin:
|
|
72
|
+
"""The built users plugin.
|
|
73
|
+
|
|
74
|
+
``prefix`` / ``router`` make it mountable via
|
|
75
|
+
``CliServerResult.mount_plugin``. Call :meth:`startup` from the app's
|
|
76
|
+
lifespan (or startup hook) and :meth:`shutdown` on exit.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
prefix: str
|
|
80
|
+
router: APIRouter
|
|
81
|
+
auth_service: AuthService
|
|
82
|
+
users_service: UsersService
|
|
83
|
+
user_store: IUserStore
|
|
84
|
+
session_store: ISessionStore
|
|
85
|
+
credential_store: ICredentialStore
|
|
86
|
+
group_store: IGroupStore
|
|
87
|
+
audit_store: IAuditStore
|
|
88
|
+
default_admin_password: Optional[str] = None
|
|
89
|
+
cleanup_interval_ms: int = DEFAULT_CLEANUP_INTERVAL_MS
|
|
90
|
+
#: Protect every CLI server route/socket when mounted (B1).
|
|
91
|
+
protect_server: bool = True
|
|
92
|
+
#: FastAPI dependency: authenticated caller (``AuthContext``) — for
|
|
93
|
+
#: host-app routes. 401 / 403 (password change required).
|
|
94
|
+
require_auth: Any = None
|
|
95
|
+
#: ``require_permission("perm")`` → FastAPI dependency (403 if missing).
|
|
96
|
+
require_permission: Any = None
|
|
97
|
+
_extra_public: list = field(default_factory=list, repr=False)
|
|
98
|
+
_base_paths: list = field(default_factory=list, repr=False)
|
|
99
|
+
_protected_apps: list = field(default_factory=list, repr=False)
|
|
100
|
+
_cleanup_task: Optional[asyncio.Task] = field(default=None, repr=False)
|
|
101
|
+
_seeded: bool = field(default=False, repr=False)
|
|
102
|
+
_seed_lock: Optional[asyncio.Lock] = field(default=None, repr=False)
|
|
103
|
+
|
|
104
|
+
async def ensure_seeded(self) -> None:
|
|
105
|
+
"""Seed built-in groups and the root user once (idempotent)."""
|
|
106
|
+
if self._seeded:
|
|
107
|
+
return
|
|
108
|
+
if self._seed_lock is None:
|
|
109
|
+
self._seed_lock = asyncio.Lock()
|
|
110
|
+
async with self._seed_lock:
|
|
111
|
+
if self._seeded:
|
|
112
|
+
return
|
|
113
|
+
await self.auth_service.seed(self.default_admin_password)
|
|
114
|
+
self._seeded = True
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def auth(self) -> AuthService:
|
|
118
|
+
return self.auth_service
|
|
119
|
+
|
|
120
|
+
def allow_anonymous(self, path: str, methods: list[str] | None = None) -> None:
|
|
121
|
+
"""Exempt an exact path from server protection (e.g. another plugin's
|
|
122
|
+
login endpoint)."""
|
|
123
|
+
self._extra_public.append(
|
|
124
|
+
(path, frozenset(m.upper() for m in methods) if methods else None),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def protect(self, app: Any) -> None:
|
|
128
|
+
"""Install the server-protection middleware on ``app`` (innermost,
|
|
129
|
+
so CORS stays outermost). Must be called before the app starts."""
|
|
130
|
+
if any(a is app for a in self._protected_apps):
|
|
131
|
+
return
|
|
132
|
+
from starlette.middleware import Middleware
|
|
133
|
+
|
|
134
|
+
app.user_middleware.append(Middleware(
|
|
135
|
+
ServerProtectionMiddleware,
|
|
136
|
+
auth_service=self.auth_service,
|
|
137
|
+
ensure_ready=self.ensure_seeded,
|
|
138
|
+
extra_public=self._extra_public,
|
|
139
|
+
base_paths=self._base_paths,
|
|
140
|
+
))
|
|
141
|
+
app.middleware_stack = None
|
|
142
|
+
self._protected_apps.append(app)
|
|
143
|
+
|
|
144
|
+
def on_mount(self, server: Any) -> None:
|
|
145
|
+
"""Hook called by ``CliServerResult.mount_plugin``: scopes user events
|
|
146
|
+
to the user's own event sockets, closes events/logs sockets whose session
|
|
147
|
+
ended, adopts the server's allowed origins (CSRF), protects the whole
|
|
148
|
+
server unless disabled and owns access to the remote shell (``users``
|
|
149
|
+
shell mode)."""
|
|
150
|
+
manager = getattr(server, "event_socket_manager", None)
|
|
151
|
+
if manager is not None and hasattr(manager, "send_to_user"):
|
|
152
|
+
self.auth_service.user_event_sender = manager.send_to_user
|
|
153
|
+
options = getattr(server, "options", None)
|
|
154
|
+
origins = getattr(options, "allowed_origins", None)
|
|
155
|
+
if origins and self.auth_service.options.allowed_origins is None:
|
|
156
|
+
self.auth_service.options.allowed_origins = list(origins)
|
|
157
|
+
# A custom CLI API base path is protected like the default ones.
|
|
158
|
+
base_path = getattr(options, "base_path", None)
|
|
159
|
+
if isinstance(base_path, str) and base_path.strip("/"):
|
|
160
|
+
base = _normalize_base(base_path)
|
|
161
|
+
if base not in self._base_paths:
|
|
162
|
+
self._base_paths.append(base)
|
|
163
|
+
if self.protect_server:
|
|
164
|
+
self.protect(server.app)
|
|
165
|
+
# Events/logs sockets are bound to their session: closed (4401
|
|
166
|
+
# "session ended") on logout, revoke, disable/delete and expiry.
|
|
167
|
+
for name in ("event_socket_manager", "log_socket_manager"):
|
|
168
|
+
sockets = getattr(server, name, None)
|
|
169
|
+
if sockets is not None and hasattr(sockets, "set_session_check"):
|
|
170
|
+
sockets.set_session_check(self.auth_service.is_context_active)
|
|
171
|
+
self.auth_service.add_change_listener(sockets.request_recheck)
|
|
172
|
+
# Remote shell: users mode (ticket + shell:execute, session-bound).
|
|
173
|
+
set_shell_provider = getattr(server, "set_shell_access_provider", None)
|
|
174
|
+
if callable(set_shell_provider):
|
|
175
|
+
set_shell_provider(UsersShellAccess(self.auth_service))
|
|
176
|
+
|
|
177
|
+
async def startup(self) -> None:
|
|
178
|
+
"""Seed and start the background session sweep on the running loop."""
|
|
179
|
+
await self.ensure_seeded()
|
|
180
|
+
if self._cleanup_task is None or self._cleanup_task.done():
|
|
181
|
+
self._cleanup_task = asyncio.get_running_loop().create_task(self._cleanup_loop())
|
|
182
|
+
|
|
183
|
+
async def shutdown(self) -> None:
|
|
184
|
+
"""Stop the background session sweep."""
|
|
185
|
+
task, self._cleanup_task = self._cleanup_task, None
|
|
186
|
+
if task is None:
|
|
187
|
+
return
|
|
188
|
+
task.cancel()
|
|
189
|
+
try:
|
|
190
|
+
await task
|
|
191
|
+
except (asyncio.CancelledError, RuntimeError):
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
def dispose(self) -> None:
|
|
195
|
+
"""Synchronous best-effort stop (prefer :meth:`shutdown`)."""
|
|
196
|
+
task, self._cleanup_task = self._cleanup_task, None
|
|
197
|
+
if task is not None:
|
|
198
|
+
try:
|
|
199
|
+
task.cancel()
|
|
200
|
+
except RuntimeError:
|
|
201
|
+
pass # loop already closed
|
|
202
|
+
|
|
203
|
+
async def _cleanup_loop(self) -> None:
|
|
204
|
+
interval = max(self.cleanup_interval_ms, 1) / 1000
|
|
205
|
+
while True:
|
|
206
|
+
await asyncio.sleep(interval)
|
|
207
|
+
try:
|
|
208
|
+
await self.auth_service.cleanup_sessions()
|
|
209
|
+
except Exception:
|
|
210
|
+
logger.exception("Session cleanup error")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class CliUsersBuilder:
|
|
214
|
+
"""Fluent builder for the users plugin."""
|
|
215
|
+
|
|
216
|
+
def __init__(self) -> None:
|
|
217
|
+
self._user_store: IUserStore | None = None
|
|
218
|
+
self._session_store: ISessionStore | None = None
|
|
219
|
+
self._credential_store: ICredentialStore | None = None
|
|
220
|
+
self._audit_store: IAuditStore | None = None
|
|
221
|
+
self._group_store: IGroupStore | None = None
|
|
222
|
+
self._auth_adapter: ExternalAuthAdapter | None = None
|
|
223
|
+
self._default_admin_password: str | None = None
|
|
224
|
+
self._session_timeout_ms = DEFAULT_SESSION_TIMEOUT_MS
|
|
225
|
+
self._max_session_lifetime_ms = DEFAULT_MAX_SESSION_LIFETIME_MS
|
|
226
|
+
self._rate_limit_attempts = DEFAULT_RATE_LIMIT_ATTEMPTS
|
|
227
|
+
self._rate_limit_window_ms = DEFAULT_RATE_LIMIT_WINDOW_MS
|
|
228
|
+
self._trust_proxy = False
|
|
229
|
+
self._allowed_origins: list[str] | None = None
|
|
230
|
+
self._protect_server = True
|
|
231
|
+
self._min_password_length = DEFAULT_MIN_PASSWORD_LENGTH
|
|
232
|
+
self._max_login_attempts = DEFAULT_MAX_LOGIN_ATTEMPTS
|
|
233
|
+
self._lockout_ms = DEFAULT_LOCKOUT_MS
|
|
234
|
+
self._cleanup_interval_ms = DEFAULT_CLEANUP_INTERVAL_MS
|
|
235
|
+
|
|
236
|
+
def set_user_store(self, store: IUserStore) -> CliUsersBuilder:
|
|
237
|
+
self._user_store = store
|
|
238
|
+
return self
|
|
239
|
+
|
|
240
|
+
def set_session_store(self, store: ISessionStore) -> CliUsersBuilder:
|
|
241
|
+
self._session_store = store
|
|
242
|
+
return self
|
|
243
|
+
|
|
244
|
+
def set_credential_store(self, store: ICredentialStore) -> CliUsersBuilder:
|
|
245
|
+
self._credential_store = store
|
|
246
|
+
return self
|
|
247
|
+
|
|
248
|
+
def set_audit_store(self, store: IAuditStore) -> CliUsersBuilder:
|
|
249
|
+
self._audit_store = store
|
|
250
|
+
return self
|
|
251
|
+
|
|
252
|
+
def set_group_store(self, store: IGroupStore) -> CliUsersBuilder:
|
|
253
|
+
self._group_store = store
|
|
254
|
+
return self
|
|
255
|
+
|
|
256
|
+
def set_auth_adapter(self, adapter: ExternalAuthAdapter | None) -> CliUsersBuilder:
|
|
257
|
+
"""Delegate authentication to the host app (see README)."""
|
|
258
|
+
self._auth_adapter = adapter
|
|
259
|
+
return self
|
|
260
|
+
|
|
261
|
+
def set_default_admin_password(self, password: str | None) -> CliUsersBuilder:
|
|
262
|
+
"""Password of the seeded ``root`` user. When not set, a random
|
|
263
|
+
password is generated, logged once, and must be changed at first
|
|
264
|
+
login."""
|
|
265
|
+
self._default_admin_password = password
|
|
266
|
+
return self
|
|
267
|
+
|
|
268
|
+
def set_max_session_lifetime(self, lifetime_ms: int) -> CliUsersBuilder:
|
|
269
|
+
"""Absolute session lifetime (default 12 hours)."""
|
|
270
|
+
self._max_session_lifetime_ms = lifetime_ms
|
|
271
|
+
return self
|
|
272
|
+
|
|
273
|
+
def set_rate_limit(self, attempts: int, window_ms: int = DEFAULT_RATE_LIMIT_WINDOW_MS) -> CliUsersBuilder:
|
|
274
|
+
"""Per-IP limit for login / verify-password / self password change
|
|
275
|
+
(default 20 per minute)."""
|
|
276
|
+
self._rate_limit_attempts = attempts
|
|
277
|
+
self._rate_limit_window_ms = window_ms
|
|
278
|
+
return self
|
|
279
|
+
|
|
280
|
+
def set_trust_proxy(self, trust: bool | int = True) -> CliUsersBuilder:
|
|
281
|
+
"""Take the client IP from ``X-Forwarded-For`` behind trusted proxies.
|
|
282
|
+
|
|
283
|
+
``False``/``0`` uses the socket peer (header ignored), ``True`` = 1
|
|
284
|
+
trusted hop, an int ``n`` = ``n`` hops: the client is the entry at
|
|
285
|
+
``len - n`` (the rightmost for one proxy), the leftmost if there are
|
|
286
|
+
fewer than ``n`` entries. Only enable behind a reverse proxy.
|
|
287
|
+
"""
|
|
288
|
+
self._trust_proxy = trust
|
|
289
|
+
return self
|
|
290
|
+
|
|
291
|
+
def set_allowed_origins(self, origins: list[str] | None) -> CliUsersBuilder:
|
|
292
|
+
"""Origins accepted for bearer-less state-changing requests (CSRF,
|
|
293
|
+
adapter mode). Defaults to the server's ``allowed_origins``."""
|
|
294
|
+
self._allowed_origins = list(origins) if origins is not None else None
|
|
295
|
+
return self
|
|
296
|
+
|
|
297
|
+
def set_protect_server(self, protect: bool) -> CliUsersBuilder:
|
|
298
|
+
"""Protect every CLI server route and socket (default ``True``)."""
|
|
299
|
+
self._protect_server = protect
|
|
300
|
+
return self
|
|
301
|
+
|
|
302
|
+
def set_session_timeout(self, timeout_ms: int) -> CliUsersBuilder:
|
|
303
|
+
"""Idle session timeout in milliseconds (default 30 minutes)."""
|
|
304
|
+
self._session_timeout_ms = timeout_ms
|
|
305
|
+
return self
|
|
306
|
+
|
|
307
|
+
def set_min_password_length(self, length: int) -> CliUsersBuilder:
|
|
308
|
+
self._min_password_length = length
|
|
309
|
+
return self
|
|
310
|
+
|
|
311
|
+
def set_max_login_attempts(self, attempts: int) -> CliUsersBuilder:
|
|
312
|
+
self._max_login_attempts = attempts
|
|
313
|
+
return self
|
|
314
|
+
|
|
315
|
+
def set_lockout(self, lockout_ms: int) -> CliUsersBuilder:
|
|
316
|
+
"""Lockout duration after too many failed logins (default 5 minutes)."""
|
|
317
|
+
self._lockout_ms = lockout_ms
|
|
318
|
+
return self
|
|
319
|
+
|
|
320
|
+
def set_cleanup_interval(self, interval_ms: int) -> CliUsersBuilder:
|
|
321
|
+
"""Interval of the background expired-session sweep (default 5 min)."""
|
|
322
|
+
self._cleanup_interval_ms = interval_ms
|
|
323
|
+
return self
|
|
324
|
+
|
|
325
|
+
def build(self, broadcast_fn: BroadcastFn | None = None) -> CliUsersPlugin:
|
|
326
|
+
"""Build the plugin. ``broadcast_fn`` receives JSON-encoded events
|
|
327
|
+
(e.g. ``session-kicked``); it may be sync or async."""
|
|
328
|
+
auth_service = AuthService(
|
|
329
|
+
self._user_store or InMemoryUserStore(),
|
|
330
|
+
self._session_store or InMemorySessionStore(),
|
|
331
|
+
self._credential_store or InMemoryCredentialStore(),
|
|
332
|
+
self._audit_store or InMemoryAuditStore(),
|
|
333
|
+
self._group_store or InMemoryGroupStore(),
|
|
334
|
+
options=AuthServiceOptions(
|
|
335
|
+
session_timeout_ms=self._session_timeout_ms,
|
|
336
|
+
max_session_lifetime_ms=self._max_session_lifetime_ms,
|
|
337
|
+
min_password_length=self._min_password_length,
|
|
338
|
+
max_login_attempts=self._max_login_attempts,
|
|
339
|
+
lockout_ms=self._lockout_ms,
|
|
340
|
+
rate_limit_attempts=self._rate_limit_attempts,
|
|
341
|
+
rate_limit_window_ms=self._rate_limit_window_ms,
|
|
342
|
+
trust_proxy=self._trust_proxy,
|
|
343
|
+
allowed_origins=self._allowed_origins,
|
|
344
|
+
),
|
|
345
|
+
broadcast_fn=broadcast_fn,
|
|
346
|
+
auth_adapter=self._auth_adapter,
|
|
347
|
+
)
|
|
348
|
+
users_service = UsersService(auth_service)
|
|
349
|
+
|
|
350
|
+
router = APIRouter()
|
|
351
|
+
plugin = CliUsersPlugin(
|
|
352
|
+
prefix=PREFIX,
|
|
353
|
+
router=router,
|
|
354
|
+
auth_service=auth_service,
|
|
355
|
+
users_service=users_service,
|
|
356
|
+
user_store=auth_service.user_store,
|
|
357
|
+
session_store=auth_service.session_store,
|
|
358
|
+
credential_store=auth_service.credential_store,
|
|
359
|
+
group_store=auth_service.group_store,
|
|
360
|
+
audit_store=auth_service.audit_store,
|
|
361
|
+
default_admin_password=self._default_admin_password,
|
|
362
|
+
cleanup_interval_ms=self._cleanup_interval_ms,
|
|
363
|
+
protect_server=self._protect_server,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Requests made before startup() (e.g. apps without a lifespan) still
|
|
367
|
+
# see the seeded built-ins.
|
|
368
|
+
get_auth, require_permission, get_auth_pending = create_auth_dependencies(
|
|
369
|
+
auth_service, ensure_ready=plugin.ensure_seeded,
|
|
370
|
+
)
|
|
371
|
+
plugin.require_auth = get_auth
|
|
372
|
+
plugin.require_permission = require_permission
|
|
373
|
+
router.include_router(create_sessions_router(
|
|
374
|
+
auth_service, get_auth, require_permission,
|
|
375
|
+
ensure_ready=plugin.ensure_seeded, get_auth_pending=get_auth_pending,
|
|
376
|
+
))
|
|
377
|
+
router.include_router(create_users_router(
|
|
378
|
+
auth_service, users_service, get_auth, require_permission,
|
|
379
|
+
get_auth_pending=get_auth_pending,
|
|
380
|
+
))
|
|
381
|
+
router.include_router(create_groups_router(
|
|
382
|
+
auth_service, users_service, get_auth, require_permission,
|
|
383
|
+
))
|
|
384
|
+
router.include_router(create_audit_router(auth_service, require_permission))
|
|
385
|
+
return plugin
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Users plugin controllers."""
|
|
2
|
+
|
|
3
|
+
from .audit_controller import create_audit_router
|
|
4
|
+
from .groups_controller import create_groups_router
|
|
5
|
+
from .sessions_controller import create_sessions_router
|
|
6
|
+
from .users_controller import create_users_router
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"create_audit_router",
|
|
10
|
+
"create_groups_router",
|
|
11
|
+
"create_sessions_router",
|
|
12
|
+
"create_users_router",
|
|
13
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Routing helpers shared by the users controllers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter
|
|
8
|
+
|
|
9
|
+
from ..errors import UsersApiRoute
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def new_router(prefix: str, tag: str) -> APIRouter:
|
|
13
|
+
return APIRouter(prefix=prefix, tags=[tag], route_class=UsersApiRoute)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def collection(router: APIRouter, method: str, **kwargs: Any) -> Callable[[Callable], Callable]:
|
|
17
|
+
"""Register a collection endpoint at both ``<prefix>`` and ``<prefix>/``
|
|
18
|
+
so neither form redirects."""
|
|
19
|
+
|
|
20
|
+
def decorator(func: Callable) -> Callable:
|
|
21
|
+
router.add_api_route("", func, methods=[method], **kwargs)
|
|
22
|
+
router.add_api_route("/", func, methods=[method], include_in_schema=False, **kwargs)
|
|
23
|
+
return func
|
|
24
|
+
|
|
25
|
+
return decorator
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Audit controller — ``/audit``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, Query, Response
|
|
8
|
+
|
|
9
|
+
from ..models import AuditQueryOptions
|
|
10
|
+
from ..permissions import AUDIT_CLEAR, AUDIT_READ
|
|
11
|
+
from ..services.auth_service import AuthContext, AuthService
|
|
12
|
+
from ._routing import collection, new_router
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_audit_router(
|
|
16
|
+
auth_service: AuthService,
|
|
17
|
+
require_permission: Callable[[str], Callable],
|
|
18
|
+
) -> APIRouter:
|
|
19
|
+
router = new_router("/audit", "audit")
|
|
20
|
+
audit_store = auth_service.audit_store
|
|
21
|
+
|
|
22
|
+
@collection(router, "GET")
|
|
23
|
+
async def list_entries(
|
|
24
|
+
event: str | None = None,
|
|
25
|
+
username: str | None = None,
|
|
26
|
+
limit: int | None = Query(default=None, ge=0),
|
|
27
|
+
ctx: AuthContext = Depends(require_permission(AUDIT_READ)),
|
|
28
|
+
):
|
|
29
|
+
entries = await audit_store.get_entries(
|
|
30
|
+
AuditQueryOptions(event=event or None, username=username or None, limit=limit or None),
|
|
31
|
+
)
|
|
32
|
+
return [e.to_dict() for e in entries]
|
|
33
|
+
|
|
34
|
+
@collection(router, "DELETE", status_code=204)
|
|
35
|
+
async def clear_entries(ctx: AuthContext = Depends(require_permission(AUDIT_CLEAR))):
|
|
36
|
+
await audit_store.clear()
|
|
37
|
+
await auth_service.audit("audit_cleared", ctx.user.name, actor=ctx.user.name)
|
|
38
|
+
return Response(status_code=204)
|
|
39
|
+
|
|
40
|
+
return router
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Groups controller — ``/groups`` (CRUD, permissions, membership)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, Response
|
|
8
|
+
|
|
9
|
+
from ..errors import bad_request, forbidden, not_found
|
|
10
|
+
from ..models import CreateGroupRequest, Group, UpdateGroupRequest
|
|
11
|
+
from ..permissions import GROUPS_CREATE, GROUPS_DELETE, GROUPS_READ, GROUPS_UPDATE
|
|
12
|
+
from ..services.auth_service import AuthContext, AuthService
|
|
13
|
+
from ..services.users_service import UsersService
|
|
14
|
+
from ._routing import collection, new_router
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_groups_router(
|
|
18
|
+
auth_service: AuthService,
|
|
19
|
+
users_service: UsersService,
|
|
20
|
+
get_auth: Callable,
|
|
21
|
+
require_permission: Callable[[str], Callable],
|
|
22
|
+
) -> APIRouter:
|
|
23
|
+
router = new_router("/groups", "groups")
|
|
24
|
+
group_store = auth_service.group_store
|
|
25
|
+
user_store = auth_service.user_store
|
|
26
|
+
|
|
27
|
+
async def load_group(name: str) -> Group:
|
|
28
|
+
group = await group_store.get_group(name)
|
|
29
|
+
if not group:
|
|
30
|
+
raise not_found(f"group not found: {name}")
|
|
31
|
+
return group
|
|
32
|
+
|
|
33
|
+
async def load_user(id_or_name: str):
|
|
34
|
+
user = await user_store.get_user(id_or_name)
|
|
35
|
+
if not user:
|
|
36
|
+
raise not_found(f"user not found: {id_or_name}")
|
|
37
|
+
return user
|
|
38
|
+
|
|
39
|
+
@collection(router, "GET")
|
|
40
|
+
async def list_groups(ctx: AuthContext = Depends(get_auth)):
|
|
41
|
+
return [g.to_dict() for g in await group_store.get_groups()]
|
|
42
|
+
|
|
43
|
+
@collection(router, "POST", status_code=201)
|
|
44
|
+
async def create_group(
|
|
45
|
+
body: CreateGroupRequest,
|
|
46
|
+
ctx: AuthContext = Depends(require_permission(GROUPS_CREATE)),
|
|
47
|
+
):
|
|
48
|
+
group = await users_service.create_group(
|
|
49
|
+
ctx, name=body.name, description=body.description, permissions=body.permissions,
|
|
50
|
+
)
|
|
51
|
+
return group.to_dict()
|
|
52
|
+
|
|
53
|
+
@router.get("/{name}")
|
|
54
|
+
async def get_group(name: str, ctx: AuthContext = Depends(get_auth)):
|
|
55
|
+
return (await load_group(name)).to_dict()
|
|
56
|
+
|
|
57
|
+
@router.get("/{name}/members")
|
|
58
|
+
async def list_members(name: str, ctx: AuthContext = Depends(get_auth)):
|
|
59
|
+
group = await load_group(name)
|
|
60
|
+
if not ctx.can(GROUPS_READ) and not auth_service.in_group(ctx.user, group.name):
|
|
61
|
+
raise forbidden(f"missing permission: {GROUPS_READ}")
|
|
62
|
+
return [u.to_dict() for u in await users_service.group_members(group)]
|
|
63
|
+
|
|
64
|
+
@router.put("/{name}")
|
|
65
|
+
async def update_group(
|
|
66
|
+
name: str,
|
|
67
|
+
body: UpdateGroupRequest,
|
|
68
|
+
ctx: AuthContext = Depends(require_permission(GROUPS_UPDATE)),
|
|
69
|
+
):
|
|
70
|
+
group = await load_group(name)
|
|
71
|
+
updated = await users_service.update_group(
|
|
72
|
+
ctx, group, description=body.description, permissions=body.permissions,
|
|
73
|
+
)
|
|
74
|
+
return updated.to_dict()
|
|
75
|
+
|
|
76
|
+
@router.delete("/{name}", status_code=204)
|
|
77
|
+
async def delete_group(
|
|
78
|
+
name: str,
|
|
79
|
+
ctx: AuthContext = Depends(require_permission(GROUPS_DELETE)),
|
|
80
|
+
):
|
|
81
|
+
await users_service.delete_group(ctx, await load_group(name))
|
|
82
|
+
return Response(status_code=204)
|
|
83
|
+
|
|
84
|
+
@router.post("/{name}/members/{id_or_name}", status_code=204)
|
|
85
|
+
async def add_member(
|
|
86
|
+
name: str,
|
|
87
|
+
id_or_name: str,
|
|
88
|
+
ctx: AuthContext = Depends(require_permission(GROUPS_UPDATE)),
|
|
89
|
+
):
|
|
90
|
+
group = await group_store.get_group(name)
|
|
91
|
+
if not group:
|
|
92
|
+
# Contract: referenced groups must exist when adding a member → 400.
|
|
93
|
+
raise bad_request(f"unknown group: {name}")
|
|
94
|
+
user = await load_user(id_or_name)
|
|
95
|
+
await users_service.add_member(ctx, group, user)
|
|
96
|
+
return Response(status_code=204)
|
|
97
|
+
|
|
98
|
+
@router.delete("/{name}/members/{id_or_name}", status_code=204)
|
|
99
|
+
async def remove_member(
|
|
100
|
+
name: str,
|
|
101
|
+
id_or_name: str,
|
|
102
|
+
ctx: AuthContext = Depends(require_permission(GROUPS_UPDATE)),
|
|
103
|
+
):
|
|
104
|
+
group = await load_group(name)
|
|
105
|
+
user = await load_user(id_or_name)
|
|
106
|
+
await users_service.remove_member(ctx, group, user)
|
|
107
|
+
return Response(status_code=204)
|
|
108
|
+
|
|
109
|
+
return router
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Sessions controller — ``/sessions``.
|
|
2
|
+
|
|
3
|
+
- ``POST /sessions`` login (public)
|
|
4
|
+
- ``GET /sessions/current`` current session (authenticated)
|
|
5
|
+
- ``DELETE /sessions/current`` logout (authenticated)
|
|
6
|
+
- ``GET /sessions`` list sessions (``sessions:read``)
|
|
7
|
+
- ``DELETE /sessions/{id}`` revoke (``sessions:revoke`` or own session)
|
|
8
|
+
- ``POST /sessions/ws-ticket`` single-use WebSocket ticket (authenticated)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Callable
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, Depends, Request, Response
|
|
16
|
+
|
|
17
|
+
from ..errors import error_response, forbidden, not_found
|
|
18
|
+
from ..models import LoginRequest, SessionMetadata
|
|
19
|
+
from ..permissions import SESSIONS_READ, SESSIONS_REVOKE
|
|
20
|
+
from ..services.auth_service import AuthContext, AuthService
|
|
21
|
+
from ._routing import collection, new_router
|
|
22
|
+
|
|
23
|
+
MANAGED_BY_HOST = "Authentication is managed by the host application"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_sessions_router(
|
|
27
|
+
auth_service: AuthService,
|
|
28
|
+
get_auth: Callable,
|
|
29
|
+
require_permission: Callable[[str], Callable],
|
|
30
|
+
ensure_ready: Callable | None = None,
|
|
31
|
+
get_auth_pending: Callable | None = None,
|
|
32
|
+
) -> APIRouter:
|
|
33
|
+
get_auth_pending = get_auth_pending or get_auth
|
|
34
|
+
router = new_router("/sessions", "sessions")
|
|
35
|
+
external = auth_service.external_auth
|
|
36
|
+
|
|
37
|
+
if external:
|
|
38
|
+
@collection(router, "POST")
|
|
39
|
+
async def login_external():
|
|
40
|
+
return error_response(501, MANAGED_BY_HOST)
|
|
41
|
+
|
|
42
|
+
# Declared before DELETE /{session_id} so "current" is not taken as an id.
|
|
43
|
+
@router.delete("/current")
|
|
44
|
+
async def logout_external():
|
|
45
|
+
return error_response(501, MANAGED_BY_HOST)
|
|
46
|
+
else:
|
|
47
|
+
@collection(router, "POST", status_code=201)
|
|
48
|
+
async def login(body: LoginRequest, request: Request):
|
|
49
|
+
if ensure_ready is not None:
|
|
50
|
+
await ensure_ready()
|
|
51
|
+
auth_service.check_rate_limit(request)
|
|
52
|
+
metadata = SessionMetadata(
|
|
53
|
+
remote_address=auth_service.client_ip(request),
|
|
54
|
+
user_agent=request.headers.get("user-agent"),
|
|
55
|
+
)
|
|
56
|
+
result = await auth_service.login(body.username, body.password, metadata)
|
|
57
|
+
return {
|
|
58
|
+
"token": result.token,
|
|
59
|
+
"session": auth_service.session_payload(
|
|
60
|
+
result.session, result.user, result.permissions,
|
|
61
|
+
),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@router.delete("/current", status_code=204)
|
|
65
|
+
async def logout(ctx: AuthContext = Depends(get_auth_pending)):
|
|
66
|
+
if ctx.token:
|
|
67
|
+
await auth_service.logout(ctx.token)
|
|
68
|
+
return Response(status_code=204)
|
|
69
|
+
|
|
70
|
+
@router.post("/ws-ticket", status_code=201)
|
|
71
|
+
async def ws_ticket(ctx: AuthContext = Depends(get_auth)):
|
|
72
|
+
ticket, expires_in = auth_service.issue_ticket(ctx)
|
|
73
|
+
return {"ticket": ticket, "expiresIn": expires_in}
|
|
74
|
+
|
|
75
|
+
@router.get("/current")
|
|
76
|
+
async def current_session(ctx: AuthContext = Depends(get_auth_pending)):
|
|
77
|
+
assert ctx.session is not None
|
|
78
|
+
return auth_service.session_payload(ctx.session, ctx.user, ctx.permissions)
|
|
79
|
+
|
|
80
|
+
@collection(router, "GET")
|
|
81
|
+
async def list_sessions(ctx: AuthContext = Depends(require_permission(SESSIONS_READ))):
|
|
82
|
+
result = []
|
|
83
|
+
for session in await auth_service.session_store.get_active_sessions():
|
|
84
|
+
if auth_service.is_expired(session):
|
|
85
|
+
await auth_service.session_store.delete_session_by_id(session.id)
|
|
86
|
+
continue
|
|
87
|
+
user = await auth_service.user_store.get_user(session.user_id)
|
|
88
|
+
result.append(session.to_listing_dict(
|
|
89
|
+
auth_service.session_timeout_ms,
|
|
90
|
+
auth_service.max_session_lifetime_ms,
|
|
91
|
+
user_name=user.name if user else None,
|
|
92
|
+
current=ctx.session is not None and session.id == ctx.session.id,
|
|
93
|
+
))
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
@router.delete("/{session_id}", status_code=204)
|
|
97
|
+
async def revoke_session(session_id: str, ctx: AuthContext = Depends(get_auth)):
|
|
98
|
+
target = await auth_service.session_store.get_session_by_id(session_id)
|
|
99
|
+
if not target:
|
|
100
|
+
raise not_found(f"session not found: {session_id}")
|
|
101
|
+
if target.user_id != ctx.user.id and not ctx.can(SESSIONS_REVOKE):
|
|
102
|
+
raise forbidden(f"missing permission: {SESSIONS_REVOKE}")
|
|
103
|
+
user = await auth_service.user_store.get_user(target.user_id)
|
|
104
|
+
if user is not None:
|
|
105
|
+
await auth_service.ensure_covers_user(ctx, user)
|
|
106
|
+
await auth_service.revoke_session(
|
|
107
|
+
target, "revoked",
|
|
108
|
+
actor=ctx.user.name,
|
|
109
|
+
actor_id=ctx.user.id,
|
|
110
|
+
username=user.name if user else None,
|
|
111
|
+
)
|
|
112
|
+
return Response(status_code=204)
|
|
113
|
+
|
|
114
|
+
return router
|