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.
Files changed (32) hide show
  1. qodalis_cli_server_plugin_users-1.0.0b6.dist-info/METADATA +21 -0
  2. qodalis_cli_server_plugin_users-1.0.0b6.dist-info/RECORD +32 -0
  3. qodalis_cli_server_plugin_users-1.0.0b6.dist-info/WHEEL +4 -0
  4. qodalis_cli_server_plugin_users-1.0.0b6.dist-info/licenses/LICENSE +21 -0
  5. qodalis_cli_users/__init__.py +134 -0
  6. qodalis_cli_users/_http.py +162 -0
  7. qodalis_cli_users/cli_users_builder.py +385 -0
  8. qodalis_cli_users/controllers/__init__.py +13 -0
  9. qodalis_cli_users/controllers/_routing.py +25 -0
  10. qodalis_cli_users/controllers/audit_controller.py +40 -0
  11. qodalis_cli_users/controllers/groups_controller.py +109 -0
  12. qodalis_cli_users/controllers/sessions_controller.py +114 -0
  13. qodalis_cli_users/controllers/users_controller.py +183 -0
  14. qodalis_cli_users/errors.py +118 -0
  15. qodalis_cli_users/middleware/__init__.py +5 -0
  16. qodalis_cli_users/middleware/auth_middleware.py +77 -0
  17. qodalis_cli_users/models.py +282 -0
  18. qodalis_cli_users/permissions.py +58 -0
  19. qodalis_cli_users/server_protection.py +209 -0
  20. qodalis_cli_users/services/__init__.py +6 -0
  21. qodalis_cli_users/services/auth_service.py +786 -0
  22. qodalis_cli_users/services/password_hasher.py +98 -0
  23. qodalis_cli_users/services/users_service.py +330 -0
  24. qodalis_cli_users/shell_access.py +125 -0
  25. qodalis_cli_users/stores/__init__.py +27 -0
  26. qodalis_cli_users/stores/in_memory_audit_store.py +50 -0
  27. qodalis_cli_users/stores/in_memory_credential_store.py +25 -0
  28. qodalis_cli_users/stores/in_memory_group_store.py +65 -0
  29. qodalis_cli_users/stores/in_memory_session_store.py +71 -0
  30. qodalis_cli_users/stores/in_memory_user_store.py +102 -0
  31. qodalis_cli_users/stores/interfaces.py +208 -0
  32. 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