Skip to content

Commit 15e118d

Browse files
committed
UI fixes for adding user details
Signed-off-by: Shoumi <shoumimukherjee@gmail.com>
1 parent 076a66c commit 15e118d

17 files changed

+2007
-93
lines changed

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,16 @@ PAGINATION_INCLUDE_LINKS=true
11731173
# Enable TLS for gRPC connections by default
11741174
# MCPGATEWAY_GRPC_TLS_ENABLED=false
11751175

1176+
#####################################
1177+
# Security Event Logging
1178+
#####################################
1179+
1180+
# Enable security event logging (authentication attempts, authorization failures, etc.)
1181+
# Options: true (default), false
1182+
# When enabled, the AuthContextMiddleware will log all authentication attempts to the database
1183+
# This is INDEPENDENT of observability settings - security logging is critical for audit trails
1184+
# SECURITY_LOGGING_ENABLED=true
1185+
11761186
#####################################
11771187
# Observability Settings
11781188
#####################################

mcpgateway/admin.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8353,7 +8353,11 @@ async def admin_delete_resource(resource_id: str, request: Request, db: Session
83538353
LOGGER.debug(f"User {get_user_email(user)} is deleting resource ID {resource_id}")
83548354
error_message = None
83558355
try:
8356-
await resource_service.delete_resource(user["db"] if isinstance(user, dict) else db, resource_id)
8356+
await resource_service.delete_resource(
8357+
user["db"] if isinstance(user, dict) else db,
8358+
resource_id,
8359+
user_email=user_email,
8360+
)
83578361
except PermissionError as e:
83588362
LOGGER.warning(f"Permission denied for user {user_email} deleting resource {resource_id}: {e}")
83598363
error_message = str(e)
@@ -11425,6 +11429,7 @@ async def admin_test_a2a_agent(
1142511429
return JSONResponse(content={"success": False, "error": "A2A features are disabled"}, status_code=403)
1142611430

1142711431
try:
11432+
user_email = get_user_email(user)
1142811433
# Get the agent by ID
1142911434
agent = await a2a_service.get_agent(db, agent_id)
1143011435

@@ -11440,7 +11445,14 @@ async def admin_test_a2a_agent(
1144011445
test_params = {"message": "Hello from MCP Gateway Admin UI test!", "test": True, "timestamp": int(time.time())}
1144111446

1144211447
# Invoke the agent
11443-
result = await a2a_service.invoke_agent(db, agent.name, test_params, "admin_test")
11448+
result = await a2a_service.invoke_agent(
11449+
db,
11450+
agent.name,
11451+
test_params,
11452+
"admin_test",
11453+
user_email=user_email,
11454+
user_id=user_email,
11455+
)
1144411456

1144511457
return JSONResponse(content={"success": True, "result": result, "agent_name": agent.name, "test_timestamp": time.time()})
1144611458

mcpgateway/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,7 @@ def _parse_allowed_origins(cls, v: Any) -> Set[str]:
777777
metrics_aggregation_enabled: bool = Field(default=True, description="Enable automatic log aggregation into performance metrics")
778778
metrics_aggregation_backfill_hours: int = Field(default=6, ge=0, le=168, description="Hours of structured logs to backfill into performance metrics on startup")
779779
metrics_aggregation_window_minutes: int = Field(default=5, description="Time window for metrics aggregation (minutes)")
780+
metrics_aggregation_auto_start: bool = Field(default=False, description="Automatically run the log aggregation loop on application startup")
780781

781782
# Log Search Configuration
782783
log_search_max_results: int = Field(default=1000, description="Maximum results per log search query")

mcpgateway/main.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
452452
# Reconfigure uvicorn loggers after startup to capture access logs in dual output
453453
logging_service.configure_uvicorn_after_startup()
454454

455-
if settings.metrics_aggregation_enabled:
455+
if settings.metrics_aggregation_enabled and settings.metrics_aggregation_auto_start:
456456
aggregation_stop_event = asyncio.Event()
457457
log_aggregator = get_log_aggregator()
458458

@@ -491,6 +491,8 @@ async def run_log_aggregation_loop() -> None:
491491

492492
aggregation_backfill_task = asyncio.create_task(run_log_backfill())
493493
aggregation_loop_task = asyncio.create_task(run_log_aggregation_loop())
494+
elif settings.metrics_aggregation_enabled:
495+
logger.info("Metrics aggregation auto-start disabled; performance metrics will be generated on-demand when requested.")
494496

495497
yield
496498
except Exception as e:
@@ -1234,23 +1236,28 @@ async def _call_streamable_http(self, scope, receive, send):
12341236
app.add_middleware(CorrelationIDMiddleware)
12351237
logger.info(f"✅ Correlation ID tracking enabled (header: {settings.correlation_id_header})")
12361238

1239+
# Add authentication context middleware if security logging is enabled
1240+
# This middleware extracts user context and logs security events (authentication attempts)
1241+
# Note: This is independent of observability - security logging is always important
1242+
if settings.security_logging_enabled:
1243+
# First-Party
1244+
from mcpgateway.middleware.auth_middleware import AuthContextMiddleware
1245+
1246+
app.add_middleware(AuthContextMiddleware)
1247+
logger.info("🔐 Authentication context middleware enabled - logging security events")
1248+
else:
1249+
logger.info("🔐 Security event logging disabled")
1250+
12371251
# Add observability middleware if enabled
12381252
# Note: Middleware runs in REVERSE order (last added runs first)
1239-
# We add ObservabilityMiddleware first so it wraps AuthContextMiddleware
1253+
# If AuthContextMiddleware is already registered, ObservabilityMiddleware wraps it
12401254
# Execution order will be: AuthContext -> Observability -> Request Handler
12411255
if settings.observability_enabled:
12421256
# First-Party
12431257
from mcpgateway.middleware.observability_middleware import ObservabilityMiddleware
12441258

12451259
app.add_middleware(ObservabilityMiddleware, enabled=True)
12461260
logger.info("🔍 Observability middleware enabled - tracing all HTTP requests")
1247-
1248-
# Add authentication context middleware (runs BEFORE observability in execution)
1249-
# First-Party
1250-
from mcpgateway.middleware.auth_middleware import AuthContextMiddleware
1251-
1252-
app.add_middleware(AuthContextMiddleware)
1253-
logger.info("🔐 Authentication context middleware enabled - extracting user info for observability")
12541261
else:
12551262
logger.info("🔍 Observability middleware disabled")
12561263

@@ -2454,7 +2461,20 @@ async def invoke_a2a_agent(
24542461
logger.debug(f"User {user} is invoking A2A agent '{agent_name}' with type '{interaction_type}'")
24552462
if a2a_service is None:
24562463
raise HTTPException(status_code=503, detail="A2A service not available")
2457-
return await a2a_service.invoke_agent(db, agent_name, parameters, interaction_type)
2464+
user_email = get_user_email(user)
2465+
user_id = None
2466+
if isinstance(user, dict):
2467+
user_id = str(user.get("id") or user.get("sub") or user_email)
2468+
else:
2469+
user_id = str(user)
2470+
return await a2a_service.invoke_agent(
2471+
db,
2472+
agent_name,
2473+
parameters,
2474+
interaction_type,
2475+
user_id=user_id,
2476+
user_email=user_email,
2477+
)
24582478
except A2AAgentNotFoundError as e:
24592479
raise HTTPException(status_code=404, detail=str(e))
24602480
except A2AAgentError as e:

mcpgateway/middleware/auth_middleware.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,24 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response:
8787
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
8888
user = await get_current_user(credentials, db)
8989

90+
# Eagerly access user attributes before session closes to prevent DetachedInstanceError
91+
# This forces SQLAlchemy to load the data while the session is still active
92+
# Note: EmailUser uses 'email' as primary key, not 'id'
93+
user_email = user.email
94+
user_id = user_email # For EmailUser, email IS the ID
95+
96+
# Expunge the user from the session so it can be used after session closes
97+
# This makes the object detached but with all attributes already loaded
98+
db.expunge(user)
99+
90100
# Store user in request state for downstream use
91101
request.state.user = user
92-
logger.info(f"✓ Authenticated user for observability: {user.email}")
102+
logger.info(f"✓ Authenticated user: {user_email if user_email else user_id}")
93103

94104
# Log successful authentication
95105
security_logger.log_authentication_attempt(
96-
user_id=str(user.id),
97-
user_email=user.email,
106+
user_id=user_id,
107+
user_email=user_email,
98108
auth_method="bearer_token",
99109
success=True,
100110
client_ip=request.client.host if request.client else "unknown",

mcpgateway/middleware/request_logging_middleware.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@
1919
from typing import Callable
2020

2121
# Third-Party
22-
from fastapi import Request, Response
22+
from fastapi.security import HTTPAuthorizationCredentials
23+
from starlette.requests import Request
24+
from starlette.responses import Response
2325
from starlette.middleware.base import BaseHTTPMiddleware
2426

2527
# First-Party
28+
from mcpgateway.auth import get_current_user
29+
from mcpgateway.db import SessionLocal
2630
from mcpgateway.services.logging_service import LoggingService
2731
from mcpgateway.services.structured_logger import get_structured_logger
2832
from mcpgateway.utils.correlation_id import get_correlation_id
@@ -127,6 +131,44 @@ def __init__(self, app, enable_gateway_logging: bool = True, log_detailed_reques
127131
self.log_detailed_requests = log_detailed_requests
128132
self.log_level = log_level.upper()
129133
self.max_body_size = max_body_size # Expected to be in bytes
134+
135+
async def _resolve_user_identity(self, request: Request):
136+
"""Best-effort extraction of user identity for request logs."""
137+
# Prefer context injected by upstream middleware
138+
if hasattr(request.state, "user") and request.state.user is not None:
139+
raw_user_id = getattr(request.state.user, "id", None)
140+
user_email = getattr(request.state.user, "email", None)
141+
return (str(raw_user_id) if raw_user_id is not None else None, user_email)
142+
143+
# Fallback: try to authenticate using cookies/headers (matches AuthContextMiddleware)
144+
token = None
145+
if request.cookies:
146+
token = request.cookies.get("jwt_token") or request.cookies.get("access_token") or request.cookies.get("token")
147+
148+
if not token:
149+
auth_header = request.headers.get("authorization")
150+
if auth_header and auth_header.startswith("Bearer "):
151+
token = auth_header.replace("Bearer ", "")
152+
153+
if not token:
154+
return (None, None)
155+
156+
db = None
157+
try:
158+
db = SessionLocal()
159+
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
160+
user = await get_current_user(credentials, db)
161+
raw_user_id = getattr(user, "id", None)
162+
user_email = getattr(user, "email", None)
163+
return (str(raw_user_id) if raw_user_id is not None else None, user_email)
164+
except Exception:
165+
return (None, None)
166+
finally:
167+
if db:
168+
try:
169+
db.close()
170+
except Exception:
171+
pass
130172

131173
async def dispatch(self, request: Request, call_next: Callable):
132174
"""Process incoming request and log details with sensitive data masked.
@@ -147,6 +189,7 @@ async def dispatch(self, request: Request, call_next: Callable):
147189
method = request.method
148190
user_agent = request.headers.get("user-agent", "unknown")
149191
client_ip = request.client.host if request.client else "unknown"
192+
user_id, user_email = await self._resolve_user_identity(request)
150193

151194
# Skip boundary logging for health checks and static assets
152195
skip_paths = ["/health", "/healthz", "/static", "/favicon.ico"]
@@ -160,6 +203,8 @@ async def dispatch(self, request: Request, call_next: Callable):
160203
message=f"Request started: {method} {path}",
161204
component="gateway",
162205
correlation_id=correlation_id,
206+
user_email=user_email,
207+
user_id=user_id,
163208
operation_type="http_request",
164209
request_method=method,
165210
request_path=path,
@@ -187,6 +232,8 @@ async def dispatch(self, request: Request, call_next: Callable):
187232
message=f"Request completed: {method} {path} - {response.status_code}",
188233
component="gateway",
189234
correlation_id=correlation_id,
235+
user_email=user_email,
236+
user_id=user_id,
190237
operation_type="http_request",
191238
request_method=method,
192239
request_path=path,
@@ -295,6 +342,8 @@ async def receive():
295342
message=f"Request failed: {method} {path}",
296343
component="gateway",
297344
correlation_id=correlation_id,
345+
user_email=user_email,
346+
user_id=user_id,
298347
operation_type="http_request",
299348
request_method=method,
300349
request_path=path,
@@ -324,6 +373,8 @@ async def receive():
324373
message=f"Request completed: {method} {path} - {status_code}",
325374
component="gateway",
326375
correlation_id=correlation_id,
376+
user_email=user_email,
377+
user_id=user_id,
327378
operation_type="http_request",
328379
request_method=method,
329380
request_path=path,

0 commit comments

Comments
 (0)