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.
- coding_client/__init__.py +1 -0
- coding_client/api/__init__.py +19 -0
- coding_client/api/exception_handlers.py +158 -0
- coding_client/api/logging_setup.py +44 -0
- coding_client/api/metrics.py +40 -0
- coding_client/api/openapi_config.py +124 -0
- coding_client/api/request_logging.py +60 -0
- coding_client/api/validation.py +150 -0
- coding_client/auth/__init__.py +78 -0
- coding_client/auth/_default_tenant.py +132 -0
- coding_client/auth/_header_trust.py +98 -0
- coding_client/auth/_onbehalf.py +315 -0
- coding_client/auth/_trusted_networks.py +94 -0
- coding_client/auth/dependencies.py +61 -0
- coding_client/auth/middleware.py +445 -0
- coding_client/auth/models.py +83 -0
- coding_client/auth/outbound.py +137 -0
- coding_client/auth/rbac.py +21 -0
- coding_client/auth/rbac_coding.py +5 -0
- coding_client/auth/tenant.py +650 -0
- coding_client/auth/websocket.py +148 -0
- coding_client/clients/__init__.py +24 -0
- coding_client/clients/base.py +131 -0
- coding_client/clients/coding_ai.py +33 -0
- coding_client/clients/coding_analysis.py +36 -0
- coding_client/clients/coding_containers.py +45 -0
- coding_client/clients/coding_github.py +127 -0
- coding_client/clients/coding_realtime.py +127 -0
- coding_client/clients/coding_security.py +51 -0
- coding_client/clients/coding_testing.py +31 -0
- coding_client/clients/coding_workspaces.py +265 -0
- coding_client/config/__init__.py +12 -0
- coding_client/config/base_settings.py +61 -0
- coding_client/config/yaml_config_loader.py +172 -0
- coding_client/db/__init__.py +42 -0
- coding_client/db/db_types.py +122 -0
- coding_client/db/exceptions.py +59 -0
- coding_client/db/postgres_connection.py +572 -0
- coding_client/db/tenant_query_helper.py +417 -0
- coding_client/db_init.py +49 -0
- coding_client/exceptions/__init__.py +67 -0
- coding_client/exceptions/api_exceptions.py +395 -0
- coding_client/exceptions/pydantic_ai_exceptions.py +533 -0
- coding_client/interfaces/__init__.py +22 -0
- coding_client/interfaces/cache_interface.py +179 -0
- coding_client/interfaces/container_interface.py +49 -0
- coding_client/interfaces/docker_interface.py +283 -0
- coding_client/interfaces/graph_interface.py +28 -0
- coding_client/interfaces/security_interface.py +72 -0
- coding_client/interfaces/webhook_interface.py +67 -0
- coding_client/llm/__init__.py +54 -0
- coding_client/llm/base_agent.py +195 -0
- coding_client/llm/context.py +121 -0
- coding_client/llm/responses.py +102 -0
- coding_client/llm/routing.py +82 -0
- coding_client/llm/settings.py +102 -0
- coding_client/models/__init__.py +62 -0
- coding_client/models/agent.py +38 -0
- coding_client/models/ai_test_failure.py +745 -0
- coding_client/models/analysis.py +330 -0
- coding_client/models/architecture.py +685 -0
- coding_client/models/bug.py +128 -0
- coding_client/models/code_graph.py +317 -0
- coding_client/models/container.py +241 -0
- coding_client/models/core.py +878 -0
- coding_client/models/docker_container.py +104 -0
- coding_client/models/github.py +293 -0
- coding_client/models/logging.py +29 -0
- coding_client/models/package_manager.py +257 -0
- coding_client/models/pr_validation.py +440 -0
- coding_client/models/pydantic_ai.py +395 -0
- coding_client/models/repo_scanner.py +249 -0
- coding_client/models/responses.py +431 -0
- coding_client/models/security.py +185 -0
- coding_client/models/static_analysis.py +508 -0
- coding_client/models/system.py +48 -0
- coding_client/models/test_executor.py +204 -0
- coding_client/models/testing.py +673 -0
- coding_client/models/testing_config.py +200 -0
- coding_client/models/validation.py +127 -0
- coding_client/models_init.py +272 -0
- coding_client/telemetry.py +195 -0
- coding_client/testing.py +180 -0
- coding_client/types/__init__.py +130 -0
- coding_client/types/constraint_types.py +276 -0
- coding_client/types/core_types.py +112 -0
- coding_client/types/state_machine.py +443 -0
- coding_client/utils/__init__.py +86 -0
- coding_client/utils/archive_utils.py +387 -0
- coding_client/utils/ast_debug.py +45 -0
- coding_client/utils/async_timeout.py +364 -0
- coding_client/utils/constants.py +518 -0
- coding_client/utils/content_processing.py +326 -0
- coding_client/utils/database.py +916 -0
- coding_client/utils/encoding_helper.py +222 -0
- coding_client/utils/enum_helpers.py +166 -0
- coding_client/utils/file_operations.py +177 -0
- coding_client/utils/filesystem_ops.py +528 -0
- coding_client/utils/http_client.py +562 -0
- coding_client/utils/path_utils.py +471 -0
- coding_client/utils/profiling.py +421 -0
- coding_client/utils/response_builder.py +240 -0
- coding_client/utils/retry_helper.py +712 -0
- coding_client/utils/security_validation.py +423 -0
- coding_client-1.21.2.dist-info/METADATA +234 -0
- coding_client-1.21.2.dist-info/RECORD +109 -0
- coding_client-1.21.2.dist-info/WHEEL +5 -0
- coding_client-1.21.2.dist-info/licenses/LICENSE +201 -0
- 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
|
+
]
|