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.
Files changed (26) hide show
  1. {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/PKG-INFO +2 -2
  2. {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/pyproject.toml +1 -1
  3. qodalis_cli_server_plugin_admin-1.0.0b7/qodalis_cli_admin/auth/auth_controller.py +188 -0
  4. {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
  5. {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
  6. qodalis_cli_server_plugin_admin-1.0.0b7/qodalis_cli_admin/dashboard/assets/index-BAmUWZCS.js +242 -0
  7. qodalis_cli_server_plugin_admin-1.0.0b7/qodalis_cli_admin/dashboard/assets/index-BF7ofZj2.css +32 -0
  8. qodalis_cli_server_plugin_admin-1.0.0b7/qodalis_cli_admin/dashboard/assets/wasm/qodalis_cli_wasm_bg.wasm +0 -0
  9. qodalis_cli_server_plugin_admin-1.0.0b7/qodalis_cli_admin/dashboard/index.html +14 -0
  10. {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
  11. {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
  12. {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
  13. qodalis_cli_server_plugin_admin-1.0.0b5/qodalis_cli_admin/auth/auth_controller.py +0 -92
  14. {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/.gitignore +0 -0
  15. {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/__init__.py +0 -0
  16. {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/auth/__init__.py +0 -0
  17. {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
  18. {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/controllers/__init__.py +0 -0
  19. {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
  20. {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
  21. {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
  22. {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
  23. {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
  24. {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/dashboard_resolver.py +0 -0
  25. {qodalis_cli_server_plugin_admin-1.0.0b5 → qodalis_cli_server_plugin_admin-1.0.0b7}/qodalis_cli_admin/services/__init__.py +0 -0
  26. {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
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: qodalis-cli-server-plugin-admin
3
- Version: 1.0.0b5
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
@@ -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.0b5"
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
- auth_dep = create_auth_dependency(jwt_service)
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(config, jwt_service, auth_dependency=auth_dep)
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 [])