qodalis-cli-server-plugin-admin 1.0.0b5__tar.gz → 1.0.0b7__tar.gz
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_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/PKG-INFO +2 -2
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/pyproject.toml +1 -1
- qodalis_cli_server_plugin_admin-1.0.0b7/qodalis_cli_admin/auth/auth_controller.py +188 -0
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/auth/auth_middleware.py +22 -0
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/cli_admin_builder.py +34 -3
- qodalis_cli_server_plugin_admin-1.0.0b7/qodalis_cli_admin/dashboard/assets/index-BAmUWZCS.js +242 -0
- qodalis_cli_server_plugin_admin-1.0.0b7/qodalis_cli_admin/dashboard/assets/index-BF7ofZj2.css +32 -0
- qodalis_cli_server_plugin_admin-1.0.0b7/qodalis_cli_admin/dashboard/assets/wasm/qodalis_cli_wasm_bg.wasm +0 -0
- qodalis_cli_server_plugin_admin-1.0.0b7/qodalis_cli_admin/dashboard/index.html +14 -0
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/services/admin_config.py +9 -3
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/services/log_ring_buffer.py +22 -2
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/services/module_registry.py +47 -27
- qodalis_cli_server_plugin_admin-1.0.0b5/qodalis_cli_admin/auth/auth_controller.py +0 -92
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/.gitignore +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/__init__.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/auth/__init__.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/auth/jwt_service.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/controllers/__init__.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/controllers/config_controller.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/controllers/logs_controller.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/controllers/plugins_controller.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/controllers/status_controller.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/controllers/ws_clients_controller.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/dashboard_resolver.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/services/__init__.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/spa_static_files.py +0 -0
{qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: qodalis-cli-server-plugin-admin
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.0b7
|
|
4
4
|
Summary: Admin dashboard plugin for Qodalis CLI servers
|
|
5
5
|
Project-URL: Homepage, https://github.com/qodalis-solutions/cli-server-python
|
|
6
6
|
Project-URL: Repository, https://github.com/qodalis-solutions/cli-server-python
|
{qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/pyproject.toml
RENAMED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "qodalis-cli-server-plugin-admin"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.0b7"
|
|
8
8
|
description = "Admin dashboard plugin for Qodalis CLI servers"
|
|
9
9
|
readme = { text = "", content-type = "text/plain" }
|
|
10
10
|
license = "MIT"
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Authentication controller — login and ``/me`` endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from .jwt_service import JwtService
|
|
13
|
+
from ..services.admin_config import AdminConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LoginRequest(BaseModel):
|
|
17
|
+
"""Request body for the login endpoint."""
|
|
18
|
+
|
|
19
|
+
username: str
|
|
20
|
+
password: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LoginResponse(BaseModel):
|
|
24
|
+
"""Response body returned on successful login."""
|
|
25
|
+
|
|
26
|
+
token: str
|
|
27
|
+
expiresIn: int
|
|
28
|
+
username: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_MAX_ATTEMPTS = 5
|
|
32
|
+
_WINDOW_SECONDS = 60
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _RateLimiter:
|
|
36
|
+
"""Simple in-memory per-IP rate limiter for failed login attempts."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._attempts: dict[str, list[float]] = defaultdict(list)
|
|
40
|
+
|
|
41
|
+
def check(self, ip: str) -> None:
|
|
42
|
+
"""Raise ``HTTPException(429)`` if the IP has exceeded the limit."""
|
|
43
|
+
now = time.time()
|
|
44
|
+
cutoff = now - _WINDOW_SECONDS
|
|
45
|
+
self._attempts[ip] = [t for t in self._attempts[ip] if t > cutoff]
|
|
46
|
+
if len(self._attempts[ip]) >= _MAX_ATTEMPTS:
|
|
47
|
+
raise HTTPException(status_code=429, detail="Too many failed login attempts. Try again later.")
|
|
48
|
+
|
|
49
|
+
def record_failure(self, ip: str) -> None:
|
|
50
|
+
"""Record a failed login attempt for the given IP."""
|
|
51
|
+
self._attempts[ip].append(time.time())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
ADMIN_ACCESS = "admin:access"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def trusted_hops(trust_proxy: bool | int | None) -> int:
|
|
58
|
+
"""Number of trusted reverse-proxy hops for a ``trust_proxy`` setting.
|
|
59
|
+
|
|
60
|
+
``False``/``0``/``None`` trust no proxy, ``True`` one hop, an int ``n``
|
|
61
|
+
``n`` hops; any other enabled value below 1 (a negative int) counts as
|
|
62
|
+
one hop. ``bool`` is checked first (``True == 1`` in Python).
|
|
63
|
+
"""
|
|
64
|
+
if isinstance(trust_proxy, bool):
|
|
65
|
+
return 1 if trust_proxy else 0
|
|
66
|
+
if not trust_proxy:
|
|
67
|
+
return 0
|
|
68
|
+
return max(int(trust_proxy), 1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def forwarded_client_ip(headers: Any, trust_proxy: bool | int | None) -> str | None:
|
|
72
|
+
"""Client IP from ``X-Forwarded-For`` behind ``trust_proxy`` trusted hops.
|
|
73
|
+
|
|
74
|
+
Every proxy appends the address it saw, so only the rightmost ``n``
|
|
75
|
+
entries were written by trusted proxies; the ones to their left are
|
|
76
|
+
client-controlled. The client IP is the entry at ``len - n`` of the
|
|
77
|
+
comma-separated, trimmed list (the rightmost entry for one hop), or the
|
|
78
|
+
leftmost entry when the list has fewer than ``n`` entries. Returns
|
|
79
|
+
``None`` (use the socket peer) when no proxy is trusted, the header is
|
|
80
|
+
absent/empty, or the selected entry is empty.
|
|
81
|
+
"""
|
|
82
|
+
hops = trusted_hops(trust_proxy)
|
|
83
|
+
if hops == 0:
|
|
84
|
+
return None
|
|
85
|
+
entries = [
|
|
86
|
+
entry.strip()
|
|
87
|
+
for value in headers.getlist("x-forwarded-for")
|
|
88
|
+
for entry in value.split(",")
|
|
89
|
+
]
|
|
90
|
+
if not entries:
|
|
91
|
+
return None
|
|
92
|
+
return entries[max(len(entries) - hops, 0)] or None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def client_ip(request: Request, trust_proxy: bool | int | None) -> str:
|
|
96
|
+
"""Client IP; ``X-Forwarded-For`` is only used with ``trust_proxy`` hops."""
|
|
97
|
+
forwarded = forwarded_client_ip(request.headers, trust_proxy)
|
|
98
|
+
if forwarded:
|
|
99
|
+
return forwarded
|
|
100
|
+
return request.client.host if request.client else "127.0.0.1"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def create_auth_router(
|
|
104
|
+
admin_config: AdminConfig,
|
|
105
|
+
jwt_service: JwtService,
|
|
106
|
+
auth_dependency: Any = None,
|
|
107
|
+
*,
|
|
108
|
+
trust_proxy: bool | int = False,
|
|
109
|
+
users_plugin: Any = None,
|
|
110
|
+
) -> APIRouter:
|
|
111
|
+
"""Create a FastAPI router with ``POST /login`` and ``GET /me``.
|
|
112
|
+
|
|
113
|
+
With ``users_plugin`` the login goes through the users auth service and
|
|
114
|
+
requires the ``admin:access`` permission; the returned token is the
|
|
115
|
+
users session token. Otherwise the static admin credentials are used.
|
|
116
|
+
"""
|
|
117
|
+
router = APIRouter()
|
|
118
|
+
limiter = _RateLimiter()
|
|
119
|
+
|
|
120
|
+
@router.post("/login", response_model=LoginResponse)
|
|
121
|
+
async def login(body: LoginRequest, request: Request) -> Any:
|
|
122
|
+
ip = client_ip(request, trust_proxy)
|
|
123
|
+
limiter.check(ip)
|
|
124
|
+
|
|
125
|
+
if users_plugin is not None:
|
|
126
|
+
return await _users_login(body, request, ip)
|
|
127
|
+
|
|
128
|
+
if not admin_config.validate_credentials(body.username, body.password):
|
|
129
|
+
limiter.record_failure(ip)
|
|
130
|
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
131
|
+
|
|
132
|
+
expires_in = JwtService.DEFAULT_EXPIRY_SECONDS
|
|
133
|
+
token = jwt_service.sign_token(
|
|
134
|
+
{"username": body.username, "authenticatedAt": int(time.time())},
|
|
135
|
+
expires_in=expires_in,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return LoginResponse(token=token, expiresIn=expires_in, username=body.username)
|
|
139
|
+
|
|
140
|
+
async def _users_login(body: LoginRequest, request: Request, ip: str) -> Any:
|
|
141
|
+
from qodalis_cli_users import SessionMetadata
|
|
142
|
+
|
|
143
|
+
auth_service = users_plugin.auth_service
|
|
144
|
+
await users_plugin.ensure_seeded()
|
|
145
|
+
try:
|
|
146
|
+
result = await auth_service.login(
|
|
147
|
+
body.username, body.password,
|
|
148
|
+
SessionMetadata(remote_address=ip, user_agent=request.headers.get("user-agent")),
|
|
149
|
+
)
|
|
150
|
+
except HTTPException as exc:
|
|
151
|
+
if exc.status_code == 401:
|
|
152
|
+
limiter.record_failure(ip)
|
|
153
|
+
raise
|
|
154
|
+
allowed = any(_matches(p, ADMIN_ACCESS) for p in result.permissions)
|
|
155
|
+
if not allowed or result.user.must_change_password:
|
|
156
|
+
await auth_service.logout(result.token)
|
|
157
|
+
raise HTTPException(
|
|
158
|
+
status_code=403,
|
|
159
|
+
detail="password change required" if allowed else f"missing permission: {ADMIN_ACCESS}",
|
|
160
|
+
)
|
|
161
|
+
return LoginResponse(
|
|
162
|
+
token=result.token,
|
|
163
|
+
expiresIn=auth_service.session_timeout_ms // 1000,
|
|
164
|
+
username=result.user.name,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if auth_dependency is not None:
|
|
168
|
+
@router.get("/me")
|
|
169
|
+
async def me(payload: dict[str, Any] = Depends(auth_dependency)) -> dict[str, Any]:
|
|
170
|
+
return {
|
|
171
|
+
"username": payload.get("username", ""),
|
|
172
|
+
"authenticatedAt": payload.get("authenticatedAt"),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return router
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _matches(granted: str, required: str) -> bool:
|
|
179
|
+
g = granted.strip().lower().split(":")
|
|
180
|
+
r = required.strip().lower().split(":")
|
|
181
|
+
for i, seg in enumerate(g):
|
|
182
|
+
# A trailing '*' grants everything below; elsewhere it matches
|
|
183
|
+
# exactly one segment.
|
|
184
|
+
if seg == "*" and i == len(g) - 1:
|
|
185
|
+
return True
|
|
186
|
+
if i >= len(r) or (seg != "*" and seg != r[i]):
|
|
187
|
+
return False
|
|
188
|
+
return len(g) == len(r)
|
|
@@ -10,6 +10,28 @@ from fastapi import Request, HTTPException
|
|
|
10
10
|
from .jwt_service import JwtService
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
def create_users_auth_dependency(users_plugin: Any):
|
|
14
|
+
"""Admin auth through the users plugin: a valid users session holding
|
|
15
|
+
``admin:access`` (admin ``*`` has it)."""
|
|
16
|
+
|
|
17
|
+
async def require_auth(request: Request) -> dict[str, Any]:
|
|
18
|
+
auth_header = request.headers.get("Authorization", "")
|
|
19
|
+
if not auth_header.startswith("Bearer "):
|
|
20
|
+
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
|
|
21
|
+
await users_plugin.ensure_seeded()
|
|
22
|
+
ctx = await users_plugin.auth_service.authenticate_token(auth_header[7:].strip())
|
|
23
|
+
if ctx.must_change_password:
|
|
24
|
+
raise HTTPException(status_code=403, detail="password change required")
|
|
25
|
+
if not ctx.can("admin:access"):
|
|
26
|
+
raise HTTPException(status_code=403, detail="missing permission: admin:access")
|
|
27
|
+
return {
|
|
28
|
+
"username": ctx.user.name,
|
|
29
|
+
"authenticatedAt": (ctx.session.created_at // 1000) if ctx.session else None,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return require_auth
|
|
33
|
+
|
|
34
|
+
|
|
13
35
|
def create_auth_dependency(jwt_service: JwtService):
|
|
14
36
|
"""Return a FastAPI ``Depends`` callable that validates Bearer tokens."""
|
|
15
37
|
|
|
@@ -11,7 +11,7 @@ from fastapi import APIRouter
|
|
|
11
11
|
from .spa_static_files import SPAStaticFiles
|
|
12
12
|
|
|
13
13
|
from .auth.jwt_service import JwtService
|
|
14
|
-
from .auth.auth_middleware import create_auth_dependency
|
|
14
|
+
from .auth.auth_middleware import create_auth_dependency, create_users_auth_dependency
|
|
15
15
|
from .auth.auth_controller import create_auth_router
|
|
16
16
|
from .controllers.status_controller import create_status_router
|
|
17
17
|
from .controllers.plugins_controller import create_plugins_router
|
|
@@ -36,6 +36,9 @@ class AdminBuildDeps:
|
|
|
36
36
|
builder: Any # CliBuilder
|
|
37
37
|
broadcast_fn: BroadcastFn | None = None
|
|
38
38
|
enabled_features: list[str] | None = None
|
|
39
|
+
#: When set, the dashboard authenticates through the users plugin
|
|
40
|
+
#: (``admin:access`` permission); static credentials are not used.
|
|
41
|
+
users_plugin: Any = None
|
|
39
42
|
|
|
40
43
|
|
|
41
44
|
@dataclass
|
|
@@ -59,6 +62,8 @@ class CliAdminBuilder:
|
|
|
59
62
|
self._password: str = ""
|
|
60
63
|
self._jwt_secret: str = ""
|
|
61
64
|
self._dashboard_path: str | None = None
|
|
65
|
+
self._trust_proxy = False
|
|
66
|
+
self._users_plugin: Any = None
|
|
62
67
|
|
|
63
68
|
def set_credentials(self, username: str, password: str) -> CliAdminBuilder:
|
|
64
69
|
"""Set admin login credentials (overrides env vars)."""
|
|
@@ -76,24 +81,50 @@ class CliAdminBuilder:
|
|
|
76
81
|
self._dashboard_path = path
|
|
77
82
|
return self
|
|
78
83
|
|
|
84
|
+
def set_trust_proxy(self, trust: bool | int = True) -> CliAdminBuilder:
|
|
85
|
+
"""Trust ``X-Forwarded-For`` for login rate limiting (behind a proxy only).
|
|
86
|
+
|
|
87
|
+
``False``/``0`` uses the socket peer, ``True`` = 1 trusted hop, an int
|
|
88
|
+
``n`` = ``n`` hops: the client is the entry at ``len - n`` (the
|
|
89
|
+
rightmost for one proxy), the leftmost if there are fewer entries.
|
|
90
|
+
"""
|
|
91
|
+
self._trust_proxy = trust
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def set_users_plugin(self, users_plugin: Any) -> CliAdminBuilder:
|
|
95
|
+
"""Authenticate the dashboard through the users plugin."""
|
|
96
|
+
self._users_plugin = users_plugin
|
|
97
|
+
return self
|
|
98
|
+
|
|
79
99
|
def build(self, deps: AdminBuildDeps) -> CliAdminPlugin:
|
|
80
100
|
"""Build the plugin, returning the router, auth dependency, and log buffer."""
|
|
81
101
|
start_time = time.time()
|
|
82
102
|
|
|
103
|
+
users_plugin = self._users_plugin or deps.users_plugin
|
|
83
104
|
config = AdminConfig(
|
|
84
105
|
username=self._username,
|
|
85
106
|
password=self._password,
|
|
86
107
|
jwt_secret=self._jwt_secret,
|
|
108
|
+
warn_default=users_plugin is None,
|
|
87
109
|
)
|
|
88
110
|
jwt_service = JwtService(secret=config.jwt_secret)
|
|
89
111
|
log_buffer = LogRingBuffer(broadcast_fn=deps.broadcast_fn)
|
|
90
112
|
log_buffer.install_handler()
|
|
91
113
|
module_registry = ModuleRegistry(deps.builder)
|
|
92
|
-
|
|
114
|
+
if users_plugin is not None:
|
|
115
|
+
auth_dep = create_users_auth_dependency(users_plugin)
|
|
116
|
+
allow = getattr(users_plugin, "allow_anonymous", None)
|
|
117
|
+
if callable(allow):
|
|
118
|
+
allow("/api/v1/qcli/auth/login", ["POST"])
|
|
119
|
+
else:
|
|
120
|
+
auth_dep = create_auth_dependency(jwt_service)
|
|
93
121
|
|
|
94
122
|
router = APIRouter()
|
|
95
123
|
|
|
96
|
-
auth_router = create_auth_router(
|
|
124
|
+
auth_router = create_auth_router(
|
|
125
|
+
config, jwt_service, auth_dependency=auth_dep,
|
|
126
|
+
trust_proxy=self._trust_proxy, users_plugin=users_plugin,
|
|
127
|
+
)
|
|
97
128
|
router.include_router(auth_router, prefix="/auth")
|
|
98
129
|
|
|
99
130
|
enabled_features: list[str] = list(deps.enabled_features or [])
|