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,183 @@
1
+ """Users controller — ``/users`` (CRUD, permissions, password)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable
6
+
7
+ from fastapi import APIRouter, Depends, Query, Request, Response
8
+
9
+ from ..errors import bad_request, error_response, forbidden, not_found
10
+ from ..models import (
11
+ CreateUserRequest,
12
+ PasswordChangeRequest,
13
+ UpdateUserRequest,
14
+ User,
15
+ VerifyPasswordRequest,
16
+ )
17
+ from ..permissions import USERS_CREATE, USERS_DELETE, USERS_READ, USERS_UPDATE
18
+ from ..services.auth_service import AuthContext, AuthService
19
+ from ..services.users_service import UsersService
20
+ from ._routing import collection, new_router
21
+
22
+ MANAGED_BY_HOST = "Authentication is managed by the host application"
23
+
24
+
25
+ def create_users_router(
26
+ auth_service: AuthService,
27
+ users_service: UsersService,
28
+ get_auth: Callable,
29
+ require_permission: Callable[[str], Callable],
30
+ get_auth_pending: Callable | None = None,
31
+ ) -> APIRouter:
32
+ get_auth_pending = get_auth_pending or get_auth
33
+ router = new_router("/users", "users")
34
+ user_store = auth_service.user_store
35
+ external = auth_service.external_auth
36
+
37
+ async def load_target(id_or_name: str, ctx: AuthContext, permission: str, *, allow_self: bool) -> User:
38
+ """Resolve the target user, enforcing ``permission`` (or self).
39
+
40
+ Callers lacking the permission get 403 rather than 404 so user
41
+ existence is not leaked to them.
42
+ """
43
+ target = await user_store.get_user(id_or_name)
44
+ allowed = ctx.can(permission) or (allow_self and ctx.is_self(target))
45
+ if not allowed:
46
+ raise forbidden(f"missing permission: {permission}")
47
+ if target is None:
48
+ raise not_found(f"user not found: {id_or_name}")
49
+ return target
50
+
51
+ @collection(router, "GET")
52
+ async def list_users(
53
+ query: str | None = None,
54
+ skip: int = Query(default=0, ge=0),
55
+ take: int | None = Query(default=None, ge=0),
56
+ ctx: AuthContext = Depends(require_permission(USERS_READ)),
57
+ ):
58
+ users = await users_service.list_users(query, skip, take)
59
+ return [u.to_dict() for u in users]
60
+
61
+ @collection(router, "POST", status_code=201)
62
+ async def create_user(
63
+ body: CreateUserRequest,
64
+ ctx: AuthContext = Depends(require_permission(USERS_CREATE)),
65
+ ):
66
+ user = await users_service.create_user(
67
+ ctx,
68
+ name=body.name,
69
+ email=body.email,
70
+ groups=body.groups,
71
+ password=body.password,
72
+ disabled=body.disabled,
73
+ home_dir=body.homeDir,
74
+ data=body.data,
75
+ )
76
+ return user.to_dict()
77
+
78
+ @router.get("/{id_or_name}")
79
+ async def get_user(id_or_name: str, ctx: AuthContext = Depends(get_auth)):
80
+ target = await load_target(id_or_name, ctx, USERS_READ, allow_self=True)
81
+ return target.to_dict()
82
+
83
+ @router.get("/{id_or_name}/permissions")
84
+ async def get_user_permissions(id_or_name: str, ctx: AuthContext = Depends(get_auth)):
85
+ target = await load_target(id_or_name, ctx, USERS_READ, allow_self=True)
86
+ groups = list(target.groups or [])
87
+ return {
88
+ "userId": target.id,
89
+ "groups": groups,
90
+ "permissions": await auth_service.effective_permissions(groups),
91
+ }
92
+
93
+ @router.put("/{id_or_name}")
94
+ async def update_user(
95
+ id_or_name: str,
96
+ body: UpdateUserRequest,
97
+ ctx: AuthContext = Depends(get_auth),
98
+ ):
99
+ target = await load_target(id_or_name, ctx, USERS_UPDATE, allow_self=True)
100
+ if not ctx.can(USERS_UPDATE):
101
+ # Self-service: only email and data may change.
102
+ restricted = [
103
+ f for f, v in (
104
+ ("name", body.name),
105
+ ("groups", body.groups),
106
+ ("disabled", body.disabled),
107
+ ("homeDir", body.homeDir),
108
+ ) if v is not None
109
+ ]
110
+ if restricted:
111
+ raise forbidden(f"missing permission: {USERS_UPDATE}")
112
+ user = await users_service.update_user(
113
+ ctx,
114
+ target,
115
+ name=body.name,
116
+ email=body.email,
117
+ groups=body.groups,
118
+ disabled=body.disabled,
119
+ home_dir=body.homeDir,
120
+ data=body.data,
121
+ )
122
+ return user.to_dict()
123
+
124
+ @router.delete("/{id_or_name}", status_code=204)
125
+ async def delete_user(
126
+ id_or_name: str,
127
+ ctx: AuthContext = Depends(require_permission(USERS_DELETE)),
128
+ ):
129
+ target = await load_target(id_or_name, ctx, USERS_DELETE, allow_self=False)
130
+ await users_service.delete_user(ctx, target)
131
+ return Response(status_code=204)
132
+
133
+ if external:
134
+ @router.put("/{id_or_name}/password")
135
+ async def change_password_external(id_or_name: str):
136
+ return error_response(501, MANAGED_BY_HOST)
137
+
138
+ @router.post("/{id_or_name}/verify-password")
139
+ async def verify_password_external(id_or_name: str):
140
+ return error_response(501, MANAGED_BY_HOST)
141
+
142
+ return router
143
+
144
+ @router.put("/{id_or_name}/password", status_code=204)
145
+ async def change_password(
146
+ id_or_name: str,
147
+ body: PasswordChangeRequest,
148
+ request: Request,
149
+ ctx: AuthContext = Depends(get_auth_pending),
150
+ ):
151
+ target = await user_store.get_user(id_or_name)
152
+ is_self = ctx.is_self(target)
153
+ # While a password change is pending only the own password may change.
154
+ if not is_self:
155
+ auth_service.ensure_password_changed(ctx)
156
+ target = await load_target(id_or_name, ctx, USERS_UPDATE, allow_self=True)
157
+ password = body.password if body.password is not None else body.newPassword
158
+ if password is None:
159
+ raise bad_request("password is required")
160
+ if is_self:
161
+ auth_service.check_rate_limit(request)
162
+ if is_self and (not ctx.can(USERS_UPDATE) or ctx.must_change_password):
163
+ if not body.currentPassword:
164
+ raise bad_request("currentPassword is required")
165
+ if not await auth_service.check_password_attempt(ctx, target, body.currentPassword):
166
+ raise forbidden("current password is incorrect")
167
+ await users_service.change_password(ctx, target, password, temporary=body.temporary)
168
+ return Response(status_code=204)
169
+
170
+ @router.post("/{id_or_name}/verify-password")
171
+ async def verify_password(
172
+ id_or_name: str,
173
+ body: VerifyPasswordRequest,
174
+ request: Request,
175
+ ctx: AuthContext = Depends(get_auth),
176
+ ):
177
+ target = await load_target(id_or_name, ctx, USERS_UPDATE, allow_self=True)
178
+ await auth_service.ensure_covers_user(ctx, target)
179
+ auth_service.check_rate_limit(request)
180
+ valid = await auth_service.check_password_attempt(ctx, target, body.password)
181
+ return {"valid": valid}
182
+
183
+ return router
@@ -0,0 +1,118 @@
1
+ """Error handling for the users routes.
2
+
3
+ Every error produced by the users routes — including FastAPI's request
4
+ validation errors (422 by default) and ``HTTPException``s raised by
5
+ dependencies — is returned as ``{"error": "<message>"}``; validation errors
6
+ become 400. This is scoped to the users routers via a custom route class, so
7
+ other routers of the host app keep FastAPI's default behaviour.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Callable, Coroutine
13
+
14
+ from fastapi import HTTPException, Request, Response
15
+ from fastapi.exceptions import RequestValidationError
16
+ from fastapi.responses import JSONResponse
17
+ from fastapi.routing import APIRoute
18
+
19
+ BODY_MAX_BYTES = 1024 * 1024
20
+
21
+
22
+ class UsersApiError(HTTPException):
23
+ """An error with an HTTP status, rendered as ``{"error": message}`` on the
24
+ users routes (and by the server-protection middleware). Being an
25
+ ``HTTPException`` it also works in host-app routes using the exported
26
+ guards."""
27
+
28
+ def __init__(self, status_code: int, message: str) -> None:
29
+ super().__init__(status_code=status_code, detail=message)
30
+ self.message = message
31
+
32
+
33
+ def bad_request(message: str) -> UsersApiError:
34
+ return UsersApiError(400, message)
35
+
36
+
37
+ def unauthorized(message: str = "unauthorized") -> UsersApiError:
38
+ return UsersApiError(401, message)
39
+
40
+
41
+ def forbidden(message: str = "forbidden") -> UsersApiError:
42
+ return UsersApiError(403, message)
43
+
44
+
45
+ def not_found(message: str) -> UsersApiError:
46
+ return UsersApiError(404, message)
47
+
48
+
49
+ def conflict(message: str) -> UsersApiError:
50
+ return UsersApiError(409, message)
51
+
52
+
53
+ def error_response(status_code: int, message: str) -> JSONResponse:
54
+ return JSONResponse(status_code=status_code, content={"error": message})
55
+
56
+
57
+ def _validation_message(exc: RequestValidationError) -> str:
58
+ errors = exc.errors()
59
+ if not errors:
60
+ return "invalid request"
61
+ first = errors[0]
62
+ loc = [str(p) for p in first.get("loc", ()) if p not in ("body", "query", "path")]
63
+ field = ".".join(loc)
64
+ err_type = first.get("type", "")
65
+ if err_type == "missing":
66
+ return f"{field} is required" if field else "request body is required"
67
+ if err_type in ("json_invalid", "model_attributes_type") or not field:
68
+ return "invalid request body"
69
+ return f"invalid {field}: {first.get('msg', 'invalid value')}"
70
+
71
+
72
+ async def _read_body_capped(request: Request) -> bool:
73
+ """Read the body, stopping as soon as it exceeds ``BODY_MAX_BYTES``
74
+ (chunked bodies have no Content-Length). ``False`` when too large;
75
+ otherwise the body is cached on the request for the handler."""
76
+ chunks: list[bytes] = []
77
+ size = 0
78
+ async for chunk in request.stream():
79
+ size += len(chunk)
80
+ if size > BODY_MAX_BYTES:
81
+ return False
82
+ chunks.append(chunk)
83
+ request._body = b"".join(chunks)
84
+ return True
85
+
86
+
87
+ class UsersApiRoute(APIRoute):
88
+ """Route class converting validation/HTTP errors to ``{error}`` bodies."""
89
+
90
+ def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
91
+ original = super().get_route_handler()
92
+
93
+ async def handler(request: Request) -> Response:
94
+ length = request.headers.get("content-length")
95
+ try:
96
+ too_large = length is not None and int(length) > BODY_MAX_BYTES
97
+ except ValueError:
98
+ too_large = True
99
+ if too_large or (
100
+ request.method in ("POST", "PUT", "PATCH")
101
+ and not await _read_body_capped(request)
102
+ ):
103
+ return error_response(400, "request body too large")
104
+ try:
105
+ return await original(request)
106
+ except UsersApiError as exc:
107
+ return error_response(exc.status_code, exc.message)
108
+ except RequestValidationError as exc:
109
+ return error_response(400, _validation_message(exc))
110
+ except HTTPException as exc:
111
+ detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
112
+ return JSONResponse(
113
+ status_code=exc.status_code,
114
+ content={"error": detail},
115
+ headers=getattr(exc, "headers", None),
116
+ )
117
+
118
+ return handler
@@ -0,0 +1,5 @@
1
+ """Users plugin middleware."""
2
+
3
+ from .auth_middleware import authenticate_request, create_auth_dependencies
4
+
5
+ __all__ = ["authenticate_request", "create_auth_dependencies"]
@@ -0,0 +1,77 @@
1
+ """FastAPI dependencies for authenticating users-plugin (and host) requests.
2
+
3
+ ``get_auth`` resolves the caller (bearer token, or the external auth adapter)
4
+ to an :class:`AuthContext` holding the **live** user and its effective
5
+ permissions, and enforces CSRF (B9) and the ``mustChangePassword`` gate (B4).
6
+ ``get_auth_pending`` is the same without the password-change gate (for the
7
+ few endpoints allowed while a password change is pending).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Awaitable, Callable
13
+
14
+ from fastapi import Depends, Request
15
+
16
+ from .._http import SAFE_METHODS, bearer_token, get_scope_auth, has_bearer, origin_allowed
17
+ from ..errors import forbidden, unauthorized
18
+ from ..services.auth_service import AuthContext, AuthService
19
+
20
+ GetAuth = Callable[[Request], Awaitable[AuthContext]]
21
+
22
+ ORIGIN_NOT_ALLOWED = "origin not allowed"
23
+
24
+
25
+ async def authenticate_request(auth_service: AuthService, request: Request) -> AuthContext:
26
+ """Authenticate an HTTP request (no password-change gate)."""
27
+ if auth_service.external_auth:
28
+ # Ambient (adapter/cookie) credentials: state-changing requests
29
+ # without a bearer token must come from an allowed Origin (CSRF);
30
+ # same-origin requests (e.g. the bundled dashboard) always pass.
31
+ if request.method.upper() not in SAFE_METHODS and not has_bearer(request.headers):
32
+ if not origin_allowed(
33
+ request.headers.get("origin"),
34
+ auth_service.options.allowed_origins,
35
+ request.scope,
36
+ auth_service.options.trust_proxy,
37
+ ):
38
+ raise forbidden(ORIGIN_NOT_ALLOWED)
39
+ return await auth_service.authenticate_external(request)
40
+ token = bearer_token(request.headers)
41
+ if not token:
42
+ raise unauthorized()
43
+ return await auth_service.authenticate_token(token)
44
+
45
+
46
+ def create_auth_dependencies(
47
+ auth_service: AuthService,
48
+ ensure_ready: Callable[[], Awaitable[None]] | None = None,
49
+ ) -> tuple[GetAuth, Callable[[str], Callable[..., Awaitable[AuthContext]]], GetAuth]:
50
+ """Return ``(get_auth, require_permission, get_auth_pending)``."""
51
+
52
+ async def get_auth_pending(request: Request) -> AuthContext:
53
+ if ensure_ready is not None:
54
+ await ensure_ready()
55
+ ctx = get_scope_auth(request.scope)
56
+ if isinstance(ctx, AuthContext):
57
+ return ctx
58
+ return await authenticate_request(auth_service, request)
59
+
60
+ async def get_auth(request: Request) -> AuthContext:
61
+ ctx = await get_auth_pending(request)
62
+ auth_service.ensure_password_changed(ctx)
63
+ return ctx
64
+
65
+ def require_permission(permission: str) -> Callable[..., Awaitable[AuthContext]]:
66
+ async def dependency(ctx: AuthContext = Depends(get_auth)) -> AuthContext:
67
+ if not ctx.can(permission):
68
+ raise forbidden(f"missing permission: {permission}")
69
+ return ctx
70
+
71
+ return dependency
72
+
73
+ return get_auth, require_permission, get_auth_pending
74
+
75
+
76
+ def bearer_token_from(request: Request) -> str | None: # backwards compat
77
+ return bearer_token(request.headers)
@@ -0,0 +1,282 @@
1
+ """Data models for the users plugin.
2
+
3
+ Internal records are dataclasses; their ``to_dict`` methods produce the
4
+ camelCase JSON shapes defined by the server contract shared with the node and
5
+ .NET servers. Request bodies are pydantic models.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ import uuid
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING, Any, Callable, Optional
14
+
15
+ if TYPE_CHECKING:
16
+ from starlette.requests import Request
17
+
18
+ from pydantic import BaseModel, ConfigDict
19
+
20
+
21
+ def now_ms() -> int:
22
+ """Current time in epoch milliseconds."""
23
+ return int(time.time() * 1000)
24
+
25
+
26
+ def _new_id() -> str:
27
+ return str(uuid.uuid4())
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Internal records
32
+ # ---------------------------------------------------------------------------
33
+
34
+ @dataclass
35
+ class User:
36
+ id: str = field(default_factory=_new_id)
37
+ name: str = ""
38
+ email: str = ""
39
+ groups: list[str] = field(default_factory=list)
40
+ disabled: bool = False
41
+ must_change_password: bool = False
42
+ home_dir: str | None = None
43
+ data: dict[str, Any] | None = None
44
+ last_login_at: int | None = None
45
+ created_at: int = field(default_factory=now_ms)
46
+ updated_at: int = field(default_factory=now_ms)
47
+
48
+ def to_dict(self) -> dict[str, Any]:
49
+ result: dict[str, Any] = {
50
+ "id": self.id,
51
+ "name": self.name,
52
+ "email": self.email,
53
+ "groups": list(self.groups),
54
+ "disabled": bool(self.disabled),
55
+ "mustChangePassword": bool(self.must_change_password),
56
+ "createdAt": self.created_at,
57
+ "updatedAt": self.updated_at,
58
+ }
59
+ if self.home_dir is not None:
60
+ result["homeDir"] = self.home_dir
61
+ if self.last_login_at is not None:
62
+ result["lastLoginAt"] = self.last_login_at
63
+ if self.data is not None:
64
+ result["data"] = self.data
65
+ return result
66
+
67
+
68
+ @dataclass
69
+ class Credentials:
70
+ user_id: str = ""
71
+ #: Encoded hash, ``pbkdf2$<iterations>$<saltB64>$<hashB64>``.
72
+ password_hash: str = ""
73
+ #: Salt (base64). Also embedded in ``password_hash``; kept for store
74
+ #: implementations that persist it separately.
75
+ salt: str = ""
76
+ last_changed: int = field(default_factory=now_ms)
77
+
78
+
79
+ @dataclass
80
+ class SessionMetadata:
81
+ remote_address: str | None = None
82
+ user_agent: str | None = None
83
+
84
+ def to_dict(self) -> dict[str, Any]:
85
+ result: dict[str, Any] = {}
86
+ if self.user_agent is not None:
87
+ result["userAgent"] = self.user_agent
88
+ if self.remote_address is not None:
89
+ result["remoteAddress"] = self.remote_address
90
+ return result
91
+
92
+
93
+ @dataclass
94
+ class Session:
95
+ """A stored login session.
96
+
97
+ References the user by id only; the live user record is resolved from the
98
+ user store on every request. ``user_name`` is a snapshot taken at login,
99
+ used only as a fallback when the user can no longer be resolved.
100
+ """
101
+
102
+ id: str = field(default_factory=_new_id)
103
+ #: SHA-256 (hex) of the bearer token — the raw token is never stored.
104
+ token_hash: str = ""
105
+ user_id: str = ""
106
+ user_name: str = ""
107
+ created_at: int = field(default_factory=now_ms)
108
+ last_activity: int = field(default_factory=now_ms)
109
+ metadata: SessionMetadata = field(default_factory=SessionMetadata)
110
+
111
+ @property
112
+ def login_time(self) -> int:
113
+ return self.created_at
114
+
115
+ def expires_at(self, timeout_ms: int, max_lifetime_ms: int | None = None) -> int:
116
+ """``min(lastActivity + idle timeout, createdAt + max lifetime)``."""
117
+ idle = self.last_activity + timeout_ms
118
+ if max_lifetime_ms is None:
119
+ return idle
120
+ return min(idle, self.created_at + max_lifetime_ms)
121
+
122
+ def to_listing_dict(
123
+ self,
124
+ timeout_ms: int,
125
+ max_lifetime_ms: int | None = None,
126
+ *,
127
+ user_name: str | None = None,
128
+ current: bool | None = None,
129
+ ) -> dict[str, Any]:
130
+ """Listing shape — never includes the token."""
131
+ result: dict[str, Any] = {
132
+ "id": self.id,
133
+ "userId": self.user_id,
134
+ "userName": user_name if user_name is not None else self.user_name,
135
+ "createdAt": self.created_at,
136
+ "lastActivity": self.last_activity,
137
+ "expiresAt": self.expires_at(timeout_ms, max_lifetime_ms),
138
+ "metadata": (self.metadata or SessionMetadata()).to_dict(),
139
+ }
140
+ if current is not None:
141
+ result["current"] = current
142
+ return result
143
+
144
+
145
+ @dataclass
146
+ class AuditEntry:
147
+ id: str = field(default_factory=_new_id)
148
+ timestamp: int = field(default_factory=now_ms)
149
+ event: str = ""
150
+ username: str = ""
151
+ details: str | None = None
152
+ actor: str | None = None
153
+ metadata: dict[str, Any] | None = None
154
+ user_id: str | None = None
155
+ actor_id: str | None = None
156
+
157
+ def to_dict(self) -> dict[str, Any]:
158
+ result: dict[str, Any] = {
159
+ "id": self.id,
160
+ "timestamp": self.timestamp,
161
+ "event": self.event,
162
+ "username": self.username,
163
+ }
164
+ if self.user_id is not None:
165
+ result["userId"] = self.user_id
166
+ if self.actor_id is not None:
167
+ result["actorId"] = self.actor_id
168
+ if self.details is not None:
169
+ result["details"] = self.details
170
+ if self.actor is not None:
171
+ result["actor"] = self.actor
172
+ if self.metadata is not None:
173
+ result["metadata"] = self.metadata
174
+ return result
175
+
176
+
177
+ @dataclass
178
+ class AuditQueryOptions:
179
+ limit: int | None = None
180
+ event: str | None = None
181
+ username: str | None = None
182
+
183
+
184
+ @dataclass
185
+ class Group:
186
+ id: str = field(default_factory=_new_id)
187
+ name: str = ""
188
+ description: str | None = None
189
+ permissions: list[str] = field(default_factory=list)
190
+ built_in: bool = False
191
+ created_at: int = field(default_factory=now_ms)
192
+ updated_at: int = field(default_factory=now_ms)
193
+
194
+ def to_dict(self) -> dict[str, Any]:
195
+ result: dict[str, Any] = {
196
+ "id": self.id,
197
+ "name": self.name,
198
+ "permissions": list(self.permissions),
199
+ "builtIn": bool(self.built_in),
200
+ "createdAt": self.created_at,
201
+ "updatedAt": self.updated_at,
202
+ }
203
+ if self.description is not None:
204
+ result["description"] = self.description
205
+ return result
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Request bodies (camelCase field names, as sent by the client)
210
+ # ---------------------------------------------------------------------------
211
+
212
+ class _Body(BaseModel):
213
+ model_config = ConfigDict(extra="ignore")
214
+
215
+
216
+ class LoginRequest(_Body):
217
+ username: str
218
+ password: str
219
+
220
+
221
+ class CreateUserRequest(_Body):
222
+ name: str
223
+ email: str
224
+ groups: list[str] | None = None
225
+ password: str | None = None
226
+ disabled: bool | None = None
227
+ homeDir: str | None = None # noqa: N815
228
+ data: dict[str, Any] | None = None
229
+
230
+
231
+ class UpdateUserRequest(_Body):
232
+ name: str | None = None
233
+ email: str | None = None
234
+ groups: list[str] | None = None
235
+ disabled: bool | None = None
236
+ homeDir: str | None = None # noqa: N815
237
+ data: dict[str, Any] | None = None
238
+
239
+
240
+ class PasswordChangeRequest(_Body):
241
+ password: str | None = None
242
+ currentPassword: str | None = None # noqa: N815
243
+ #: Admin reset only: ``False`` keeps the new password permanent;
244
+ #: otherwise the target must change it at next login.
245
+ temporary: bool | None = None
246
+ #: Deprecated alias of ``password`` (pre-contract clients).
247
+ newPassword: str | None = None # noqa: N815
248
+
249
+
250
+ class VerifyPasswordRequest(_Body):
251
+ password: str
252
+
253
+
254
+ class CreateGroupRequest(_Body):
255
+ name: str
256
+ description: str | None = None
257
+ permissions: list[str] | None = None
258
+
259
+
260
+ class UpdateGroupRequest(_Body):
261
+ description: str | None = None
262
+ permissions: list[str] | None = None
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # External auth adapter
267
+ # ---------------------------------------------------------------------------
268
+
269
+ @dataclass
270
+ class ExternalAuthResult:
271
+ """Result returned by an external auth adapter.
272
+
273
+ ``is_admin`` adds the built-in ``admin`` group to the user's effective
274
+ groups (the adapter's ``user`` object is never mutated).
275
+ """
276
+
277
+ user: User
278
+ is_admin: bool = False
279
+
280
+
281
+ # (Request) -> ExternalAuthResult | None (may also be async)
282
+ ExternalAuthAdapter = Callable[["Request"], Optional[ExternalAuthResult]]