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,98 @@
|
|
|
1
|
+
"""PBKDF2-HMAC-SHA256 password hashing.
|
|
2
|
+
|
|
3
|
+
Encoded format (shared by the node, python and .NET servers)::
|
|
4
|
+
|
|
5
|
+
pbkdf2$100000$<saltB64>$<hashB64>
|
|
6
|
+
|
|
7
|
+
16-byte random salt, 32-byte derived key, constant-time comparison.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import secrets
|
|
16
|
+
|
|
17
|
+
ITERATIONS = 100_000
|
|
18
|
+
SALT_BYTES = 16
|
|
19
|
+
KEY_BYTES = 32
|
|
20
|
+
PREFIX = "pbkdf2"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _derive(password: str, salt: bytes, iterations: int, key_len: int = KEY_BYTES) -> bytes:
|
|
24
|
+
# surrogatepass: a lone surrogate (never in a stored password) must fail
|
|
25
|
+
# verification normally instead of raising.
|
|
26
|
+
return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8", "surrogatepass"), salt, iterations, key_len)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def hash_password(password: str) -> tuple[str, str]:
|
|
30
|
+
"""Hash ``password``; returns ``(encoded_hash, salt_b64)``."""
|
|
31
|
+
salt = secrets.token_bytes(SALT_BYTES)
|
|
32
|
+
key = _derive(password, salt, ITERATIONS)
|
|
33
|
+
salt_b64 = base64.b64encode(salt).decode("ascii")
|
|
34
|
+
encoded = f"{PREFIX}${ITERATIONS}${salt_b64}${base64.b64encode(key).decode('ascii')}"
|
|
35
|
+
return encoded, salt_b64
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def verify_password(password: str, encoded: str, legacy_salt: str = "") -> bool:
|
|
39
|
+
"""Verify ``password`` against an encoded hash (constant-time).
|
|
40
|
+
|
|
41
|
+
Hashes written by earlier plugin versions (hex ``sha256(salt + password)``
|
|
42
|
+
with a separate salt) are still accepted so existing persisted stores keep
|
|
43
|
+
working; :func:`needs_rehash` reports them so callers can upgrade them.
|
|
44
|
+
"""
|
|
45
|
+
if not encoded:
|
|
46
|
+
return False
|
|
47
|
+
parts = encoded.split("$")
|
|
48
|
+
if len(parts) == 4 and parts[0] == PREFIX:
|
|
49
|
+
try:
|
|
50
|
+
iterations = int(parts[1])
|
|
51
|
+
salt = base64.b64decode(parts[2])
|
|
52
|
+
expected = base64.b64decode(parts[3])
|
|
53
|
+
except (ValueError, TypeError):
|
|
54
|
+
return False
|
|
55
|
+
if iterations <= 0 or not expected:
|
|
56
|
+
return False
|
|
57
|
+
actual = _derive(password, salt, iterations, len(expected))
|
|
58
|
+
return hmac.compare_digest(actual, expected)
|
|
59
|
+
# Legacy SHA-256 format
|
|
60
|
+
legacy = hashlib.sha256((legacy_salt + password).encode("utf-8", "surrogatepass")).hexdigest()
|
|
61
|
+
return hmac.compare_digest(legacy.encode("ascii"), encoded.encode("utf-8", "replace"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def needs_rehash(encoded: str) -> bool:
|
|
65
|
+
parts = encoded.split("$")
|
|
66
|
+
return not (len(parts) == 4 and parts[0] == PREFIX and parts[1] == str(ITERATIONS))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Async API — PBKDF2 runs off the event loop with a concurrency cap.
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
import asyncio # noqa: E402
|
|
74
|
+
from concurrent.futures import ThreadPoolExecutor # noqa: E402
|
|
75
|
+
|
|
76
|
+
#: At most this many PBKDF2 computations run at once.
|
|
77
|
+
MAX_CONCURRENT_HASHES = 4
|
|
78
|
+
_executor = ThreadPoolExecutor(max_workers=MAX_CONCURRENT_HASHES, thread_name_prefix="qcli-pbkdf2")
|
|
79
|
+
_dummy_hash: str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def hash_password_async(password: str) -> tuple[str, str]:
|
|
83
|
+
return await asyncio.get_running_loop().run_in_executor(_executor, hash_password, password)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def verify_password_async(password: str, encoded: str, legacy_salt: str = "") -> bool:
|
|
87
|
+
return await asyncio.get_running_loop().run_in_executor(
|
|
88
|
+
_executor, verify_password, password, encoded, legacy_salt,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def dummy_verify_async(password: str) -> None:
|
|
93
|
+
"""Run a full PBKDF2 verification against a throwaway hash so unknown
|
|
94
|
+
users take as long as known ones."""
|
|
95
|
+
global _dummy_hash
|
|
96
|
+
if _dummy_hash is None:
|
|
97
|
+
_dummy_hash = (await hash_password_async(secrets.token_hex(16)))[0]
|
|
98
|
+
await verify_password_async(password, _dummy_hash)
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""User and group management — validation, privilege checks, protections,
|
|
2
|
+
cascades and audit.
|
|
3
|
+
|
|
4
|
+
Controllers check endpoint permissions; this service enforces the data and
|
|
5
|
+
security rules shared by every caller.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..errors import bad_request, conflict, not_found
|
|
13
|
+
from ..models import Group, User
|
|
14
|
+
from ..permissions import ADMIN_GROUP, USERS_GROUP
|
|
15
|
+
from ..validation import (
|
|
16
|
+
DESCRIPTION_MAX,
|
|
17
|
+
NAME_MAX,
|
|
18
|
+
validate_data,
|
|
19
|
+
validate_email,
|
|
20
|
+
validate_name,
|
|
21
|
+
validate_permissions,
|
|
22
|
+
validate_text,
|
|
23
|
+
)
|
|
24
|
+
from .auth_service import AuthContext, AuthService
|
|
25
|
+
|
|
26
|
+
BUILT_IN_GROUPS = (ADMIN_GROUP, USERS_GROUP)
|
|
27
|
+
|
|
28
|
+
LAST_ADMIN = "cannot remove the last admin"
|
|
29
|
+
HOME_DIR_MAX = 1024
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _clean(value: str | None) -> str:
|
|
33
|
+
return (value or "").strip()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _actor(ctx: AuthContext | None) -> dict[str, Any]:
|
|
37
|
+
if ctx is None:
|
|
38
|
+
return {}
|
|
39
|
+
return {"actor": ctx.user.name, "actor_id": ctx.user.id}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UsersService:
|
|
43
|
+
def __init__(self, auth: AuthService) -> None:
|
|
44
|
+
self.auth = auth
|
|
45
|
+
self.users = auth.user_store
|
|
46
|
+
self.groups = auth.group_store
|
|
47
|
+
self.credentials = auth.credential_store
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
# Helpers
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
async def resolve_groups(self, names: list[str] | None) -> list[str]:
|
|
54
|
+
"""Validate group names; return the stored (canonical) names,
|
|
55
|
+
de-duplicated case-insensitively."""
|
|
56
|
+
out: list[str] = []
|
|
57
|
+
for raw in names or []:
|
|
58
|
+
name = _clean(raw)
|
|
59
|
+
if not name:
|
|
60
|
+
continue
|
|
61
|
+
group = await self.groups.get_group(name) if len(name) <= NAME_MAX else None
|
|
62
|
+
if not group:
|
|
63
|
+
raise bad_request(f"unknown group: {name[:NAME_MAX]}")
|
|
64
|
+
if not any(g.lower() == group.name.lower() for g in out):
|
|
65
|
+
out.append(group.name)
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
async def _ensure_unique(self, name: str | None, email: str | None, exclude_id: str | None) -> None:
|
|
69
|
+
"""Names and emails share one namespace (case-insensitive)."""
|
|
70
|
+
for user in await self.users.get_users():
|
|
71
|
+
if user.id == exclude_id:
|
|
72
|
+
continue
|
|
73
|
+
taken = {user.name.lower(), user.email.lower()}
|
|
74
|
+
if name is not None and name.lower() in taken:
|
|
75
|
+
raise conflict(f"user already exists: {name}")
|
|
76
|
+
if email is not None and email.lower() in taken:
|
|
77
|
+
raise conflict(f"email already in use: {email}")
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _is_built_in(group: Group) -> bool:
|
|
81
|
+
return group.built_in or group.name.lower() in BUILT_IN_GROUPS
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# Users
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
async def list_users(self, query: str | None, skip: int, take: int | None) -> list[User]:
|
|
88
|
+
users = await self.users.get_users()
|
|
89
|
+
q = _clean(query).lower()
|
|
90
|
+
if q:
|
|
91
|
+
users = [u for u in users if q in u.name.lower() or q in u.email.lower()]
|
|
92
|
+
users = users[skip:]
|
|
93
|
+
if take is not None:
|
|
94
|
+
users = users[:take]
|
|
95
|
+
return users
|
|
96
|
+
|
|
97
|
+
async def create_user(
|
|
98
|
+
self,
|
|
99
|
+
ctx: AuthContext | None,
|
|
100
|
+
*,
|
|
101
|
+
name: str,
|
|
102
|
+
email: str,
|
|
103
|
+
groups: list[str] | None = None,
|
|
104
|
+
password: str | None = None,
|
|
105
|
+
disabled: bool | None = None,
|
|
106
|
+
home_dir: str | None = None,
|
|
107
|
+
data: dict[str, Any] | None = None,
|
|
108
|
+
) -> User:
|
|
109
|
+
name = validate_name(_clean(name))
|
|
110
|
+
email = validate_email(_clean(email))
|
|
111
|
+
validate_text(home_dir, "homeDir", HOME_DIR_MAX)
|
|
112
|
+
validate_data(data)
|
|
113
|
+
if password is not None:
|
|
114
|
+
error = self.auth.validate_password_policy(password)
|
|
115
|
+
if error:
|
|
116
|
+
raise bad_request(error)
|
|
117
|
+
resolved = await self.resolve_groups(groups)
|
|
118
|
+
if resolved and ctx is not None:
|
|
119
|
+
await self.auth.ensure_can_grant_groups(ctx, resolved)
|
|
120
|
+
if not resolved:
|
|
121
|
+
resolved = await self.resolve_groups([USERS_GROUP])
|
|
122
|
+
await self._ensure_unique(name, email, None)
|
|
123
|
+
try:
|
|
124
|
+
user = await self.users.create_user(
|
|
125
|
+
name, email, resolved, data,
|
|
126
|
+
disabled=bool(disabled), home_dir=home_dir,
|
|
127
|
+
)
|
|
128
|
+
except ValueError as exc:
|
|
129
|
+
raise conflict(str(exc)) from exc
|
|
130
|
+
if password is not None:
|
|
131
|
+
await self.auth.set_password(user.id, password)
|
|
132
|
+
await self.auth.audit("user_created", user.name, user_id=user.id, **_actor(ctx))
|
|
133
|
+
return user
|
|
134
|
+
|
|
135
|
+
async def update_user(
|
|
136
|
+
self,
|
|
137
|
+
ctx: AuthContext | None,
|
|
138
|
+
target: User,
|
|
139
|
+
*,
|
|
140
|
+
name: str | None = None,
|
|
141
|
+
email: str | None = None,
|
|
142
|
+
groups: list[str] | None = None,
|
|
143
|
+
disabled: bool | None = None,
|
|
144
|
+
home_dir: str | None = None,
|
|
145
|
+
data: dict[str, Any] | None = None,
|
|
146
|
+
) -> User:
|
|
147
|
+
if name is not None:
|
|
148
|
+
name = validate_name(_clean(name))
|
|
149
|
+
if name == target.name:
|
|
150
|
+
name = None
|
|
151
|
+
if email is not None:
|
|
152
|
+
email = validate_email(_clean(email))
|
|
153
|
+
if email == target.email:
|
|
154
|
+
email = None
|
|
155
|
+
validate_text(home_dir, "homeDir", HOME_DIR_MAX)
|
|
156
|
+
validate_data(data)
|
|
157
|
+
|
|
158
|
+
resolved_groups: list[str] | None = None
|
|
159
|
+
if groups is not None:
|
|
160
|
+
resolved_groups = await self.resolve_groups(groups)
|
|
161
|
+
|
|
162
|
+
if ctx is not None:
|
|
163
|
+
await self.auth.ensure_covers_user(ctx, target)
|
|
164
|
+
if resolved_groups is not None:
|
|
165
|
+
added = [g for g in resolved_groups if not self.auth.in_group(target, g)]
|
|
166
|
+
if added:
|
|
167
|
+
await self.auth.ensure_can_grant_groups(ctx, added)
|
|
168
|
+
|
|
169
|
+
await self._ensure_unique(name, email, target.id)
|
|
170
|
+
|
|
171
|
+
removes_admin = resolved_groups is not None and not any(
|
|
172
|
+
g.lower() == ADMIN_GROUP for g in resolved_groups
|
|
173
|
+
)
|
|
174
|
+
if (removes_admin or disabled is True) and await self.auth.is_last_enabled_admin(target):
|
|
175
|
+
raise conflict(LAST_ADMIN)
|
|
176
|
+
|
|
177
|
+
was_disabled = bool(target.disabled)
|
|
178
|
+
try:
|
|
179
|
+
user = await self.users.update_user(
|
|
180
|
+
target.id,
|
|
181
|
+
name=name,
|
|
182
|
+
email=email,
|
|
183
|
+
groups=resolved_groups,
|
|
184
|
+
disabled=disabled,
|
|
185
|
+
home_dir=home_dir,
|
|
186
|
+
data=data,
|
|
187
|
+
)
|
|
188
|
+
except KeyError as exc:
|
|
189
|
+
raise not_found(f"user not found: {target.name}") from exc
|
|
190
|
+
|
|
191
|
+
actor = _actor(ctx)
|
|
192
|
+
await self.auth.audit("user_updated", user.name, user_id=user.id, **actor)
|
|
193
|
+
if disabled is not None and disabled != was_disabled:
|
|
194
|
+
if disabled:
|
|
195
|
+
await self.auth.audit("user_disabled", user.name, user_id=user.id, **actor)
|
|
196
|
+
await self.auth.revoke_user_sessions(user.id, "user_disabled", **actor)
|
|
197
|
+
else:
|
|
198
|
+
await self.auth.audit("user_enabled", user.name, user_id=user.id, **actor)
|
|
199
|
+
return user
|
|
200
|
+
|
|
201
|
+
async def delete_user(self, ctx: AuthContext | None, target: User) -> None:
|
|
202
|
+
if ctx is not None and ctx.is_self(target):
|
|
203
|
+
raise conflict("cannot delete yourself")
|
|
204
|
+
if ctx is not None:
|
|
205
|
+
await self.auth.ensure_covers_user(ctx, target)
|
|
206
|
+
if await self.auth.is_last_enabled_admin(target):
|
|
207
|
+
raise conflict(LAST_ADMIN)
|
|
208
|
+
await self.users.delete_user(target.id)
|
|
209
|
+
await self.credentials.delete_credentials(target.id)
|
|
210
|
+
self.auth.reset_failures(target.id)
|
|
211
|
+
actor = _actor(ctx)
|
|
212
|
+
await self.auth.revoke_user_sessions(target.id, "user_deleted", **actor)
|
|
213
|
+
await self.auth.audit("user_deleted", target.name, user_id=target.id, **actor)
|
|
214
|
+
|
|
215
|
+
async def change_password(
|
|
216
|
+
self, ctx: AuthContext, target: User, password: str, *, temporary: bool | None = None,
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Set ``target``'s password. By self: clears mustChangePassword and
|
|
219
|
+
revokes the caller's other sessions. Admin reset: clears the
|
|
220
|
+
target's lockout, revokes all their sessions and sets
|
|
221
|
+
mustChangePassword unless ``temporary`` is ``False``."""
|
|
222
|
+
error = self.auth.validate_password_policy(password)
|
|
223
|
+
if error:
|
|
224
|
+
raise bad_request(error)
|
|
225
|
+
is_self = ctx.is_self(target)
|
|
226
|
+
if not is_self:
|
|
227
|
+
await self.auth.ensure_covers_user(ctx, target)
|
|
228
|
+
await self.auth.set_password(target.id, password)
|
|
229
|
+
actor = _actor(ctx)
|
|
230
|
+
if is_self:
|
|
231
|
+
if target.must_change_password:
|
|
232
|
+
await self.users.update_user(target.id, must_change_password=False)
|
|
233
|
+
ctx.user.must_change_password = False
|
|
234
|
+
await self.auth.audit("password_changed", target.name, user_id=target.id, **actor)
|
|
235
|
+
await self.auth.revoke_user_sessions(
|
|
236
|
+
target.id, "password_changed",
|
|
237
|
+
except_session_id=ctx.session.id if ctx.session else None, **actor,
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
self.auth.reset_failures(target.id)
|
|
241
|
+
await self.users.update_user(target.id, must_change_password=temporary is not False)
|
|
242
|
+
await self.auth.audit("password_changed", target.name, user_id=target.id, **actor)
|
|
243
|
+
await self.auth.revoke_user_sessions(target.id, "password_changed", **actor)
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# Groups
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
async def group_members(self, group: Group) -> list[User]:
|
|
250
|
+
return [u for u in await self.users.get_users() if self.auth.in_group(u, group.name)]
|
|
251
|
+
|
|
252
|
+
async def create_group(
|
|
253
|
+
self,
|
|
254
|
+
ctx: AuthContext | None,
|
|
255
|
+
*,
|
|
256
|
+
name: str,
|
|
257
|
+
description: str | None = None,
|
|
258
|
+
permissions: list[str] | None = None,
|
|
259
|
+
) -> Group:
|
|
260
|
+
name = validate_name(_clean(name))
|
|
261
|
+
validate_text(description, "description", DESCRIPTION_MAX)
|
|
262
|
+
perms = validate_permissions(permissions) or []
|
|
263
|
+
if ctx is not None:
|
|
264
|
+
self.auth.ensure_can_grant_permissions(ctx, perms)
|
|
265
|
+
if await self.groups.get_group(name):
|
|
266
|
+
raise conflict(f"group already exists: {name}")
|
|
267
|
+
try:
|
|
268
|
+
group = await self.groups.create_group(name, description, perms)
|
|
269
|
+
except ValueError as exc:
|
|
270
|
+
raise conflict(str(exc)) from exc
|
|
271
|
+
await self.auth.audit("group_created", ctx.user.name if ctx else "", details=group.name, **_actor(ctx))
|
|
272
|
+
return group
|
|
273
|
+
|
|
274
|
+
async def update_group(
|
|
275
|
+
self,
|
|
276
|
+
ctx: AuthContext | None,
|
|
277
|
+
group: Group,
|
|
278
|
+
*,
|
|
279
|
+
description: str | None = None,
|
|
280
|
+
permissions: list[str] | None = None,
|
|
281
|
+
) -> Group:
|
|
282
|
+
validate_text(description, "description", DESCRIPTION_MAX)
|
|
283
|
+
perms = validate_permissions(permissions)
|
|
284
|
+
if perms is not None and group.name.lower() == ADMIN_GROUP:
|
|
285
|
+
raise conflict("cannot change the permissions of the admin group")
|
|
286
|
+
if perms is not None and ctx is not None:
|
|
287
|
+
self.auth.ensure_can_grant_permissions(ctx, perms)
|
|
288
|
+
try:
|
|
289
|
+
updated = await self.groups.update_group(
|
|
290
|
+
group.name, description=description, permissions=perms,
|
|
291
|
+
)
|
|
292
|
+
except KeyError as exc:
|
|
293
|
+
raise not_found(f"group not found: {group.name}") from exc
|
|
294
|
+
await self.auth.audit("group_updated", ctx.user.name if ctx else "", details=updated.name, **_actor(ctx))
|
|
295
|
+
return updated
|
|
296
|
+
|
|
297
|
+
async def delete_group(self, ctx: AuthContext | None, group: Group) -> None:
|
|
298
|
+
if self._is_built_in(group):
|
|
299
|
+
raise conflict(f"cannot delete built-in group: {group.name}")
|
|
300
|
+
for user in await self.group_members(group):
|
|
301
|
+
await self.users.update_user(
|
|
302
|
+
user.id,
|
|
303
|
+
groups=[g for g in user.groups if g.lower() != group.name.lower()],
|
|
304
|
+
)
|
|
305
|
+
await self.groups.delete_group(group.name)
|
|
306
|
+
await self.auth.audit("group_deleted", ctx.user.name if ctx else "", details=group.name, **_actor(ctx))
|
|
307
|
+
|
|
308
|
+
async def add_member(self, ctx: AuthContext | None, group: Group, user: User) -> None:
|
|
309
|
+
if ctx is not None:
|
|
310
|
+
await self.auth.ensure_covers_user(ctx, user)
|
|
311
|
+
await self.auth.ensure_can_grant_groups(ctx, [group.name])
|
|
312
|
+
if self.auth.in_group(user, group.name):
|
|
313
|
+
return
|
|
314
|
+
await self.users.update_user(user.id, groups=[*user.groups, group.name])
|
|
315
|
+
await self.auth.audit("group_member_added", user.name, details=group.name,
|
|
316
|
+
user_id=user.id, **_actor(ctx))
|
|
317
|
+
|
|
318
|
+
async def remove_member(self, ctx: AuthContext | None, group: Group, user: User) -> None:
|
|
319
|
+
if ctx is not None:
|
|
320
|
+
await self.auth.ensure_covers_user(ctx, user)
|
|
321
|
+
if not self.auth.in_group(user, group.name):
|
|
322
|
+
return
|
|
323
|
+
if group.name.lower() == ADMIN_GROUP and await self.auth.is_last_enabled_admin(user):
|
|
324
|
+
raise conflict(LAST_ADMIN)
|
|
325
|
+
await self.users.update_user(
|
|
326
|
+
user.id,
|
|
327
|
+
groups=[g for g in user.groups if g.lower() != group.name.lower()],
|
|
328
|
+
)
|
|
329
|
+
await self.auth.audit("group_member_removed", user.name, details=group.name,
|
|
330
|
+
user_id=user.id, **_actor(ctx))
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Remote shell access for the users plugin (Appendix C, ``users`` mode).
|
|
2
|
+
|
|
3
|
+
Registered on the CLI server when the plugin is mounted. An open shell is
|
|
4
|
+
bound to the session that opened it: the server re-checks it periodically and
|
|
5
|
+
on every session/user/group change, and closes it (4401 ``session ended``)
|
|
6
|
+
after logout, expiry, revoke, disable/delete or loss of ``shell:execute``.
|
|
7
|
+
Identities from an external auth adapter are re-validated by calling the
|
|
8
|
+
adapter again with the shell's WebSocket request, so the adapter must be able
|
|
9
|
+
to authenticate it from its headers/cookies.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
from urllib.parse import parse_qs
|
|
17
|
+
|
|
18
|
+
from ._http import get_scope_auth
|
|
19
|
+
from .permissions import has_permission
|
|
20
|
+
from .services.auth_service import AuthContext, AuthService
|
|
21
|
+
|
|
22
|
+
SHELL_PERMISSION = "shell:execute"
|
|
23
|
+
#: ``shell_closed`` audit reason when ``shell:execute`` is lost.
|
|
24
|
+
PERMISSION_REMOVED = "permission removed"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class UsersShellPrincipal:
|
|
29
|
+
"""The caller owning a shell (duck-type of ``qodalis_cli.ShellPrincipal``)."""
|
|
30
|
+
|
|
31
|
+
user_id: str
|
|
32
|
+
user_name: str
|
|
33
|
+
session_id: str | None
|
|
34
|
+
context: AuthContext
|
|
35
|
+
#: The shell's WebSocket: adapter identities are re-validated by calling
|
|
36
|
+
#: the external auth adapter again with it.
|
|
37
|
+
connection: Any = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UsersShellAccess:
|
|
41
|
+
"""``ShellAccessProvider`` backed by the users plugin's sessions."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, auth_service: AuthService) -> None:
|
|
44
|
+
self.auth = auth_service
|
|
45
|
+
|
|
46
|
+
async def resolve(self, websocket: Any) -> UsersShellPrincipal | None:
|
|
47
|
+
# Normally set by the server-protection middleware (ticket consumed);
|
|
48
|
+
# without it (protect_server=False) the shell still requires a ticket.
|
|
49
|
+
ctx = get_scope_auth(websocket.scope)
|
|
50
|
+
if not isinstance(ctx, AuthContext):
|
|
51
|
+
raw = websocket.scope.get("query_string", b"").decode("latin-1")
|
|
52
|
+
ticket = (parse_qs(raw).get("ticket") or [None])[0]
|
|
53
|
+
ctx = await self.auth.consume_ticket(ticket)
|
|
54
|
+
if ctx is None:
|
|
55
|
+
return None
|
|
56
|
+
return UsersShellPrincipal(
|
|
57
|
+
user_id=ctx.user.id,
|
|
58
|
+
user_name=ctx.user.name,
|
|
59
|
+
session_id=ctx.session.id if ctx.session else None,
|
|
60
|
+
context=ctx,
|
|
61
|
+
connection=websocket,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def can_open(self, principal: UsersShellPrincipal) -> bool:
|
|
65
|
+
ctx = principal.context
|
|
66
|
+
return not ctx.must_change_password and ctx.can(SHELL_PERMISSION)
|
|
67
|
+
|
|
68
|
+
async def is_active(self, principal: UsersShellPrincipal) -> bool | str:
|
|
69
|
+
"""``True`` while valid; ``False`` (session ended) or
|
|
70
|
+
``"permission removed"`` otherwise. Never extends the session."""
|
|
71
|
+
ctx = principal.context
|
|
72
|
+
if ctx.external:
|
|
73
|
+
return await self._adapter_active(principal)
|
|
74
|
+
if principal.session_id is None:
|
|
75
|
+
return False
|
|
76
|
+
session = await self.auth.session_store.get_session_by_id(principal.session_id)
|
|
77
|
+
if session is None or session.user_id != principal.user_id or self.auth.is_expired(session):
|
|
78
|
+
return False
|
|
79
|
+
user = await self.auth.user_store.get_user(principal.user_id)
|
|
80
|
+
if user is None or user.id != principal.user_id or user.disabled or user.must_change_password:
|
|
81
|
+
return False
|
|
82
|
+
if not has_permission(await self.auth.user_permissions(user), SHELL_PERMISSION):
|
|
83
|
+
return PERMISSION_REMOVED
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
async def _adapter_active(self, principal: UsersShellPrincipal) -> bool | str:
|
|
87
|
+
"""Adapter identities have no stored session: ask the external auth
|
|
88
|
+
adapter again with the shell's WebSocket (its headers and cookies).
|
|
89
|
+
``None`` or an exception ends the shell; a different user or one
|
|
90
|
+
without ``shell:execute`` too."""
|
|
91
|
+
if principal.connection is None or not self.auth.external_auth:
|
|
92
|
+
return False
|
|
93
|
+
try:
|
|
94
|
+
fresh = await self.auth.authenticate_external(principal.connection)
|
|
95
|
+
except Exception:
|
|
96
|
+
return False
|
|
97
|
+
if fresh.user.id != principal.user_id or fresh.must_change_password:
|
|
98
|
+
return False
|
|
99
|
+
if not fresh.can(SHELL_PERMISSION):
|
|
100
|
+
return PERMISSION_REMOVED
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
async def touch(self, principal: UsersShellPrincipal) -> None:
|
|
104
|
+
"""Count shell input as session activity (idle timeout)."""
|
|
105
|
+
if principal.context.external or principal.session_id is None:
|
|
106
|
+
return
|
|
107
|
+
session = await self.auth.session_store.get_session_by_id(principal.session_id)
|
|
108
|
+
if session is not None and session.token_hash:
|
|
109
|
+
await self.auth.session_store.update_last_activity(session.token_hash)
|
|
110
|
+
|
|
111
|
+
async def audit(self, event: str, principal: UsersShellPrincipal, metadata: dict[str, Any]) -> None:
|
|
112
|
+
await self.auth.audit(
|
|
113
|
+
event,
|
|
114
|
+
principal.user_name,
|
|
115
|
+
user_id=principal.user_id,
|
|
116
|
+
actor=principal.user_name,
|
|
117
|
+
actor_id=principal.user_id,
|
|
118
|
+
metadata=metadata,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def add_change_listener(self, listener: Callable[[], Any]) -> None:
|
|
122
|
+
self.auth.add_change_listener(listener)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = ["SHELL_PERMISSION", "UsersShellAccess", "UsersShellPrincipal"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Users plugin store implementations."""
|
|
2
|
+
|
|
3
|
+
from .interfaces import (
|
|
4
|
+
IUserStore,
|
|
5
|
+
ISessionStore,
|
|
6
|
+
ICredentialStore,
|
|
7
|
+
IAuditStore,
|
|
8
|
+
IGroupStore,
|
|
9
|
+
)
|
|
10
|
+
from .in_memory_user_store import InMemoryUserStore
|
|
11
|
+
from .in_memory_session_store import InMemorySessionStore
|
|
12
|
+
from .in_memory_credential_store import InMemoryCredentialStore
|
|
13
|
+
from .in_memory_audit_store import InMemoryAuditStore
|
|
14
|
+
from .in_memory_group_store import InMemoryGroupStore
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"IAuditStore",
|
|
18
|
+
"ICredentialStore",
|
|
19
|
+
"IGroupStore",
|
|
20
|
+
"ISessionStore",
|
|
21
|
+
"IUserStore",
|
|
22
|
+
"InMemoryAuditStore",
|
|
23
|
+
"InMemoryCredentialStore",
|
|
24
|
+
"InMemoryGroupStore",
|
|
25
|
+
"InMemorySessionStore",
|
|
26
|
+
"InMemoryUserStore",
|
|
27
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""In-memory implementation of IAuditStore.
|
|
2
|
+
|
|
3
|
+
Keeps the last 1000 entries, with failed logins capped separately (last 200)
|
|
4
|
+
so a flood of failed attempts cannot evict other history.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import itertools
|
|
10
|
+
from collections import deque
|
|
11
|
+
|
|
12
|
+
from ..models import AuditEntry, AuditQueryOptions
|
|
13
|
+
from .interfaces import IAuditStore
|
|
14
|
+
|
|
15
|
+
MAX_ENTRIES = 1000
|
|
16
|
+
MAX_FAILED_LOGIN_ENTRIES = 200
|
|
17
|
+
FAILED_LOGIN_EVENT = "login_failed"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InMemoryAuditStore(IAuditStore):
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
max_entries: int = MAX_ENTRIES,
|
|
24
|
+
max_failed_login_entries: int = MAX_FAILED_LOGIN_ENTRIES,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._seq = itertools.count()
|
|
27
|
+
self._entries: deque[tuple[int, AuditEntry]] = deque(maxlen=max_entries)
|
|
28
|
+
self._failed: deque[tuple[int, AuditEntry]] = deque(maxlen=max_failed_login_entries)
|
|
29
|
+
|
|
30
|
+
async def log(self, entry: AuditEntry) -> None:
|
|
31
|
+
target = self._failed if entry.event == FAILED_LOGIN_EVENT else self._entries
|
|
32
|
+
target.append((next(self._seq), entry))
|
|
33
|
+
|
|
34
|
+
async def get_entries(self, options: AuditQueryOptions | None = None) -> list[AuditEntry]:
|
|
35
|
+
merged = sorted([*self._entries, *self._failed], key=lambda x: x[0], reverse=True)
|
|
36
|
+
result = [e for _, e in merged] # newest first
|
|
37
|
+
if options:
|
|
38
|
+
if options.event:
|
|
39
|
+
ev = options.event.lower()
|
|
40
|
+
result = [e for e in result if e.event.lower() == ev]
|
|
41
|
+
if options.username:
|
|
42
|
+
un = options.username.lower()
|
|
43
|
+
result = [e for e in result if e.username.lower() == un]
|
|
44
|
+
if options.limit is not None and options.limit > 0:
|
|
45
|
+
result = result[: options.limit]
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
async def clear(self) -> None:
|
|
49
|
+
self._entries.clear()
|
|
50
|
+
self._failed.clear()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""In-memory implementation of ICredentialStore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..models import Credentials, now_ms
|
|
6
|
+
from .interfaces import ICredentialStore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InMemoryCredentialStore(ICredentialStore):
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
self._credentials: dict[str, Credentials] = {}
|
|
12
|
+
|
|
13
|
+
async def set_password(self, user_id: str, hash_: str, salt: str) -> None:
|
|
14
|
+
self._credentials[user_id] = Credentials(
|
|
15
|
+
user_id=user_id,
|
|
16
|
+
password_hash=hash_,
|
|
17
|
+
salt=salt,
|
|
18
|
+
last_changed=now_ms(),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
async def get_credentials(self, user_id: str) -> Credentials | None:
|
|
22
|
+
return self._credentials.get(user_id)
|
|
23
|
+
|
|
24
|
+
async def delete_credentials(self, user_id: str) -> None:
|
|
25
|
+
self._credentials.pop(user_id, None)
|