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