qodalis-cli-server-plugin-admin 1.0.0b1__tar.gz → 1.0.0b6__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 (23) hide show
  1. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/PKG-INFO +2 -2
  2. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/pyproject.toml +1 -1
  3. qodalis_cli_server_plugin_admin-1.0.0b6/qodalis_cli_admin/auth/auth_controller.py +188 -0
  4. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/auth/auth_middleware.py +23 -3
  5. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/auth/jwt_service.py +5 -10
  6. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/cli_admin_builder.py +40 -9
  7. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/controllers/config_controller.py +2 -0
  8. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/dashboard_resolver.py +8 -10
  9. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/services/admin_config.py +10 -3
  10. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/services/log_ring_buffer.py +22 -5
  11. qodalis_cli_server_plugin_admin-1.0.0b6/qodalis_cli_admin/services/module_registry.py +113 -0
  12. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/spa_static_files.py +1 -1
  13. qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/auth/auth_controller.py +0 -103
  14. qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/services/module_registry.py +0 -64
  15. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/.gitignore +0 -0
  16. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/__init__.py +0 -0
  17. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/auth/__init__.py +0 -0
  18. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/controllers/__init__.py +0 -0
  19. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/controllers/logs_controller.py +0 -0
  20. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/controllers/plugins_controller.py +0 -0
  21. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/controllers/status_controller.py +0 -0
  22. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/controllers/ws_clients_controller.py +0 -0
  23. {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b6}/qodalis_cli_admin/services/__init__.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.0b1
3
+ Version: 1.0.0b6
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.0b1"
7
+ version = "1.0.0b6"
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
 
@@ -36,8 +58,6 @@ def create_auth_dependency(jwt_service: JwtService):
36
58
  return require_auth
37
59
 
38
60
 
39
- # Convenience alias — requires a JwtService instance to be set externally.
40
- # Prefer ``create_auth_dependency(jwt_service)`` for production use.
41
61
  async def require_auth(request: Request) -> dict[str, Any]: # noqa: F811
42
- """Placeholder — use :func:`create_auth_dependency` to create a real dependency."""
62
+ """No-op fallback; use ``create_auth_dependency`` to create a real dependency."""
43
63
  raise HTTPException(status_code=401, detail="Auth not configured")
@@ -33,12 +33,9 @@ class JwtService:
33
33
  ) -> str:
34
34
  """Create a signed JWT token.
35
35
 
36
- Parameters
37
- ----------
38
- payload:
39
- Claims to embed in the token.
40
- expires_in:
41
- Lifetime in seconds. Defaults to 24 hours.
36
+ Args:
37
+ payload: Claims to embed in the token.
38
+ expires_in: Lifetime in seconds. Defaults to 24 hours.
42
39
  """
43
40
  exp = int(time.time()) + (self.DEFAULT_EXPIRY_SECONDS if expires_in is None else expires_in)
44
41
  data = {**payload, "exp": exp, "iat": int(time.time())}
@@ -47,9 +44,7 @@ class JwtService:
47
44
  def verify_token(self, token: str) -> dict[str, Any]:
48
45
  """Decode and verify a JWT token.
49
46
 
50
- Raises
51
- ------
52
- jwt.InvalidTokenError
53
- If the token is invalid or expired.
47
+ Raises:
48
+ jwt.InvalidTokenError: If the token is invalid or expired.
54
49
  """
55
50
  return jwt.decode(token, self._secret, algorithms=["HS256"])
@@ -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,16 +36,22 @@ 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
42
45
  class CliAdminPlugin:
43
46
  """Result of building the admin plugin."""
44
47
 
48
+ prefix: str
49
+ dashboard_prefix: str
45
50
  router: APIRouter
46
51
  auth_dependency: Any
47
52
  log_buffer: LogRingBuffer
48
53
  dashboard_app: Any # ASGI app (SPA fallback) or None
54
+ module_registry: ModuleRegistry | None = None
49
55
 
50
56
 
51
57
  class CliAdminBuilder:
@@ -56,6 +62,8 @@ class CliAdminBuilder:
56
62
  self._password: str = ""
57
63
  self._jwt_secret: str = ""
58
64
  self._dashboard_path: str | None = None
65
+ self._trust_proxy = False
66
+ self._users_plugin: Any = None
59
67
 
60
68
  def set_credentials(self, username: str, password: str) -> CliAdminBuilder:
61
69
  """Set admin login credentials (overrides env vars)."""
@@ -73,30 +81,52 @@ class CliAdminBuilder:
73
81
  self._dashboard_path = path
74
82
  return self
75
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
+
76
99
  def build(self, deps: AdminBuildDeps) -> CliAdminPlugin:
77
100
  """Build the plugin, returning the router, auth dependency, and log buffer."""
78
101
  start_time = time.time()
79
102
 
80
- # Services
103
+ users_plugin = self._users_plugin or deps.users_plugin
81
104
  config = AdminConfig(
82
105
  username=self._username,
83
106
  password=self._password,
84
107
  jwt_secret=self._jwt_secret,
108
+ warn_default=users_plugin is None,
85
109
  )
86
110
  jwt_service = JwtService(secret=config.jwt_secret)
87
111
  log_buffer = LogRingBuffer(broadcast_fn=deps.broadcast_fn)
88
112
  log_buffer.install_handler()
89
113
  module_registry = ModuleRegistry(deps.builder)
90
- 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)
91
121
 
92
- # Build the top-level router
93
122
  router = APIRouter()
94
123
 
95
- # Auth routes (login is public, /me is protected)
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
- # Detect enabled features
100
130
  enabled_features: list[str] = list(deps.enabled_features or [])
101
131
 
102
132
  if "filesystem" not in enabled_features and (
@@ -112,7 +142,6 @@ class CliAdminBuilder:
112
142
  enabled_features.append("jobs")
113
143
  break
114
144
 
115
- # Protected routes
116
145
  status_router = create_status_router(
117
146
  start_time, deps.event_socket_manager, auth_dep, enabled_features,
118
147
  registry=deps.registry,
@@ -131,7 +160,6 @@ class CliAdminBuilder:
131
160
  ws_clients_router = create_ws_clients_router(deps.event_socket_manager, auth_dep)
132
161
  router.include_router(ws_clients_router)
133
162
 
134
- # Resolve dashboard SPA
135
163
  dashboard_dir = resolve_dashboard_dir(self._dashboard_path)
136
164
  dashboard_app = None
137
165
 
@@ -139,8 +167,11 @@ class CliAdminBuilder:
139
167
  dashboard_app = SPAStaticFiles(directory=dashboard_dir, html=True)
140
168
 
141
169
  return CliAdminPlugin(
170
+ prefix="/api/v1/qcli",
171
+ dashboard_prefix="/qcli/admin",
142
172
  router=router,
143
173
  auth_dependency=auth_dep,
144
174
  log_buffer=log_buffer,
145
175
  dashboard_app=dashboard_app,
176
+ module_registry=module_registry,
146
177
  )
@@ -11,6 +11,8 @@ from ..services.admin_config import AdminConfig
11
11
 
12
12
 
13
13
  class UpdateConfigRequest(BaseModel):
14
+ """Request body for updating admin configuration settings."""
15
+
14
16
  settings: dict[str, Any]
15
17
 
16
18
 
@@ -5,23 +5,22 @@ from pathlib import Path
5
5
 
6
6
 
7
7
  def resolve_dashboard_dir(explicit_path: str | None = None) -> str | None:
8
+ """Resolve the dashboard dist directory.
9
+
10
+ Searches in order: explicit path, bundled directory, npm package
11
+ (``node_modules/@qodalis/cli-server-dashboard/dist``), and relative
12
+ development paths.
13
+
14
+ Returns:
15
+ The absolute path to the dashboard directory, or ``None``.
8
16
  """
9
- Resolve the dashboard dist directory. Looks for:
10
- 1. Explicitly configured path
11
- 2. Bundled dashboard directory (embedded in this package)
12
- 3. node_modules/@qodalis/cli-server-dashboard/dist (npm package)
13
- 4. Relative development paths (sibling repo)
14
- """
15
- # 1. Explicit override
16
17
  if explicit_path and os.path.isdir(explicit_path):
17
18
  return os.path.abspath(explicit_path)
18
19
 
19
- # 2. Bundled dashboard directory (embedded in this package)
20
20
  bundled = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dashboard')
21
21
  if os.path.isdir(bundled):
22
22
  return bundled
23
23
 
24
- # 3. npm package — search upwards from cwd for node_modules
25
24
  current = os.getcwd()
26
25
  while current != os.path.dirname(current):
27
26
  candidate = os.path.join(
@@ -31,7 +30,6 @@ def resolve_dashboard_dir(explicit_path: str | None = None) -> str | None:
31
30
  return os.path.abspath(candidate)
32
31
  current = os.path.dirname(current)
33
32
 
34
- # 4. Relative development paths
35
33
  this_dir = os.path.dirname(os.path.abspath(__file__))
36
34
  dev_paths = [
37
35
  os.path.join(this_dir, "..", "..", "..", "..", "cli-server-dashboard", "dist"),
@@ -22,6 +22,7 @@ class AdminConfig:
22
22
  username: str = ""
23
23
  password: str = ""
24
24
  jwt_secret: str = ""
25
+ warn_default: bool = True
25
26
 
26
27
  # Mutable runtime settings
27
28
  _mutable_settings: dict[str, Any] = field(default_factory=dict)
@@ -38,7 +39,7 @@ class AdminConfig:
38
39
  if not self.jwt_secret:
39
40
  self.jwt_secret = os.environ.get("QCLI_ADMIN_JWT_SECRET", "")
40
41
 
41
- if self.username == "admin" and self.password == "admin":
42
+ if self.warn_default and self.username == "admin" and self.password == "admin":
42
43
  logger.warning(
43
44
  "Using default admin credentials. Set QCLI_ADMIN_USERNAME"
44
45
  " and QCLI_ADMIN_PASSWORD environment variables."
@@ -46,8 +47,13 @@ class AdminConfig:
46
47
 
47
48
  def validate_credentials(self, username: str, password: str) -> bool:
48
49
  """Return *True* if the supplied credentials match."""
49
- username_ok = hmac.compare_digest(username, self.username)
50
- password_ok = hmac.compare_digest(password, self.password)
50
+ # surrogatepass: a lone surrogate in the input must not raise (500)
51
+ username_ok = hmac.compare_digest(
52
+ username.encode("utf-8", "surrogatepass"), self.username.encode("utf-8", "surrogatepass"),
53
+ )
54
+ password_ok = hmac.compare_digest(
55
+ password.encode("utf-8", "surrogatepass"), self.password.encode("utf-8", "surrogatepass"),
56
+ )
51
57
  return username_ok and password_ok
52
58
 
53
59
  def get_config_sections(self) -> list[dict[str, Any]]:
@@ -105,6 +111,7 @@ class AdminConfig:
105
111
 
106
112
 
107
113
  def _infer_type(val: Any) -> str:
114
+ """Infer a JSON-schema-style type string from a Python value."""
108
115
  if isinstance(val, bool):
109
116
  return "boolean"
110
117
  if isinstance(val, (int, float)):
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ import re
6
7
  from collections import deque
7
8
  from dataclasses import dataclass, field
8
9
  from datetime import datetime, timezone
@@ -21,6 +22,20 @@ class LogEntry:
21
22
 
22
23
  BroadcastFn = Callable[[str], Awaitable[None]]
23
24
 
25
+ _REDACTIONS = [
26
+ # Also the URL-encoded ``Bearer%20<token>`` form (``_header_Authorization`` query).
27
+ (re.compile(r"(?i)(bearer(?:\s+|%20))[A-Za-z0-9._~+/=%-]+"), r"\1[redacted]"),
28
+ (re.compile(r"(?i)\b(token|ticket|password|secret|cmd)=([^&\s]+)"), r"\1=[redacted]"),
29
+ (re.compile(r'(?i)"(token|ticket|password|currentPassword|newPassword)"\s*:\s*"[^"]*"'), r'"\1": "[redacted]"'),
30
+ ]
31
+
32
+
33
+ def redact(message: str) -> str:
34
+ """Mask bearer tokens, tickets and passwords in a log message."""
35
+ for pattern, repl in _REDACTIONS:
36
+ message = pattern.sub(repl, message)
37
+ return message
38
+
24
39
  _LEVEL_MAP = {
25
40
  "WARNING": "WARN",
26
41
  }
@@ -47,8 +62,6 @@ class LogRingBuffer:
47
62
  self._broadcast_fn = broadcast_fn
48
63
  self._handler: _RingBufferHandler | None = None
49
64
 
50
- # -- public API -----------------------------------------------------------
51
-
52
65
  def add(self, entry: LogEntry) -> None:
53
66
  """Append a log entry to the buffer."""
54
67
  entry.level = (entry.level or "INFO").upper()
@@ -93,8 +106,6 @@ class LogRingBuffer:
93
106
  total,
94
107
  )
95
108
 
96
- # -- logging integration --------------------------------------------------
97
-
98
109
  def install_handler(self, logger_name: str = "") -> None:
99
110
  """Install a :class:`logging.Handler` that feeds into this buffer."""
100
111
  if self._handler is not None:
@@ -118,10 +129,16 @@ class _RingBufferHandler(logging.Handler):
118
129
  self._ring = ring
119
130
 
120
131
  def emit(self, record: logging.LogRecord) -> None:
132
+ """Convert a log record to a LogEntry and append it to the ring buffer.
133
+
134
+ Records flagged ``qcli_sensitive`` are skipped and tokens/passwords
135
+ are redacted."""
136
+ if getattr(record, "qcli_sensitive", False):
137
+ return
121
138
  entry = LogEntry(
122
139
  timestamp=datetime.now(timezone.utc).isoformat(),
123
140
  level=record.levelname,
124
- message=self.format(record),
141
+ message=redact(self.format(record)),
125
142
  logger_name=record.name,
126
143
  )
127
144
  self._ring.add(entry)
@@ -0,0 +1,113 @@
1
+ """Module registry — reads modules from CliBuilder and tracks enabled state.
2
+
3
+ Implements :class:`ICliProcessorFilter` so that processors belonging to
4
+ disabled modules are blocked at execution time.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, TYPE_CHECKING
10
+
11
+ from qodalis_cli_server_abstractions import ICliProcessorFilter
12
+
13
+ if TYPE_CHECKING:
14
+ from qodalis_cli_server_abstractions import ICliCommandProcessor
15
+ from qodalis_cli.extensions.cli_builder import CliBuilder
16
+
17
+
18
+ class ModuleRegistry(ICliProcessorFilter):
19
+ """Provides a read/toggle view over modules registered in :class:`CliBuilder`.
20
+
21
+ Also acts as a processor filter: processors that belong to a disabled
22
+ module are reported as *not allowed*, causing the executor to reject
23
+ the command with a user-friendly error.
24
+ """
25
+
26
+ def __init__(self, builder: CliBuilder) -> None:
27
+ self._builder = builder
28
+ self._disabled: set[str] = set()
29
+ # Map each processor instance to its module id for fast lookup.
30
+ self._processor_to_module: dict[int, str] = {}
31
+ self._rebuild_processor_map()
32
+
33
+ def _rebuild_processor_map(self) -> None:
34
+ """(Re-)build the processor-to-module-id mapping."""
35
+ self._processor_to_module.clear()
36
+ for idx, module in enumerate(self._builder.modules):
37
+ module_id = self._module_id(idx, module)
38
+ for processor in module.processors:
39
+ self._processor_to_module[id(processor)] = module_id
40
+
41
+ # -- ICliProcessorFilter ------------------------------------------------
42
+
43
+ def is_allowed(self, processor: ICliCommandProcessor) -> bool:
44
+ """Return ``False`` if the processor belongs to a disabled module."""
45
+ module_id = self._processor_to_module.get(id(processor))
46
+ if module_id is None:
47
+ # Processor not registered via a module — always allowed.
48
+ return True
49
+ return module_id not in self._disabled
50
+
51
+ # -- Public API ---------------------------------------------------------
52
+
53
+ def list(self) -> list[dict[str, Any]]:
54
+ """Return a list of all modules with their enabled/disabled state."""
55
+ return [
56
+ self._describe(idx, module, self._module_id(idx, module) not in self._disabled)
57
+ for idx, module in enumerate(self._builder.modules)
58
+ ]
59
+
60
+ def toggle(self, module_id: str) -> dict[str, Any]:
61
+ """Toggle a module's enabled state and return the updated record.
62
+
63
+ ``module_id`` is the module name (as listed); the legacy numeric index
64
+ is still accepted.
65
+ """
66
+ modules = self._builder.modules
67
+ idx = self._find(module_id)
68
+ if idx is None:
69
+ raise KeyError(f"Module not found: {module_id}")
70
+
71
+ module_key = self._module_id(idx, modules[idx])
72
+ if module_key in self._disabled:
73
+ self._disabled.discard(module_key)
74
+ enabled = True
75
+ else:
76
+ self._disabled.add(module_key)
77
+ enabled = False
78
+ return self._describe(idx, modules[idx], enabled)
79
+
80
+ # -- helpers ------------------------------------------------------------
81
+
82
+ def _module_id(self, idx: int, module: Any) -> str:
83
+ """Stable id: the module name (same as the node and .NET servers);
84
+ the index only when the name is missing or used twice."""
85
+ name = getattr(module, "name", None)
86
+ names = [getattr(m, "name", None) for m in self._builder.modules]
87
+ if isinstance(name, str) and name and names.count(name) == 1:
88
+ return name
89
+ return str(idx)
90
+
91
+ def _find(self, module_id: str) -> int | None:
92
+ modules = self._builder.modules
93
+ for idx, module in enumerate(modules):
94
+ if self._module_id(idx, module) == module_id:
95
+ return idx
96
+ if module_id.isdigit() and int(module_id) < len(modules):
97
+ return int(module_id)
98
+ return None
99
+
100
+ def _describe(self, idx: int, module: Any, enabled: bool) -> dict[str, Any]:
101
+ record: dict[str, Any] = {
102
+ "id": self._module_id(idx, module),
103
+ "name": getattr(module, "name", type(module).__name__),
104
+ "version": getattr(module, "version", None) or "",
105
+ "description": getattr(module, "description", None) or "",
106
+ "enabled": enabled,
107
+ "processorCount": len(module.processors),
108
+ "processors": [p.command for p in module.processors],
109
+ }
110
+ author = getattr(module, "author", None)
111
+ if author is not None:
112
+ record["author"] = getattr(author, "name", author) if not isinstance(author, str) else author
113
+ return record
@@ -24,12 +24,12 @@ class SPAStaticFiles(StaticFiles):
24
24
  """
25
25
 
26
26
  async def get_response(self, path: str, scope: Scope) -> Response:
27
+ """Return the static file or fall back to ``index.html`` for SPA routes."""
27
28
  try:
28
29
  return await super().get_response(path, scope)
29
30
  except StarletteHTTPException as exc:
30
31
  if exc.status_code != 404:
31
32
  raise
32
- # File not found — serve index.html for SPA client-side routing
33
33
  index = os.path.join(self.directory, "index.html") # type: ignore[arg-type]
34
34
  if os.path.isfile(index):
35
35
  return FileResponse(index, media_type="text/html")
@@ -1,103 +0,0 @@
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
- # ---------------------------------------------------------------------------
17
- # Request / Response models
18
- # ---------------------------------------------------------------------------
19
-
20
-
21
- class LoginRequest(BaseModel):
22
- username: str
23
- password: str
24
-
25
-
26
- class LoginResponse(BaseModel):
27
- token: str
28
- expiresIn: int
29
- username: str
30
-
31
-
32
- # ---------------------------------------------------------------------------
33
- # Rate limiting helpers
34
- # ---------------------------------------------------------------------------
35
-
36
- _MAX_ATTEMPTS = 5
37
- _WINDOW_SECONDS = 60
38
-
39
-
40
- class _RateLimiter:
41
- """Simple in-memory per-IP rate limiter for failed login attempts."""
42
-
43
- def __init__(self) -> None:
44
- self._attempts: dict[str, list[float]] = defaultdict(list)
45
-
46
- def check(self, ip: str) -> None:
47
- """Raise ``HTTPException(429)`` if the IP has exceeded the limit."""
48
- now = time.time()
49
- cutoff = now - _WINDOW_SECONDS
50
- # Clean old entries
51
- self._attempts[ip] = [t for t in self._attempts[ip] if t > cutoff]
52
- if len(self._attempts[ip]) >= _MAX_ATTEMPTS:
53
- raise HTTPException(status_code=429, detail="Too many failed login attempts. Try again later.")
54
-
55
- def record_failure(self, ip: str) -> None:
56
- self._attempts[ip].append(time.time())
57
-
58
-
59
- # ---------------------------------------------------------------------------
60
- # Router factory
61
- # ---------------------------------------------------------------------------
62
-
63
-
64
- def create_auth_router(
65
- admin_config: AdminConfig,
66
- jwt_service: JwtService,
67
- auth_dependency: Any = None,
68
- ) -> APIRouter:
69
- """Create a FastAPI router with ``POST /login`` and ``GET /me``."""
70
- router = APIRouter()
71
- limiter = _RateLimiter()
72
-
73
- @router.post("/login", response_model=LoginResponse)
74
- async def login(body: LoginRequest, request: Request) -> Any:
75
- # Check X-Forwarded-For header for real client IP behind proxy
76
- forwarded_for = request.headers.get("x-forwarded-for")
77
- if forwarded_for:
78
- client_ip = forwarded_for.split(",")[0].strip()
79
- else:
80
- client_ip = request.client.host if request.client else "127.0.0.1"
81
- limiter.check(client_ip)
82
-
83
- if not admin_config.validate_credentials(body.username, body.password):
84
- limiter.record_failure(client_ip)
85
- raise HTTPException(status_code=401, detail="Invalid credentials")
86
-
87
- expires_in = JwtService.DEFAULT_EXPIRY_SECONDS
88
- token = jwt_service.sign_token(
89
- {"username": body.username, "authenticatedAt": int(time.time())},
90
- expires_in=expires_in,
91
- )
92
-
93
- return LoginResponse(token=token, expiresIn=expires_in, username=body.username)
94
-
95
- if auth_dependency is not None:
96
- @router.get("/me")
97
- async def me(payload: dict[str, Any] = Depends(auth_dependency)) -> dict[str, Any]:
98
- return {
99
- "username": payload.get("username", ""),
100
- "authenticatedAt": payload.get("authenticatedAt"),
101
- }
102
-
103
- return router
@@ -1,64 +0,0 @@
1
- """Module registry — reads modules from CliBuilder and tracks enabled state."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Any, TYPE_CHECKING
6
-
7
- if TYPE_CHECKING:
8
- from qodalis_cli.extensions.cli_builder import CliBuilder
9
-
10
-
11
- class ModuleRegistry:
12
- """Provides a read/toggle view over modules registered in :class:`CliBuilder`."""
13
-
14
- def __init__(self, builder: CliBuilder) -> None:
15
- self._builder = builder
16
- self._disabled: set[str] = set()
17
-
18
- def list(self) -> list[dict[str, Any]]:
19
- """Return a list of all modules with their enabled/disabled state."""
20
- result: list[dict[str, Any]] = []
21
- for idx, module in enumerate(self._builder.modules):
22
- module_id = str(idx)
23
- name = getattr(module, "name", type(module).__name__)
24
- result.append(
25
- {
26
- "id": module_id,
27
- "name": name,
28
- "processorCount": len(module.processors),
29
- "processors": [p.command for p in module.processors],
30
- "enabled": module_id not in self._disabled,
31
- }
32
- )
33
- return result
34
-
35
- def toggle(self, module_id: str) -> dict[str, Any]:
36
- """Toggle a module's enabled state and return the updated record."""
37
- modules = self._builder.modules
38
- idx = int(module_id)
39
- if idx < 0 or idx >= len(modules):
40
- raise KeyError(f"Module not found: {module_id}")
41
-
42
- if module_id in self._disabled:
43
- self._disabled.discard(module_id)
44
- enabled = True
45
- else:
46
- self._disabled.add(module_id)
47
- enabled = False
48
-
49
- module = modules[idx]
50
- result: dict[str, Any] = {
51
- "id": module_id,
52
- "name": getattr(module, "name", type(module).__name__),
53
- "processorCount": len(module.processors),
54
- "processors": [p.command for p in module.processors],
55
- "enabled": enabled,
56
- }
57
-
58
- if not enabled:
59
- result["warning"] = (
60
- "Module state tracked but command unregistration is not yet"
61
- " supported. Processors remain active."
62
- )
63
-
64
- return result