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,58 @@
1
+ """Permission matching — same semantics as ``cliPermissionMatches`` in
2
+ ``@qodalis/cli-core``.
3
+
4
+ Permission strings are ``resource:action``. A trailing ``*`` segment matches
5
+ anything at that level and below (``*``, ``users:*``); a ``*`` elsewhere
6
+ matches exactly one segment (``*:read`` matches ``users:read`` only).
7
+ Matching is case-insensitive.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterable
13
+
14
+ USERS_READ = "users:read"
15
+ USERS_CREATE = "users:create"
16
+ USERS_UPDATE = "users:update"
17
+ USERS_DELETE = "users:delete"
18
+ GROUPS_READ = "groups:read"
19
+ GROUPS_CREATE = "groups:create"
20
+ GROUPS_UPDATE = "groups:update"
21
+ GROUPS_DELETE = "groups:delete"
22
+ SESSIONS_READ = "sessions:read"
23
+ SESSIONS_REVOKE = "sessions:revoke"
24
+ AUDIT_READ = "audit:read"
25
+ AUDIT_CLEAR = "audit:clear"
26
+
27
+ ADMIN_GROUP = "admin"
28
+ USERS_GROUP = "users"
29
+
30
+
31
+ def permission_matches(granted: str, required: str) -> bool:
32
+ g = granted.strip().lower().split(":")
33
+ r = required.strip().lower().split(":")
34
+ for i, seg in enumerate(g):
35
+ # A trailing '*' grants everything below; elsewhere it matches
36
+ # exactly one segment.
37
+ if seg == "*" and i == len(g) - 1:
38
+ return True
39
+ if i >= len(r) or (seg != "*" and seg != r[i]):
40
+ return False
41
+ return len(g) == len(r)
42
+
43
+
44
+ def has_permission(granted: Iterable[str] | None, required: str) -> bool:
45
+ return any(permission_matches(p, required) for p in (granted or []))
46
+
47
+
48
+ def merge_permissions(*lists: Iterable[str] | None) -> list[str]:
49
+ """Union of permission lists, de-duplicated case-insensitively."""
50
+ seen: set[str] = set()
51
+ out: list[str] = []
52
+ for lst in lists:
53
+ for p in lst or []:
54
+ key = p.strip()
55
+ if key and key.lower() not in seen:
56
+ seen.add(key.lower())
57
+ out.append(key)
58
+ return out
@@ -0,0 +1,209 @@
1
+ """Server-wide protection (security contract B1/B2/B4/B9).
2
+
3
+ An ASGI middleware installed by the users plugin that requires an
4
+ authenticated users session for every CLI server HTTP route and WebSocket,
5
+ except login, version discovery and health endpoints. The authenticated
6
+ caller is stored in ``scope["state"]["qcli_auth"]`` so the core server can
7
+ enforce processor ``required_permissions`` and scope events to users.
8
+
9
+ WebSockets authenticate with a single-use ticket (``?ticket=``) obtained from
10
+ ``POST /api/v1/qcli/sessions/ws-ticket``; bearer tokens in query strings are
11
+ not accepted. Failures close the socket with 4401 (unauthenticated) or 4403
12
+ (forbidden).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ from typing import Any, Awaitable, Callable
20
+
21
+ from starlette.exceptions import HTTPException
22
+ from starlette.requests import Request
23
+
24
+ from ._http import SAFE_METHODS, set_scope_auth
25
+ from .errors import UsersApiError
26
+ from .middleware.auth_middleware import authenticate_request
27
+ from .services.auth_service import PASSWORD_CHANGE_REQUIRED, AuthContext, AuthService
28
+
29
+ Rule = tuple[re.Pattern[str], frozenset[str] | None, str | None]
30
+
31
+ #: Always public (method set ``None`` = any method).
32
+ PUBLIC_PATHS: list[tuple[re.Pattern[str], frozenset[str] | None]] = [
33
+ (re.compile(r"^/api/v1/qcli/sessions/?$"), frozenset({"POST"})),
34
+ (re.compile(r"^/api/(qcli|cli|v1/qcli|v1/cli)/versions?/?$"), frozenset({"GET", "HEAD"})),
35
+ (re.compile(r"^/api/(qcli|cli|v1/qcli|v1/cli)/capabilities/?$"), frozenset({"GET", "HEAD"})),
36
+ (re.compile(r"^/(api/)?(qcli/)?health(z|/.*)?$"), None),
37
+ ]
38
+
39
+ #: Routes of the users plugin itself — they authenticate in their own
40
+ #: dependencies (with CSRF and password-change gating).
41
+ SELF_PROTECTED = re.compile(r"^/api/v1/qcli/(sessions|users|groups|audit)(/.*)?$")
42
+
43
+ #: HTTP permission rules, first match wins: (path, methods or None, permission).
44
+ HTTP_RULES: list[Rule] = [
45
+ (re.compile(r"^/api/(v1/)?qcli/fs/(ls|cat|stat|download)/?$"), None, "files:read"),
46
+ (re.compile(r"^/api/(v1/)?qcli/fs(/.*)?$"), None, "files:write"),
47
+ (re.compile(r"^/api/(v1/)?qcli/data-explorer(/.*)?$"), None, "data:query"),
48
+ (re.compile(r"^/api/(v1/)?qcli/jobs(/.*)?$"), frozenset({"GET", "HEAD"}), "jobs:read"),
49
+ (re.compile(r"^/api/(v1/)?qcli/jobs(/.*)?$"), None, "jobs:manage"),
50
+ (re.compile(r"^/api/(v1/)?qcli/execute(/stream)?/?$"), None, "commands:execute"),
51
+ ]
52
+
53
+ WS_RULES: list[tuple[re.Pattern[str], str | None]] = [
54
+ (re.compile(r"^/ws/(v1/)?qcli/events/?$"), None),
55
+ (re.compile(r"^/ws/(v1/)?qcli/logs/?$"), "logs:read"),
56
+ (re.compile(r"^/ws/(v1/)?qcli/shell/?$"), "shell:execute"),
57
+ ]
58
+
59
+
60
+ def http_permission(path: str, method: str, base_paths: list[str] | None = None) -> str | None:
61
+ for pattern, methods, permission in HTTP_RULES:
62
+ if pattern.match(path) and (methods is None or method in methods):
63
+ return permission
64
+ for base in base_paths or []:
65
+ if re.match(rf"^{re.escape(base)}/execute(/stream)?/?$", path):
66
+ return "commands:execute"
67
+ return None
68
+
69
+
70
+ def _normalize_base(path: str) -> str:
71
+ return "/" + path.strip().strip("/")
72
+
73
+
74
+ def ws_permission(path: str) -> str | None:
75
+ for pattern, permission in WS_RULES:
76
+ if pattern.match(path):
77
+ return permission
78
+ return None
79
+
80
+
81
+ class ServerProtectionMiddleware:
82
+ """Pure ASGI middleware enforcing B1 for HTTP and WebSocket scopes."""
83
+
84
+ def __init__(
85
+ self,
86
+ app: Any,
87
+ auth_service: AuthService,
88
+ ensure_ready: Callable[[], Awaitable[None]] | None = None,
89
+ extra_public: list[tuple[str, frozenset[str] | None]] | None = None,
90
+ base_paths: list[str] | None = None,
91
+ ) -> None:
92
+ self.app = app
93
+ self.auth = auth_service
94
+ self.ensure_ready = ensure_ready
95
+ self.extra_public = extra_public if extra_public is not None else []
96
+ #: Custom CLI API base paths (``CliServerOptions.base_path``) — also
97
+ #: protected, wherever they are mounted.
98
+ self.base_paths = base_paths if base_paths is not None else []
99
+
100
+ # ------------------------------------------------------------------
101
+
102
+ def _is_public(self, path: str, method: str) -> bool:
103
+ for pattern, methods in PUBLIC_PATHS:
104
+ if pattern.match(path) and (methods is None or method in methods):
105
+ return True
106
+ for public_path, methods in self.extra_public:
107
+ if path.rstrip("/") == public_path.rstrip("/") and (methods is None or method in methods):
108
+ return True
109
+ if method in ("GET", "HEAD"):
110
+ for base in self.base_paths:
111
+ if path.rstrip("/") in (base + "/version", base + "/capabilities"):
112
+ return True
113
+ return False
114
+
115
+ def _protected(self, path: str) -> bool:
116
+ if path.startswith("/api/") or path.startswith("/ws/"):
117
+ return True
118
+ return any(path == base or path.startswith(base + "/") for base in self.base_paths)
119
+
120
+ async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
121
+ if scope["type"] == "http":
122
+ await self._http(scope, receive, send)
123
+ elif scope["type"] == "websocket":
124
+ await self._websocket(scope, receive, send)
125
+ else:
126
+ await self.app(scope, receive, send)
127
+
128
+ # ------------------------------------------------------------------
129
+
130
+ async def _http(self, scope: dict, receive: Any, send: Any) -> None:
131
+ path: str = scope["path"]
132
+ method: str = scope["method"].upper()
133
+ if (
134
+ method == "OPTIONS"
135
+ or not self._protected(path)
136
+ or self._is_public(path, method)
137
+ or SELF_PROTECTED.match(path)
138
+ ):
139
+ await self.app(scope, receive, send)
140
+ return
141
+ if self.ensure_ready is not None:
142
+ await self.ensure_ready()
143
+ try:
144
+ ctx = await authenticate_request(self.auth, Request(scope))
145
+ if ctx.must_change_password:
146
+ raise UsersApiError(403, PASSWORD_CHANGE_REQUIRED)
147
+ permission = http_permission(path, method, self.base_paths)
148
+ if permission and not ctx.can(permission):
149
+ raise UsersApiError(403, f"missing permission: {permission}")
150
+ except UsersApiError as exc:
151
+ await _send_json(send, exc.status_code, {"error": exc.message})
152
+ return
153
+ except HTTPException as exc:
154
+ # Raised by an external auth adapter: keep its status and detail.
155
+ await _send_json(send, exc.status_code, {"error": exc.detail})
156
+ return
157
+ set_scope_auth(scope, ctx)
158
+ await self.app(scope, receive, send)
159
+
160
+ async def _websocket(self, scope: dict, receive: Any, send: Any) -> None:
161
+ path: str = scope["path"]
162
+ if not self._protected(path):
163
+ await self.app(scope, receive, send)
164
+ return
165
+ if self.ensure_ready is not None:
166
+ await self.ensure_ready()
167
+ ticket = _query_param(scope, "ticket")
168
+ ctx = await self.auth.consume_ticket(ticket)
169
+ if ctx is None:
170
+ await _close(receive, send, 4401, "unauthorized")
171
+ return
172
+ permission = ws_permission(path)
173
+ if ctx.must_change_password or (permission and not ctx.can(permission)):
174
+ await _close(receive, send, 4403, "forbidden")
175
+ return
176
+ set_scope_auth(scope, ctx)
177
+ await self.app(scope, receive, send)
178
+
179
+
180
+ def _query_param(scope: dict, name: str) -> str | None:
181
+ from urllib.parse import parse_qs
182
+
183
+ raw = scope.get("query_string", b"").decode("latin-1")
184
+ values = parse_qs(raw).get(name)
185
+ return values[0] if values else None
186
+
187
+
188
+ async def _send_json(send: Any, status: int, body: dict) -> None:
189
+ payload = json.dumps(body).encode("utf-8")
190
+ await send({
191
+ "type": "http.response.start",
192
+ "status": status,
193
+ "headers": [
194
+ (b"content-type", b"application/json"),
195
+ (b"content-length", str(len(payload)).encode("ascii")),
196
+ ],
197
+ })
198
+ await send({"type": "http.response.body", "body": payload})
199
+
200
+
201
+ async def _close(receive: Any, send: Any, code: int, reason: str) -> None:
202
+ message = await receive()
203
+ if message.get("type") != "websocket.connect":
204
+ return
205
+ await send({"type": "websocket.accept"})
206
+ await send({"type": "websocket.close", "code": code, "reason": reason})
207
+
208
+
209
+ __all__ = ["AuthContext", "ServerProtectionMiddleware", "http_permission", "ws_permission", "SAFE_METHODS"]
@@ -0,0 +1,6 @@
1
+ """Users plugin services."""
2
+
3
+ from .auth_service import AuthContext, AuthService, AuthServiceOptions, LoginResult
4
+ from .users_service import UsersService
5
+
6
+ __all__ = ["AuthContext", "AuthService", "AuthServiceOptions", "LoginResult", "UsersService"]