Skip to content

Commit a8c3e18

Browse files
authored
Merge pull request #175 from shamoo53/security-rotate-Prometheus/metrics-endpoint-behind-authentication
(security-rotate-Prometheus/metrics-endpoint-behind-authentication
2 parents 4a3363c + 2896e3a commit a8c3e18

File tree

6 files changed

+793
-77
lines changed

6 files changed

+793
-77
lines changed

docker-compose.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ services:
99
- PYTHONUNBUFFERED=1
1010
- QR_SIGNING_KEY=${QR_SIGNING_KEY:-test_signing_key}
1111
- DATABASE_URL=postgresql://veritix:veritix@db:5432/veritix
12+
- ADMIN_API_KEY=${ADMIN_API_KEY:-default_admin_secret_change_me}
1213
ports:
1314
- "8000:8000"
1415
command: uvicorn src.main:app --host 0.0.0.0 --port 8000
@@ -27,7 +28,44 @@ services:
2728
volumes:
2829
- db_data:/var/lib/postgresql/data
2930

31+
# Prometheus monitoring (optional)
32+
prometheus:
33+
image: prom/prometheus:latest
34+
container_name: veritix-prometheus
35+
ports:
36+
- "9090:9090"
37+
volumes:
38+
- ./prometheus.yml:/etc/prometheus/prometheus.yml
39+
command:
40+
- "--config.file=/etc/prometheus/prometheus.yml"
41+
- "--storage.tsdb.path=/prometheus"
42+
- "--web.console.libraries=/etc/prometheus/console_libraries"
43+
- "--web.console.templates=/etc/prometheus/consoles"
44+
depends_on:
45+
- app
46+
profiles: ["monitoring"]
47+
3048
volumes:
3149
db_data:
3250

51+
# Prometheus Configuration Example
52+
# Create a prometheus.yml file with the following content:
53+
#
54+
# global:
55+
# scrape_interval: 15s
56+
#
57+
# scrape_configs:
58+
# - job_name: 'veritix-metrics'
59+
# scheme: http
60+
# metrics_path: /metrics
61+
# static_configs:
62+
# - targets: ['app:8000']
63+
# authorization:
64+
# type: Bearer
65+
# credentials: ${ADMIN_API_KEY}
66+
#
67+
# Usage:
68+
# 1. Set ADMIN_API_KEY environment variable: export ADMIN_API_KEY="your-secure-admin-key"
69+
# 2. Start with monitoring profile: docker-compose --profile monitoring up
70+
# 3. Access Prometheus at http://localhost:9090
3371

src/analytics/service.py

Lines changed: 100 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -279,23 +279,42 @@ def get_stats_for_all_events(self) -> Dict[str, Dict[str, int]]:
279279
finally:
280280
session.close()
281281

282-
def get_recent_scans(self, event_id: str, limit: int = 100) -> List[Dict[str, Any]]:
283-
"""Get recent scan records for an event."""
282+
def get_recent_scans(self, event_id: str, from_ts: Optional[datetime] = None, to_ts: Optional[datetime] = None, page: int = 1, limit: int = 100) -> Dict[str, Any]:
283+
"""Get recent scan records for an event with date filtering and pagination."""
284284
try:
285285
session = get_session()
286286

287-
scans = session.query(TicketScan).filter(
288-
TicketScan.event_id == event_id
289-
).order_by(desc(TicketScan.scan_timestamp)).limit(limit).all()
290-
291-
return [{
292-
"id": scan.id,
293-
"ticket_id": scan.ticket_id,
294-
"scanner_id": scan.scanner_id,
295-
"scan_timestamp": scan.scan_timestamp.isoformat(),
296-
"is_valid": scan.is_valid,
297-
"location": scan.location
298-
} for scan in scans]
287+
# Build base query
288+
query = session.query(TicketScan).filter(TicketScan.event_id == event_id)
289+
290+
# Apply time filters
291+
if from_ts:
292+
query = query.filter(TicketScan.scan_timestamp >= from_ts)
293+
if to_ts:
294+
query = query.filter(TicketScan.scan_timestamp <= to_ts)
295+
296+
# Get total count for pagination
297+
total = query.count()
298+
299+
# Apply pagination
300+
offset = (page - 1) * limit
301+
scans = query.order_by(desc(TicketScan.scan_timestamp)).offset(offset).limit(limit).all()
302+
303+
return {
304+
"data": [{
305+
"id": scan.id,
306+
"ticket_id": scan.ticket_id,
307+
"scanner_id": scan.scanner_id,
308+
"scan_timestamp": scan.scan_timestamp.isoformat(),
309+
"is_valid": scan.is_valid,
310+
"location": scan.location
311+
} for scan in scans],
312+
"total": total,
313+
"page": page,
314+
"limit": limit,
315+
"from_ts": from_ts.isoformat() if from_ts else None,
316+
"to_ts": to_ts.isoformat() if to_ts else None
317+
}
299318

300319
except Exception as e:
301320
log_error("Failed to get recent scans", {
@@ -330,24 +349,43 @@ def get_scans_by_ticket_id(self, ticket_id: str, limit: int = 100) -> List[Dict[
330349
finally:
331350
session.close()
332351

333-
def get_recent_transfers(self, event_id: str, limit: int = 100) -> List[Dict[str, Any]]:
334-
"""Get recent transfer records for an event."""
352+
def get_recent_transfers(self, event_id: str, from_ts: Optional[datetime] = None, to_ts: Optional[datetime] = None, page: int = 1, limit: int = 100) -> Dict[str, Any]:
353+
"""Get recent transfer records for an event with date filtering and pagination."""
335354
try:
336355
session = get_session()
337356

338-
transfers = session.query(TicketTransfer).filter(
339-
TicketTransfer.event_id == event_id
340-
).order_by(desc(TicketTransfer.transfer_timestamp)).limit(limit).all()
341-
342-
return [{
343-
"id": transfer.id,
344-
"ticket_id": transfer.ticket_id,
345-
"from_user_id": transfer.from_user_id,
346-
"to_user_id": transfer.to_user_id,
347-
"transfer_timestamp": transfer.transfer_timestamp.isoformat(),
348-
"is_successful": transfer.is_successful,
349-
"transfer_reason": transfer.transfer_reason
350-
} for transfer in transfers]
357+
# Build base query
358+
query = session.query(TicketTransfer).filter(TicketTransfer.event_id == event_id)
359+
360+
# Apply time filters
361+
if from_ts:
362+
query = query.filter(TicketTransfer.transfer_timestamp >= from_ts)
363+
if to_ts:
364+
query = query.filter(TicketTransfer.transfer_timestamp <= to_ts)
365+
366+
# Get total count for pagination
367+
total = query.count()
368+
369+
# Apply pagination
370+
offset = (page - 1) * limit
371+
transfers = query.order_by(desc(TicketTransfer.transfer_timestamp)).offset(offset).limit(limit).all()
372+
373+
return {
374+
"data": [{
375+
"id": transfer.id,
376+
"ticket_id": transfer.ticket_id,
377+
"from_user_id": transfer.from_user_id,
378+
"to_user_id": transfer.to_user_id,
379+
"transfer_timestamp": transfer.transfer_timestamp.isoformat(),
380+
"is_successful": transfer.is_successful,
381+
"transfer_reason": transfer.transfer_reason
382+
} for transfer in transfers],
383+
"total": total,
384+
"page": page,
385+
"limit": limit,
386+
"from_ts": from_ts.isoformat() if from_ts else None,
387+
"to_ts": to_ts.isoformat() if to_ts else None
388+
}
351389

352390
except Exception as e:
353391
log_error("Failed to get recent transfers", {
@@ -358,23 +396,42 @@ def get_recent_transfers(self, event_id: str, limit: int = 100) -> List[Dict[str
358396
finally:
359397
session.close()
360398

361-
def get_invalid_attempts(self, event_id: str, limit: int = 100) -> List[Dict[str, Any]]:
362-
"""Get recent invalid attempt records for an event."""
399+
def get_invalid_attempts(self, event_id: str, from_ts: Optional[datetime] = None, to_ts: Optional[datetime] = None, page: int = 1, limit: int = 100) -> Dict[str, Any]:
400+
"""Get recent invalid attempt records for an event with date filtering and pagination."""
363401
try:
364402
session = get_session()
365403

366-
invalid_attempts = session.query(InvalidAttempt).filter(
367-
InvalidAttempt.event_id == event_id
368-
).order_by(desc(InvalidAttempt.attempt_timestamp)).limit(limit).all()
369-
370-
return [{
371-
"id": attempt.id,
372-
"attempt_type": attempt.attempt_type,
373-
"ticket_id": attempt.ticket_id,
374-
"attempt_timestamp": attempt.attempt_timestamp.isoformat(),
375-
"reason": attempt.reason,
376-
"ip_address": attempt.ip_address
377-
} for attempt in invalid_attempts]
404+
# Build base query
405+
query = session.query(InvalidAttempt).filter(InvalidAttempt.event_id == event_id)
406+
407+
# Apply time filters
408+
if from_ts:
409+
query = query.filter(InvalidAttempt.attempt_timestamp >= from_ts)
410+
if to_ts:
411+
query = query.filter(InvalidAttempt.attempt_timestamp <= to_ts)
412+
413+
# Get total count for pagination
414+
total = query.count()
415+
416+
# Apply pagination
417+
offset = (page - 1) * limit
418+
invalid_attempts = query.order_by(desc(InvalidAttempt.attempt_timestamp)).offset(offset).limit(limit).all()
419+
420+
return {
421+
"data": [{
422+
"id": attempt.id,
423+
"attempt_type": attempt.attempt_type,
424+
"ticket_id": attempt.ticket_id,
425+
"attempt_timestamp": attempt.attempt_timestamp.isoformat(),
426+
"reason": attempt.reason,
427+
"ip_address": attempt.ip_address
428+
} for attempt in invalid_attempts],
429+
"total": total,
430+
"page": page,
431+
"limit": limit,
432+
"from_ts": from_ts.isoformat() if from_ts else None,
433+
"to_ts": to_ts.isoformat() if to_ts else None
434+
}
378435

379436
except Exception as e:
380437
log_error("Failed to get invalid attempts", {

src/main.py

Lines changed: 106 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -215,12 +215,20 @@ def on_shutdown() -> None:
215215
def read_root() -> RootResponse:
216216
return RootResponse(message="Veritix Service is running. Check /health for status.")
217217

218-
219-
220218
@app.get("/metrics", response_class=PlainTextResponse, response_model=str)
221-
async def metrics_endpoint() -> PlainTextResponse:
222-
"""Prometheus metrics endpoint."""
223-
log_info("Metrics endpoint requested")
219+
async def metrics_endpoint(_: str = Depends(require_admin_key)) -> PlainTextResponse:
220+
"""Prometheus metrics endpoint (ADMIN)."""
221+
settings = get_settings()
222+
223+
# Return 503 if ADMIN_API_KEY is still the default value
224+
if settings.ADMIN_API_KEY == "default_admin_secret_change_me":
225+
return PlainTextResponse(
226+
content="503 Service Unavailable: ADMIN_API_KEY not configured. Please set a secure ADMIN_API_KEY environment variable to access metrics.",
227+
status_code=503,
228+
media_type="text/plain"
229+
)
230+
231+
log_info("Metrics endpoint requested (authenticated)")
224232
return PlainTextResponse(content=get_metrics(), media_type=get_metrics_content_type())
225233

226234

@@ -331,48 +339,119 @@ def get_analytics_stats(query: Annotated[AnalyticsStatsQuery, Query()]) -> Any:
331339

332340
@app.get("/stats/scans", response_model=AnalyticsScansResponse)
333341
def get_recent_scans(query: Annotated[AnalyticsListQuery, Query()]) -> AnalyticsScansResponse:
334-
"""Get recent scan records for an event."""
335-
event_id = query.event_id
336-
limit = query.limit
337-
log_info("Recent scans requested", {"event_id": event_id, "limit": limit})
342+
"""Get recent scan records for an event with date filtering and pagination."""
343+
log_info("Recent scans requested", {
344+
"event_id": query.event_id,
345+
"from_ts": query.from_ts.isoformat() if query.from_ts else None,
346+
"to_ts": query.to_ts.isoformat() if query.to_ts else None,
347+
"page": query.page,
348+
"limit": query.limit
349+
})
338350
try:
339-
scans = analytics_service.get_recent_scans(event_id, limit)
340-
log_info("Recent scans retrieved", {"event_id": event_id, "scan_count": len(scans)})
341-
return AnalyticsScansResponse(event_id=event_id, scans=scans, count=len(scans))
351+
result = analytics_service.get_recent_scans(
352+
event_id=query.event_id,
353+
from_ts=query.from_ts,
354+
to_ts=query.to_ts,
355+
page=query.page,
356+
limit=query.limit
357+
)
358+
log_info("Recent scans retrieved", {
359+
"event_id": query.event_id,
360+
"total": result["total"],
361+
"page": result["page"],
362+
"limit": result["limit"]
363+
})
364+
return AnalyticsScansResponse(
365+
event_id=query.event_id,
366+
data=result["data"],
367+
total=result["total"],
368+
page=result["page"],
369+
limit=result["limit"],
370+
from_ts=query.from_ts,
371+
to_ts=query.to_ts
372+
)
342373
except Exception as exc:
343-
log_error("Failed to retrieve recent scans", {"event_id": event_id, "error": str(exc)})
374+
log_error("Failed to retrieve recent scans", {"event_id": query.event_id, "error": str(exc)})
344375
raise HTTPException(status_code=500, detail=f"Failed to retrieve recent scans: {exc}")
345376

346377

347378
@app.get("/stats/transfers", response_model=AnalyticsTransfersResponse)
348379
def get_recent_transfers(
349380
query: Annotated[AnalyticsListQuery, Query()]
350381
) -> AnalyticsTransfersResponse:
351-
"""Get recent transfer records for an event."""
352-
event_id = query.event_id
353-
limit = query.limit
354-
log_info("Recent transfers requested", {"event_id": event_id, "limit": limit})
382+
"""Get recent transfer records for an event with date filtering and pagination."""
383+
log_info("Recent transfers requested", {
384+
"event_id": query.event_id,
385+
"from_ts": query.from_ts.isoformat() if query.from_ts else None,
386+
"to_ts": query.to_ts.isoformat() if query.to_ts else None,
387+
"page": query.page,
388+
"limit": query.limit
389+
})
355390
try:
356-
transfers = analytics_service.get_recent_transfers(event_id, limit)
357-
return AnalyticsTransfersResponse(event_id=event_id, transfers=transfers, count=len(transfers))
391+
result = analytics_service.get_recent_transfers(
392+
event_id=query.event_id,
393+
from_ts=query.from_ts,
394+
to_ts=query.to_ts,
395+
page=query.page,
396+
limit=query.limit
397+
)
398+
log_info("Recent transfers retrieved", {
399+
"event_id": query.event_id,
400+
"total": result["total"],
401+
"page": result["page"],
402+
"limit": result["limit"]
403+
})
404+
return AnalyticsTransfersResponse(
405+
event_id=query.event_id,
406+
data=result["data"],
407+
total=result["total"],
408+
page=result["page"],
409+
limit=result["limit"],
410+
from_ts=query.from_ts,
411+
to_ts=query.to_ts
412+
)
358413
except Exception as exc:
359-
log_error("Failed to retrieve recent transfers", {"event_id": event_id, "error": str(exc)})
414+
log_error("Failed to retrieve recent transfers", {"event_id": query.event_id, "error": str(exc)})
360415
raise HTTPException(status_code=500, detail=f"Failed to retrieve recent transfers: {exc}")
361416

362417

363418
@app.get("/stats/invalid-attempts", response_model=AnalyticsInvalidAttemptsResponse)
364419
def get_invalid_attempts(
365420
query: Annotated[AnalyticsListQuery, Query()]
366421
) -> AnalyticsInvalidAttemptsResponse:
367-
"""Get recent invalid scan attempt records for an event."""
368-
event_id = query.event_id
369-
limit = query.limit
370-
log_info("Invalid attempts requested", {"event_id": event_id, "limit": limit})
422+
"""Get recent invalid scan attempt records for an event with date filtering and pagination."""
423+
log_info("Invalid attempts requested", {
424+
"event_id": query.event_id,
425+
"from_ts": query.from_ts.isoformat() if query.from_ts else None,
426+
"to_ts": query.to_ts.isoformat() if query.to_ts else None,
427+
"page": query.page,
428+
"limit": query.limit
429+
})
371430
try:
372-
attempts = analytics_service.get_invalid_attempts(event_id, limit)
373-
return AnalyticsInvalidAttemptsResponse(event_id=event_id, attempts=attempts, count=len(attempts))
431+
result = analytics_service.get_invalid_attempts(
432+
event_id=query.event_id,
433+
from_ts=query.from_ts,
434+
to_ts=query.to_ts,
435+
page=query.page,
436+
limit=query.limit
437+
)
438+
log_info("Invalid attempts retrieved", {
439+
"event_id": query.event_id,
440+
"total": result["total"],
441+
"page": result["page"],
442+
"limit": result["limit"]
443+
})
444+
return AnalyticsInvalidAttemptsResponse(
445+
event_id=query.event_id,
446+
data=result["data"],
447+
total=result["total"],
448+
page=result["page"],
449+
limit=result["limit"],
450+
from_ts=query.from_ts,
451+
to_ts=query.to_ts
452+
)
374453
except Exception as exc:
375-
log_error("Failed to retrieve invalid attempts", {"event_id": event_id, "error": str(exc)})
454+
log_error("Failed to retrieve invalid attempts", {"event_id": query.event_id, "error": str(exc)})
376455
raise HTTPException(status_code=500, detail=f"Failed to retrieve invalid attempts: {exc}")
377456

378457

0 commit comments

Comments
 (0)