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,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,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]]
|