coding-client 1.21.2__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.
Files changed (109) hide show
  1. coding_client/__init__.py +1 -0
  2. coding_client/api/__init__.py +19 -0
  3. coding_client/api/exception_handlers.py +158 -0
  4. coding_client/api/logging_setup.py +44 -0
  5. coding_client/api/metrics.py +40 -0
  6. coding_client/api/openapi_config.py +124 -0
  7. coding_client/api/request_logging.py +60 -0
  8. coding_client/api/validation.py +150 -0
  9. coding_client/auth/__init__.py +78 -0
  10. coding_client/auth/_default_tenant.py +132 -0
  11. coding_client/auth/_header_trust.py +98 -0
  12. coding_client/auth/_onbehalf.py +315 -0
  13. coding_client/auth/_trusted_networks.py +94 -0
  14. coding_client/auth/dependencies.py +61 -0
  15. coding_client/auth/middleware.py +445 -0
  16. coding_client/auth/models.py +83 -0
  17. coding_client/auth/outbound.py +137 -0
  18. coding_client/auth/rbac.py +21 -0
  19. coding_client/auth/rbac_coding.py +5 -0
  20. coding_client/auth/tenant.py +650 -0
  21. coding_client/auth/websocket.py +148 -0
  22. coding_client/clients/__init__.py +24 -0
  23. coding_client/clients/base.py +131 -0
  24. coding_client/clients/coding_ai.py +33 -0
  25. coding_client/clients/coding_analysis.py +36 -0
  26. coding_client/clients/coding_containers.py +45 -0
  27. coding_client/clients/coding_github.py +127 -0
  28. coding_client/clients/coding_realtime.py +127 -0
  29. coding_client/clients/coding_security.py +51 -0
  30. coding_client/clients/coding_testing.py +31 -0
  31. coding_client/clients/coding_workspaces.py +265 -0
  32. coding_client/config/__init__.py +12 -0
  33. coding_client/config/base_settings.py +61 -0
  34. coding_client/config/yaml_config_loader.py +172 -0
  35. coding_client/db/__init__.py +42 -0
  36. coding_client/db/db_types.py +122 -0
  37. coding_client/db/exceptions.py +59 -0
  38. coding_client/db/postgres_connection.py +572 -0
  39. coding_client/db/tenant_query_helper.py +417 -0
  40. coding_client/db_init.py +49 -0
  41. coding_client/exceptions/__init__.py +67 -0
  42. coding_client/exceptions/api_exceptions.py +395 -0
  43. coding_client/exceptions/pydantic_ai_exceptions.py +533 -0
  44. coding_client/interfaces/__init__.py +22 -0
  45. coding_client/interfaces/cache_interface.py +179 -0
  46. coding_client/interfaces/container_interface.py +49 -0
  47. coding_client/interfaces/docker_interface.py +283 -0
  48. coding_client/interfaces/graph_interface.py +28 -0
  49. coding_client/interfaces/security_interface.py +72 -0
  50. coding_client/interfaces/webhook_interface.py +67 -0
  51. coding_client/llm/__init__.py +54 -0
  52. coding_client/llm/base_agent.py +195 -0
  53. coding_client/llm/context.py +121 -0
  54. coding_client/llm/responses.py +102 -0
  55. coding_client/llm/routing.py +82 -0
  56. coding_client/llm/settings.py +102 -0
  57. coding_client/models/__init__.py +62 -0
  58. coding_client/models/agent.py +38 -0
  59. coding_client/models/ai_test_failure.py +745 -0
  60. coding_client/models/analysis.py +330 -0
  61. coding_client/models/architecture.py +685 -0
  62. coding_client/models/bug.py +128 -0
  63. coding_client/models/code_graph.py +317 -0
  64. coding_client/models/container.py +241 -0
  65. coding_client/models/core.py +878 -0
  66. coding_client/models/docker_container.py +104 -0
  67. coding_client/models/github.py +293 -0
  68. coding_client/models/logging.py +29 -0
  69. coding_client/models/package_manager.py +257 -0
  70. coding_client/models/pr_validation.py +440 -0
  71. coding_client/models/pydantic_ai.py +395 -0
  72. coding_client/models/repo_scanner.py +249 -0
  73. coding_client/models/responses.py +431 -0
  74. coding_client/models/security.py +185 -0
  75. coding_client/models/static_analysis.py +508 -0
  76. coding_client/models/system.py +48 -0
  77. coding_client/models/test_executor.py +204 -0
  78. coding_client/models/testing.py +673 -0
  79. coding_client/models/testing_config.py +200 -0
  80. coding_client/models/validation.py +127 -0
  81. coding_client/models_init.py +272 -0
  82. coding_client/telemetry.py +195 -0
  83. coding_client/testing.py +180 -0
  84. coding_client/types/__init__.py +130 -0
  85. coding_client/types/constraint_types.py +276 -0
  86. coding_client/types/core_types.py +112 -0
  87. coding_client/types/state_machine.py +443 -0
  88. coding_client/utils/__init__.py +86 -0
  89. coding_client/utils/archive_utils.py +387 -0
  90. coding_client/utils/ast_debug.py +45 -0
  91. coding_client/utils/async_timeout.py +364 -0
  92. coding_client/utils/constants.py +518 -0
  93. coding_client/utils/content_processing.py +326 -0
  94. coding_client/utils/database.py +916 -0
  95. coding_client/utils/encoding_helper.py +222 -0
  96. coding_client/utils/enum_helpers.py +166 -0
  97. coding_client/utils/file_operations.py +177 -0
  98. coding_client/utils/filesystem_ops.py +528 -0
  99. coding_client/utils/http_client.py +562 -0
  100. coding_client/utils/path_utils.py +471 -0
  101. coding_client/utils/profiling.py +421 -0
  102. coding_client/utils/response_builder.py +240 -0
  103. coding_client/utils/retry_helper.py +712 -0
  104. coding_client/utils/security_validation.py +423 -0
  105. coding_client-1.21.2.dist-info/METADATA +234 -0
  106. coding_client-1.21.2.dist-info/RECORD +109 -0
  107. coding_client-1.21.2.dist-info/WHEEL +5 -0
  108. coding_client-1.21.2.dist-info/licenses/LICENSE +201 -0
  109. coding_client-1.21.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ """coding-client: Shared library for coding-* microservices."""
@@ -0,0 +1,19 @@
1
+ """KAG-standard API utilities: error handling, request logging, OpenAPI config, root-logger setup."""
2
+
3
+ from .exception_handlers import register_exception_handlers
4
+ from .logging_setup import configure_logging
5
+ from .metrics import add_process_time_header
6
+ from .openapi_config import configure_openapi
7
+ from .request_logging import HealthCheckFilter, RequestLoggingMiddleware
8
+ from .validation import SecurityValidationException, ValidationMiddleware
9
+
10
+ __all__ = [
11
+ "HealthCheckFilter",
12
+ "RequestLoggingMiddleware",
13
+ "SecurityValidationException",
14
+ "ValidationMiddleware",
15
+ "add_process_time_header",
16
+ "configure_logging",
17
+ "configure_openapi",
18
+ "register_exception_handlers",
19
+ ]
@@ -0,0 +1,158 @@
1
+ """KAG-standard exception handlers.
2
+
3
+ Produces the nested ``{error: {code, message, timestamp, request_id, details}}``
4
+ response envelope used across all KAG and coding-* services.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import time
11
+ import traceback
12
+ import uuid
13
+ from collections.abc import Mapping
14
+
15
+ from fastapi import FastAPI, Request
16
+ from fastapi.exceptions import RequestValidationError
17
+ from starlette.exceptions import HTTPException
18
+ from starlette.responses import JSONResponse
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _get_request_id(request: Request) -> str:
24
+ """Get request_id from state (set by RequestLoggingMiddleware) or generate one."""
25
+ return getattr(request.state, "request_id", None) or str(uuid.uuid4())
26
+
27
+
28
+ def _timestamp() -> str:
29
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
30
+
31
+
32
+ def _error_response(
33
+ status_code: int,
34
+ *,
35
+ code: str,
36
+ message: str,
37
+ request_id: str,
38
+ details: object = None,
39
+ headers: dict[str, str] | Mapping[str, str] | None = None,
40
+ ) -> JSONResponse:
41
+ body: dict = {
42
+ "code": code,
43
+ "message": message,
44
+ "timestamp": _timestamp(),
45
+ "request_id": request_id,
46
+ }
47
+ if details is not None:
48
+ body["details"] = details
49
+ return JSONResponse(
50
+ status_code=status_code,
51
+ content={"error": body},
52
+ headers=headers,
53
+ )
54
+
55
+
56
+ def register_exception_handlers(app: FastAPI, *, debug: bool = False) -> None:
57
+ """Register KAG-standard exception handlers on *app*."""
58
+
59
+ async def _handle_http(request: Request, exc: Exception) -> JSONResponse:
60
+ http_exc = exc if isinstance(exc, HTTPException) else HTTPException(status_code=500)
61
+ request_id = _get_request_id(request)
62
+ if isinstance(http_exc.detail, dict):
63
+ message = http_exc.detail.get("message", str(http_exc.detail))
64
+ else:
65
+ message = str(http_exc.detail)
66
+ return _error_response(
67
+ http_exc.status_code,
68
+ code=f"HTTP_{http_exc.status_code}",
69
+ message=message,
70
+ request_id=request_id,
71
+ details=http_exc.detail.get("details") if isinstance(http_exc.detail, dict) else None,
72
+ headers=http_exc.headers if hasattr(http_exc, "headers") else None,
73
+ )
74
+
75
+ app.add_exception_handler(HTTPException, _handle_http)
76
+
77
+ @app.exception_handler(Exception)
78
+ async def _global(request: Request, exc: Exception) -> JSONResponse:
79
+ request_id = _get_request_id(request)
80
+
81
+ # HTTPException (includes BaseAPIException subclasses)
82
+ if isinstance(exc, HTTPException):
83
+ # Extract message from structured detail dicts
84
+ if isinstance(exc.detail, dict):
85
+ message = exc.detail.get("message", str(exc.detail))
86
+ else:
87
+ message = str(exc.detail)
88
+
89
+ return _error_response(
90
+ exc.status_code,
91
+ code=f"HTTP_{exc.status_code}",
92
+ message=message,
93
+ request_id=request_id,
94
+ details=exc.detail.get("details") if isinstance(exc.detail, dict) else None,
95
+ headers=exc.headers if hasattr(exc, "headers") else None,
96
+ )
97
+
98
+ # BaseExceptionGroup wrapping HTTPException (Python 3.11+ TaskGroups)
99
+ if isinstance(exc, BaseExceptionGroup):
100
+ for sub_exc in exc.exceptions:
101
+ if isinstance(sub_exc, HTTPException):
102
+ if isinstance(sub_exc.detail, dict):
103
+ message = sub_exc.detail.get("message", str(sub_exc.detail))
104
+ else:
105
+ message = str(sub_exc.detail)
106
+ return _error_response(
107
+ sub_exc.status_code,
108
+ code=f"HTTP_{sub_exc.status_code}",
109
+ message=message,
110
+ request_id=request_id,
111
+ headers=sub_exc.headers if hasattr(sub_exc, "headers") else None,
112
+ )
113
+ if isinstance(sub_exc, BaseExceptionGroup):
114
+ for nested in sub_exc.exceptions:
115
+ if isinstance(nested, HTTPException):
116
+ msg = str(nested.detail)
117
+ return _error_response(
118
+ nested.status_code,
119
+ code=f"HTTP_{nested.status_code}",
120
+ message=msg,
121
+ request_id=request_id,
122
+ headers=nested.headers if hasattr(nested, "headers") else None,
123
+ )
124
+
125
+ # Unhandled exception
126
+ logger.error(
127
+ "Unhandled exception [%s]: %s\n%s",
128
+ request_id,
129
+ exc,
130
+ traceback.format_exc(),
131
+ )
132
+ return _error_response(
133
+ 500,
134
+ code="INTERNAL_SERVER_ERROR",
135
+ message="An unexpected error occurred",
136
+ request_id=request_id,
137
+ details=str(exc) if debug else None,
138
+ )
139
+
140
+ @app.exception_handler(RequestValidationError)
141
+ async def _validation(request: Request, exc: RequestValidationError) -> JSONResponse:
142
+ request_id = _get_request_id(request)
143
+ validation_errors = [
144
+ {
145
+ "field": ".".join(str(loc) for loc in err.get("loc", [])),
146
+ "message": err.get("msg", ""),
147
+ "type": err.get("type", ""),
148
+ "input": err.get("input"),
149
+ }
150
+ for err in exc.errors()
151
+ ]
152
+ return _error_response(
153
+ 422,
154
+ code="HTTP_422",
155
+ message="Request validation failed",
156
+ request_id=request_id,
157
+ details=validation_errors,
158
+ )
@@ -0,0 +1,44 @@
1
+ """Root logger configuration for coding-* microservices.
2
+
3
+ Without ``logging.basicConfig()``, application module loggers
4
+ (``logger = logging.getLogger(__name__)``) get no handler from uvicorn's
5
+ default ``LOGGING_CONFIG`` -- which only configures the ``uvicorn.*`` loggers,
6
+ not the root. The result is that every ``logger.info(...)`` call from app
7
+ code is silently dropped (Python's "last resort" handler emits only WARNING+).
8
+
9
+ This module provides a single entry point that all services should call
10
+ before lifespan emits its first log line, so log format and level handling
11
+ stay consistent across the fleet.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+
19
+ __all__ = ["configure_logging"]
20
+
21
+ _DEFAULT_FORMAT = "%(asctime)s %(levelname)s [%(name)s] %(message)s"
22
+
23
+
24
+ def configure_logging(default_level: str = "INFO") -> None:
25
+ """Configure the root logger so ALL module loggers get a handler.
26
+
27
+ Reads ``LOG_LEVEL`` from the environment (defaulting to ``default_level``),
28
+ installs a stream handler with a consistent format, and uses
29
+ ``force=True`` to overwrite any handler uvicorn or another framework may
30
+ have already attached.
31
+
32
+ Call this once at startup, before any application code emits log records:
33
+
34
+ - For module-level FastAPI apps, call it at the top of ``main.py``,
35
+ before ``logger = logging.getLogger(__name__)``.
36
+ - For factory-style apps, call it as the first statement inside
37
+ ``create_app()``.
38
+ """
39
+ log_level = os.getenv("LOG_LEVEL", default_level).upper()
40
+ logging.basicConfig(
41
+ level=getattr(logging, log_level, logging.INFO),
42
+ format=_DEFAULT_FORMAT,
43
+ force=True,
44
+ )
@@ -0,0 +1,40 @@
1
+ """Request timing middleware for coding-* services.
2
+
3
+ Measures request duration, sets an ``X-Process-Time`` header, and emits a
4
+ structured ``request_complete`` log line for downstream aggregation. Uses
5
+ structured-log timing rather than Prometheus counters; an OpenTelemetry
6
+ metrics SDK can be wired in here if counter/histogram exports are needed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import time
13
+
14
+ from fastapi import Request, Response
15
+ from starlette.middleware.base import RequestResponseEndpoint
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ async def add_process_time_header(
21
+ request: Request,
22
+ call_next: RequestResponseEndpoint,
23
+ ) -> Response:
24
+ """Measure request duration and add ``X-Process-Time`` header."""
25
+ start = time.perf_counter()
26
+ response: Response = await call_next(request)
27
+ duration = time.perf_counter() - start
28
+ response.headers["X-Process-Time"] = f"{duration:.4f}"
29
+
30
+ # Structured log for downstream aggregation
31
+ logger.info(
32
+ "request_complete",
33
+ extra={
34
+ "method": request.method,
35
+ "path": request.url.path,
36
+ "status": getattr(response, "status_code", 0),
37
+ "duration_s": round(duration, 4),
38
+ },
39
+ )
40
+ return response
@@ -0,0 +1,124 @@
1
+ """KAG-standard OpenAPI schema customization.
2
+
3
+ Adds Bearer token security scheme and X-Tenant-ID header parameter
4
+ to the OpenAPI specification.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from collections.abc import Set
11
+
12
+ from fastapi import FastAPI
13
+ from fastapi.openapi.utils import get_openapi
14
+
15
+
16
+ def configure_openapi(
17
+ app: FastAPI,
18
+ *,
19
+ protected_prefixes: Set[str] | None = None,
20
+ public_endpoints: Set[str] | None = None,
21
+ default_tenant_id: str | None = None,
22
+ ) -> None:
23
+ """Replace app.openapi with a KAG-standard schema generator.
24
+
25
+ Args:
26
+ app: FastAPI application instance.
27
+ protected_prefixes: Route prefixes requiring Bearer auth (e.g. {"/api/graphs"}).
28
+ public_endpoints: Exact paths that are always public (e.g. {"/", "/health"}).
29
+ default_tenant_id: Default tenant ID shown in docs. When omitted it is
30
+ read from the ``DEFAULT_TENANT_ID`` env var; if that is unset too,
31
+ the header is documented as **required** with no default, because
32
+ there is none. It must not advertise a literal the service was never
33
+ configured with — the docs previously showed ``n8n-dev``, the stale
34
+ pre-uuid7 label form of a real tenant (see
35
+ ``coding_client.auth._default_tenant``).
36
+ """
37
+ if protected_prefixes is None:
38
+ protected_prefixes = set()
39
+ if public_endpoints is None:
40
+ public_endpoints = {
41
+ "/",
42
+ "/health",
43
+ "/health/ready",
44
+ "/health/live",
45
+ "/docs",
46
+ "/redoc",
47
+ "/openapi.json",
48
+ }
49
+ if default_tenant_id is None:
50
+ default_tenant_id = os.getenv("DEFAULT_TENANT_ID", "").strip() or None
51
+
52
+ app.openapi_schema = None # Reset to force regeneration
53
+
54
+ def custom_openapi():
55
+ if app.openapi_schema:
56
+ return app.openapi_schema
57
+
58
+ openapi_schema = get_openapi(
59
+ title=app.title,
60
+ version=app.version,
61
+ description=app.description,
62
+ routes=app.routes,
63
+ )
64
+
65
+ components = openapi_schema.setdefault("components", {})
66
+
67
+ # Bearer token security scheme
68
+ security_schemes = components.setdefault("securitySchemes", {})
69
+ security_schemes["BearerAuth"] = {
70
+ "type": "http",
71
+ "scheme": "bearer",
72
+ "bearerFormat": "JWT",
73
+ "description": "Enter your Bearer JWT token",
74
+ }
75
+
76
+ # Reusable tenant header parameter
77
+ parameters = components.setdefault("parameters", {})
78
+ tenant_header: dict = {
79
+ "name": "X-Tenant-ID",
80
+ "in": "header",
81
+ "required": default_tenant_id is None,
82
+ "description": (
83
+ "Tenant identifier (defaults to configured DEFAULT_TENANT_ID)."
84
+ if default_tenant_id is not None
85
+ else "Tenant identifier. Required: this service has no "
86
+ "DEFAULT_TENANT_ID configured, so there is no fallback tenant."
87
+ ),
88
+ "schema": {"type": "string"},
89
+ }
90
+ if default_tenant_id is not None:
91
+ tenant_header["schema"]["default"] = default_tenant_id
92
+ tenant_header["example"] = default_tenant_id
93
+ parameters.setdefault("TenantIdHeader", tenant_header)
94
+
95
+ # Apply security only to protected prefixes
96
+ for path, path_item in openapi_schema.get("paths", {}).items():
97
+ normalized_path = path.rstrip("/") or "/"
98
+ requires_auth = (
99
+ any(normalized_path.startswith(prefix) for prefix in protected_prefixes)
100
+ and normalized_path not in public_endpoints
101
+ )
102
+
103
+ for operation in path_item.values():
104
+ if not isinstance(operation, dict):
105
+ continue
106
+
107
+ if requires_auth:
108
+ operation.setdefault("security", [{"BearerAuth": []}])
109
+ else:
110
+ operation.pop("security", None)
111
+
112
+ # Add tenant header parameter to all operations
113
+ op_params = operation.setdefault("parameters", [])
114
+ has_tenant = any(
115
+ p.get("name", "").lower() == "x-tenant-id" and p.get("in") == "header"
116
+ for p in op_params
117
+ )
118
+ if not has_tenant:
119
+ op_params.append({"$ref": "#/components/parameters/TenantIdHeader"})
120
+
121
+ app.openapi_schema = openapi_schema
122
+ return app.openapi_schema
123
+
124
+ setattr(app, "openapi", custom_openapi)
@@ -0,0 +1,60 @@
1
+ """Request Logging Middleware (KAG standard).
2
+
3
+ Generates unique request IDs, logs request start/end with timing,
4
+ and adds X-Request-ID + X-Processing-Time response headers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import time
11
+ import uuid
12
+ from collections.abc import Callable
13
+
14
+ from starlette.middleware.base import BaseHTTPMiddleware
15
+ from starlette.requests import Request
16
+ from starlette.responses import Response
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _SKIP_LOG_PATHS = frozenset({"/health", "/healthz", "/ready", "/readyz"})
21
+
22
+
23
+ class HealthCheckFilter(logging.Filter):
24
+ """Filter out health check requests from uvicorn access logs."""
25
+
26
+ def filter(self, record: logging.LogRecord) -> bool:
27
+ return "/health" not in record.getMessage()
28
+
29
+
30
+ class RequestLoggingMiddleware(BaseHTTPMiddleware):
31
+ """Log requests and add tracking headers (KAG standard)."""
32
+
33
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
34
+ if request.url.path in _SKIP_LOG_PATHS:
35
+ return await call_next(request)
36
+
37
+ request_id: str = str(uuid.uuid4())
38
+ request.state.request_id = request_id
39
+
40
+ start_time: float = time.time()
41
+
42
+ logger.info("Request started: %s %s [%s]", request.method, request.url.path, request_id)
43
+
44
+ response: Response = await call_next(request)
45
+
46
+ process_time: float = (time.time() - start_time) * 1000
47
+
48
+ response.headers["X-Request-ID"] = request_id
49
+ response.headers["X-Processing-Time"] = f"{process_time:.2f}ms"
50
+
51
+ logger.info(
52
+ "Request completed: %s %s [%s] - Status: %s - Time: %.2fms",
53
+ request.method,
54
+ request.url.path,
55
+ request_id,
56
+ response.status_code,
57
+ process_time,
58
+ )
59
+
60
+ return response
@@ -0,0 +1,150 @@
1
+ """Request validation middleware for coding-* services.
2
+
3
+ Provides URL normalisation, control-character detection, and structured
4
+ error responses. Shared across services so each consumer can mount the
5
+ middleware via a thin re-export rather than maintaining its own copy.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+ import traceback
13
+ import uuid
14
+ from urllib.parse import unquote
15
+
16
+ from fastapi import HTTPException, Request, Response
17
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
18
+ from starlette.responses import JSONResponse
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class SecurityValidationException(HTTPException):
24
+ """HTTPException subclass for security validation failures."""
25
+
26
+ def __init__(
27
+ self,
28
+ status_code: int = 400,
29
+ detail: str = "Security validation failed",
30
+ is_security_error: bool = True,
31
+ error_code: str = "SECURITY_VALIDATION_ERROR",
32
+ ) -> None:
33
+ super().__init__(status_code=status_code, detail=detail)
34
+ self.is_security_error = is_security_error
35
+ self.error_code = error_code
36
+
37
+
38
+ # Paths excluded from deep validation (health checks, etc.)
39
+ _EXCLUDE_PATHS: frozenset[str] = frozenset(
40
+ {
41
+ "/health",
42
+ "/healthz",
43
+ "/ready",
44
+ "/readyz",
45
+ "/",
46
+ }
47
+ )
48
+
49
+ # Regex for dangerous control characters (NULL, 0x01-0x08, VT, FF, 0x0E-0x1F, DEL)
50
+ _CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
51
+
52
+
53
+ class ValidationMiddleware(BaseHTTPMiddleware):
54
+ """Pre-processes and validates incoming requests."""
55
+
56
+ def __init__(self, app, exclude_paths: frozenset[str] | None = None, **kwargs):
57
+ super().__init__(app, **kwargs)
58
+ self._exclude_paths = exclude_paths or _EXCLUDE_PATHS
59
+
60
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
61
+ path = request.url.path
62
+
63
+ # Skip validation for excluded paths
64
+ if path in self._exclude_paths:
65
+ return await call_next(request)
66
+
67
+ try:
68
+ # Normalise URL path
69
+ normalised = self._preprocess_request(path)
70
+ if normalised != path:
71
+ request.scope["path"] = normalised
72
+
73
+ # Check for dangerous control characters in URL
74
+ self._validate_request_security(request)
75
+
76
+ return await call_next(request)
77
+
78
+ except SecurityValidationException as exc:
79
+ return self._create_validation_error_response(exc)
80
+
81
+ except HTTPException as exc:
82
+ return self._create_http_error_response(exc)
83
+
84
+ except Exception as exc:
85
+ logger.error("Unexpected validation error: %s", exc, exc_info=True)
86
+ return self._create_system_error_response(exc)
87
+
88
+ # ── Helpers ────────────────────────────────────────────────────
89
+
90
+ @staticmethod
91
+ def _preprocess_request(path: str) -> str:
92
+ """Normalise the URL path (collapse slashes, decode, strip trailing slash)."""
93
+ path = re.sub(r"/+", "/", path)
94
+ try:
95
+ path = unquote(path)
96
+ except Exception:
97
+ logger.debug("URL unquote failed for path")
98
+ if len(path) > 1 and path.endswith("/"):
99
+ path = path.rstrip("/")
100
+ return path
101
+
102
+ def _validate_request_security(self, request: Request) -> None:
103
+ """Reject requests with dangerous control characters in the URL."""
104
+ raw_url = str(request.url)
105
+ if _CONTROL_CHAR_RE.search(raw_url):
106
+ raise SecurityValidationException(
107
+ status_code=400,
108
+ detail="Request contains invalid control characters",
109
+ error_code="CONTROL_CHAR_DETECTED",
110
+ )
111
+
112
+ @staticmethod
113
+ def _create_validation_error_response(exc: SecurityValidationException) -> JSONResponse:
114
+ return JSONResponse(
115
+ status_code=exc.status_code,
116
+ content={
117
+ "error": exc.detail,
118
+ "error_code": exc.error_code,
119
+ "status_code": exc.status_code,
120
+ },
121
+ )
122
+
123
+ @staticmethod
124
+ def _create_http_error_response(exc: HTTPException) -> JSONResponse:
125
+ return JSONResponse(
126
+ status_code=exc.status_code,
127
+ content={
128
+ "error": str(exc.detail),
129
+ "status_code": exc.status_code,
130
+ },
131
+ )
132
+
133
+ @staticmethod
134
+ def _create_system_error_response(exc: Exception) -> JSONResponse:
135
+ request_id = str(uuid.uuid4())
136
+ logger.error(
137
+ "System error [%s]: %s\n%s",
138
+ request_id,
139
+ exc,
140
+ traceback.format_exc(),
141
+ )
142
+ return JSONResponse(
143
+ status_code=500,
144
+ content={
145
+ "error": "Internal server error",
146
+ "error_code": "INTERNAL_ERROR",
147
+ "request_id": request_id,
148
+ "status_code": 500,
149
+ },
150
+ )
@@ -0,0 +1,78 @@
1
+ """Authentication, authorization, and tenant isolation for coding-client.
2
+
3
+ Provides:
4
+ - User model and auth dependencies for FastAPI routes
5
+ - Auth middleware for Istio header-based authentication
6
+ - RBAC helpers (require_role, require_access_coding)
7
+ - Tenant isolation middleware and dependencies
8
+ """
9
+
10
+ from ._header_trust import trust_tenant_header
11
+ from ._onbehalf import (
12
+ may_vouch,
13
+ onbehalf_subjects,
14
+ tenant_license_verified,
15
+ tenant_was_vouched,
16
+ )
17
+ from .dependencies import get_current_user
18
+ from .middleware import AuthMiddleware
19
+ from .models import User
20
+ from .outbound import (
21
+ LICENSE_ID_HEADER,
22
+ TENANT_ID_HEADER,
23
+ TenantMissingError,
24
+ optional_tenant_headers,
25
+ tenant_headers,
26
+ )
27
+ from .rbac import require_access_coding, require_role
28
+ from .tenant import (
29
+ TenantMiddleware,
30
+ check_tenant_access,
31
+ create_tenant_context,
32
+ get_current_user_with_tenant,
33
+ get_tenant_id,
34
+ optional_tenant,
35
+ require_same_tenant,
36
+ require_tenant,
37
+ require_tenant_context,
38
+ )
39
+ from .websocket import Outcome, WebSocketIdentity, resolve_websocket_identity
40
+
41
+ __all__ = [
42
+ # Models
43
+ "User",
44
+ # Dependencies
45
+ "get_current_user",
46
+ # Middleware
47
+ "AuthMiddleware",
48
+ # Header-trust posture (TRUST_TENANT_HEADER)
49
+ "trust_tenant_header",
50
+ # On-behalf-of vouching (CODING_ONBEHALF_SUBJECTS)
51
+ "may_vouch",
52
+ "onbehalf_subjects",
53
+ "tenant_license_verified",
54
+ "tenant_was_vouched",
55
+ # WebSocket identity (middleware never runs on a websocket scope)
56
+ "Outcome",
57
+ "WebSocketIdentity",
58
+ "resolve_websocket_identity",
59
+ # Outbound identity headers
60
+ "LICENSE_ID_HEADER",
61
+ "TENANT_ID_HEADER",
62
+ "TenantMissingError",
63
+ "optional_tenant_headers",
64
+ "tenant_headers",
65
+ "TenantMiddleware",
66
+ # RBAC
67
+ "require_access_coding",
68
+ "require_role",
69
+ # Tenant
70
+ "check_tenant_access",
71
+ "create_tenant_context",
72
+ "get_current_user_with_tenant",
73
+ "get_tenant_id",
74
+ "optional_tenant",
75
+ "require_same_tenant",
76
+ "require_tenant",
77
+ "require_tenant_context",
78
+ ]