kekkai-cli 1.1.0__py3-none-any.whl → 1.1.1__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.
portal/web.py DELETED
@@ -1,393 +0,0 @@
1
- """Portal WSGI web application with Kekkai theming.
2
-
3
- Provides:
4
- - Upload API endpoint (POST /api/v1/upload)
5
- - Dashboard with Kekkai branding
6
- - Static asset serving
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import json
12
- import logging
13
- import os
14
- import re
15
- from collections.abc import Callable, Iterable
16
- from pathlib import Path
17
- from typing import Any, BinaryIO, cast
18
-
19
- from jinja2 import Environment, FileSystemLoader, select_autoescape
20
-
21
- from .api import get_tenant_info, get_tenant_stats, list_uploads
22
- from .auth import authenticate_request
23
- from .tenants import Tenant, TenantStore
24
- from .uploads import process_upload, validate_upload
25
-
26
- try:
27
- from .enterprise import ENTERPRISE_AVAILABLE
28
- from .enterprise import rbac as enterprise_rbac
29
- from .enterprise import saml as enterprise_saml
30
- except ImportError:
31
- ENTERPRISE_AVAILABLE = False
32
- enterprise_saml = None # type: ignore[assignment]
33
- enterprise_rbac = None # type: ignore[assignment]
34
-
35
- logger = logging.getLogger(__name__)
36
-
37
- Environ = dict[str, Any]
38
- StartResponse = Callable[[str, list[tuple[str, str]]], Callable[[bytes], Any]]
39
-
40
- STATIC_DIR = Path(__file__).parent / "static"
41
- TEMPLATES_DIR = Path(__file__).parent / "templates"
42
-
43
- SECURE_HEADERS = [
44
- ("X-Content-Type-Options", "nosniff"),
45
- ("X-Frame-Options", "DENY"),
46
- ("X-XSS-Protection", "1; mode=block"),
47
- ("Referrer-Policy", "strict-origin-when-cross-origin"),
48
- (
49
- "Content-Security-Policy",
50
- "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:",
51
- ),
52
- ]
53
-
54
- MULTIPART_BOUNDARY_PATTERN = re.compile(r"boundary=([^\s;]+)", re.IGNORECASE)
55
-
56
-
57
- class PortalApp:
58
- """Kekkai Portal WSGI Application."""
59
-
60
- def __init__(self, tenant_store: TenantStore) -> None:
61
- self._tenant_store = tenant_store
62
- self._jinja_env = Environment(
63
- loader=FileSystemLoader(TEMPLATES_DIR),
64
- autoescape=select_autoescape(["html", "xml"]),
65
- )
66
-
67
- def __call__(self, environ: Environ, start_response: StartResponse) -> Iterable[bytes]:
68
- path = str(environ.get("PATH_INFO", "/"))
69
- method = str(environ.get("REQUEST_METHOD", "GET"))
70
-
71
- if path.startswith("/static/"):
72
- return self._serve_static(path, start_response)
73
-
74
- if path == "/api/v1/upload" and method == "POST":
75
- return self._handle_upload(environ, start_response)
76
-
77
- if path == "/api/v1/health":
78
- return self._handle_health(start_response)
79
-
80
- if path == "/api/v1/tenant/info" and method == "GET":
81
- return self._handle_tenant_info(environ, start_response)
82
-
83
- if path == "/api/v1/uploads" and method == "GET":
84
- return self._handle_list_uploads(environ, start_response)
85
-
86
- if path == "/api/v1/stats" and method == "GET":
87
- return self._handle_stats(environ, start_response)
88
-
89
- if path == "/" and method == "GET":
90
- return self._serve_dashboard(environ, start_response)
91
-
92
- return self._not_found(start_response)
93
-
94
- def _handle_upload(self, environ: Environ, start_response: StartResponse) -> Iterable[bytes]:
95
- """Handle file upload with authentication and validation."""
96
- client_ip = str(environ.get("REMOTE_ADDR", "unknown"))
97
- headers = _extract_headers(environ)
98
-
99
- auth_result = authenticate_request(headers, self._tenant_store, client_ip)
100
- if not auth_result.authenticated or not auth_result.tenant:
101
- return self._unauthorized(start_response, auth_result.error or "Unauthorized")
102
-
103
- tenant = auth_result.tenant
104
-
105
- content_type = headers.get("content-type", "")
106
- content_length = int(environ.get("CONTENT_LENGTH", 0) or 0)
107
-
108
- if content_length > tenant.max_upload_size_mb * 1024 * 1024:
109
- return self._error_response(
110
- start_response,
111
- 413,
112
- f"File too large. Maximum: {tenant.max_upload_size_mb}MB",
113
- )
114
-
115
- input_stream = environ.get("wsgi.input")
116
- if not input_stream or content_length == 0:
117
- return self._error_response(start_response, 400, "No file provided")
118
-
119
- body = cast(BinaryIO, input_stream).read(content_length)
120
-
121
- if "multipart/form-data" in content_type:
122
- filename, file_content = _parse_multipart(body, content_type)
123
- else:
124
- filename = headers.get("x-filename", "upload.json")
125
- file_content = body
126
-
127
- if not filename or not file_content:
128
- return self._error_response(start_response, 400, "Invalid upload")
129
-
130
- validation = validate_upload(filename, content_type, file_content, tenant)
131
- if not validation.success:
132
- return self._error_response(
133
- start_response, 400, validation.error or "Validation failed"
134
- )
135
-
136
- result = process_upload(filename, file_content, tenant)
137
- if not result.success:
138
- return self._error_response(start_response, 500, result.error or "Upload failed")
139
-
140
- logger.info(
141
- "upload.complete tenant=%s upload_id=%s",
142
- tenant.id,
143
- result.upload_id,
144
- )
145
-
146
- response_data = {
147
- "success": True,
148
- "upload_id": result.upload_id,
149
- "file_hash": result.file_hash,
150
- "tenant_id": tenant.id,
151
- "dojo_product_id": tenant.dojo_product_id,
152
- "dojo_engagement_id": tenant.dojo_engagement_id,
153
- }
154
-
155
- return self._json_response(start_response, 200, response_data)
156
-
157
- def _handle_health(self, start_response: StartResponse) -> Iterable[bytes]:
158
- """Health check endpoint."""
159
- return self._json_response(start_response, 200, {"status": "healthy"})
160
-
161
- def _handle_tenant_info(
162
- self, environ: Environ, start_response: StartResponse
163
- ) -> Iterable[bytes]:
164
- """Get current tenant information."""
165
- headers = _extract_headers(environ)
166
- client_ip = str(environ.get("REMOTE_ADDR", "unknown"))
167
-
168
- auth_result = authenticate_request(headers, self._tenant_store, client_ip)
169
- if not auth_result.authenticated or not auth_result.tenant:
170
- return self._unauthorized(start_response, auth_result.error or "Unauthorized")
171
-
172
- tenant_info = get_tenant_info(auth_result.tenant)
173
- return self._json_response(start_response, 200, tenant_info)
174
-
175
- def _handle_list_uploads(
176
- self, environ: Environ, start_response: StartResponse
177
- ) -> Iterable[bytes]:
178
- """List recent uploads for authenticated tenant."""
179
- headers = _extract_headers(environ)
180
- client_ip = str(environ.get("REMOTE_ADDR", "unknown"))
181
-
182
- auth_result = authenticate_request(headers, self._tenant_store, client_ip)
183
- if not auth_result.authenticated or not auth_result.tenant:
184
- return self._unauthorized(start_response, auth_result.error or "Unauthorized")
185
-
186
- # Parse limit parameter from query string
187
- query_string = str(environ.get("QUERY_STRING", ""))
188
- limit = 50
189
- if "limit=" in query_string:
190
- try:
191
- limit_str = query_string.split("limit=")[1].split("&")[0]
192
- limit = min(int(limit_str), 100) # Cap at 100
193
- except (ValueError, IndexError):
194
- pass
195
-
196
- uploads = list_uploads(auth_result.tenant, limit)
197
- return self._json_response(start_response, 200, {"uploads": uploads})
198
-
199
- def _handle_stats(self, environ: Environ, start_response: StartResponse) -> Iterable[bytes]:
200
- """Get statistics for authenticated tenant."""
201
- headers = _extract_headers(environ)
202
- client_ip = str(environ.get("REMOTE_ADDR", "unknown"))
203
-
204
- auth_result = authenticate_request(headers, self._tenant_store, client_ip)
205
- if not auth_result.authenticated or not auth_result.tenant:
206
- return self._unauthorized(start_response, auth_result.error or "Unauthorized")
207
-
208
- stats = get_tenant_stats(auth_result.tenant)
209
- return self._json_response(start_response, 200, stats)
210
-
211
- def _serve_dashboard(self, environ: Environ, start_response: StartResponse) -> Iterable[bytes]:
212
- """Serve the Kekkai-themed dashboard."""
213
- headers = _extract_headers(environ)
214
- client_ip = str(environ.get("REMOTE_ADDR", "unknown"))
215
-
216
- auth_result = authenticate_request(headers, self._tenant_store, client_ip)
217
- tenant = auth_result.tenant if auth_result.authenticated else None
218
-
219
- content = self._render_template(tenant)
220
- response_headers = [("Content-Type", "text/html; charset=utf-8")] + SECURE_HEADERS
221
- start_response("200 OK", response_headers)
222
- return [content.encode("utf-8")]
223
-
224
- def _render_template(self, tenant: Tenant | None) -> str:
225
- """Render dashboard or login template based on authentication."""
226
- if tenant:
227
- template = self._jinja_env.get_template("dashboard.html")
228
- return str(template.render(tenant=tenant.to_dict()))
229
- else:
230
- template = self._jinja_env.get_template("login.html")
231
- return str(template.render())
232
-
233
- def _serve_static(self, path: str, start_response: StartResponse) -> Iterable[bytes]:
234
- """Serve static assets with security checks."""
235
- relative_path = path.removeprefix("/static/")
236
- if ".." in relative_path or relative_path.startswith("/"):
237
- return self._not_found(start_response)
238
-
239
- file_path = STATIC_DIR / relative_path
240
- try:
241
- resolved = file_path.resolve()
242
- if not resolved.is_relative_to(STATIC_DIR.resolve()):
243
- return self._not_found(start_response)
244
- except (ValueError, OSError):
245
- return self._not_found(start_response)
246
-
247
- if not file_path.exists() or not file_path.is_file():
248
- return self._not_found(start_response)
249
-
250
- content_type = _get_content_type(file_path.suffix)
251
- content = file_path.read_bytes()
252
-
253
- response_headers = [("Content-Type", content_type)] + SECURE_HEADERS
254
- start_response("200 OK", response_headers)
255
- return [content]
256
-
257
- def _json_response(
258
- self,
259
- start_response: StartResponse,
260
- status_code: int,
261
- data: dict[str, Any],
262
- ) -> Iterable[bytes]:
263
- """Send a JSON response."""
264
- status = f"{status_code} {'OK' if status_code == 200 else 'Error'}"
265
- response_headers = [("Content-Type", "application/json")] + SECURE_HEADERS
266
- start_response(status, response_headers)
267
- return [json.dumps(data).encode("utf-8")]
268
-
269
- def _error_response(
270
- self,
271
- start_response: StartResponse,
272
- status_code: int,
273
- message: str,
274
- ) -> Iterable[bytes]:
275
- """Send an error response."""
276
- return self._json_response(
277
- start_response,
278
- status_code,
279
- {"success": False, "error": message},
280
- )
281
-
282
- def _unauthorized(self, start_response: StartResponse, message: str) -> Iterable[bytes]:
283
- """Send 401 Unauthorized response."""
284
- response_headers = [
285
- ("Content-Type", "application/json"),
286
- ("WWW-Authenticate", "Bearer"),
287
- ] + SECURE_HEADERS
288
- start_response("401 Unauthorized", response_headers)
289
- return [json.dumps({"success": False, "error": message}).encode("utf-8")]
290
-
291
- def _not_found(self, start_response: StartResponse) -> Iterable[bytes]:
292
- """Send 404 Not Found response."""
293
- return self._error_response(start_response, 404, "Not found")
294
-
295
-
296
- def create_app(tenant_store_path: Path | None = None) -> PortalApp:
297
- """Create a configured PortalApp instance."""
298
- store_path = tenant_store_path or Path(
299
- os.environ.get("PORTAL_TENANT_STORE", "/var/lib/kekkai-portal/tenants.json")
300
- )
301
- tenant_store = TenantStore(store_path)
302
- return PortalApp(tenant_store)
303
-
304
-
305
- def main() -> int:
306
- """Run the portal development server."""
307
- from wsgiref.simple_server import make_server
308
-
309
- host = os.environ.get("PORTAL_HOST", "127.0.0.1")
310
- port = int(os.environ.get("PORTAL_PORT", "8000"))
311
- tenant_store = os.environ.get("PORTAL_TENANT_STORE")
312
-
313
- store_path = Path(tenant_store) if tenant_store else None
314
- app = create_app(store_path)
315
-
316
- print(f"Starting Kekkai Portal on http://{host}:{port}")
317
- print("Press Ctrl+C to stop")
318
-
319
- with make_server(host, port, app) as httpd:
320
- try:
321
- httpd.serve_forever()
322
- except KeyboardInterrupt:
323
- print("\nShutting down...")
324
-
325
- return 0
326
-
327
-
328
- if __name__ == "__main__":
329
- import sys
330
-
331
- sys.exit(main())
332
-
333
-
334
- def _extract_headers(environ: Environ) -> dict[str, str]:
335
- """Extract HTTP headers from WSGI environ."""
336
- headers: dict[str, str] = {}
337
- for key, value in environ.items():
338
- if key.startswith("HTTP_"):
339
- header_name = key[5:].replace("_", "-").lower()
340
- headers[header_name] = str(value)
341
- elif key == "CONTENT_TYPE":
342
- headers["content-type"] = str(value)
343
- elif key == "CONTENT_LENGTH":
344
- headers["content-length"] = str(value)
345
- return headers
346
-
347
-
348
- def _parse_multipart(body: bytes, content_type: str) -> tuple[str | None, bytes | None]:
349
- """Parse multipart form data to extract file."""
350
- match = MULTIPART_BOUNDARY_PATTERN.search(content_type)
351
- if not match:
352
- return None, None
353
-
354
- boundary = match.group(1).encode()
355
- if boundary.startswith(b'"') and boundary.endswith(b'"'):
356
- boundary = boundary[1:-1]
357
-
358
- parts = body.split(b"--" + boundary)
359
- for part in parts:
360
- if b"Content-Disposition" not in part:
361
- continue
362
-
363
- header_end = part.find(b"\r\n\r\n")
364
- if header_end == -1:
365
- continue
366
-
367
- headers_raw = part[:header_end].decode("utf-8", errors="replace")
368
- content = part[header_end + 4 :]
369
-
370
- if content.endswith(b"\r\n"):
371
- content = content[:-2]
372
-
373
- filename_match = re.search(r'filename="([^"]+)"', headers_raw)
374
- if filename_match:
375
- return filename_match.group(1), content
376
-
377
- return None, None
378
-
379
-
380
- def _get_content_type(extension: str) -> str:
381
- """Get MIME type for file extension."""
382
- types = {
383
- ".css": "text/css",
384
- ".js": "application/javascript",
385
- ".png": "image/png",
386
- ".jpg": "image/jpeg",
387
- ".jpeg": "image/jpeg",
388
- ".svg": "image/svg+xml",
389
- ".ico": "image/x-icon",
390
- ".woff": "font/woff",
391
- ".woff2": "font/woff2",
392
- }
393
- return types.get(extension.lower(), "application/octet-stream")