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