Coverage for mcpgateway / db.py: 99%
2291 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 00:56 +0100
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 00:56 +0100
1# -*- coding: utf-8 -*-
2"""Location: ./mcpgateway/db.py
3Copyright 2025
4SPDX-License-Identifier: Apache-2.0
5Authors: Mihai Criveti
7ContextForge Database Models.
8This module defines SQLAlchemy models for storing MCP entities including:
9- Tools with input schema validation
10- Resources with subscription tracking
11- Prompts with argument templates
12- Federated gateways with capability tracking
13- Updated to record server associations independently using many-to-many relationships,
14- and to record tool execution metrics.
16Examples:
17 >>> from mcpgateway.db import connect_args
18 >>> isinstance(connect_args, dict)
19 True
20 >>> 'keepalives' in connect_args or 'check_same_thread' in connect_args or len(connect_args) == 0
21 True
22"""
24# Standard
25from contextlib import contextmanager
26from datetime import datetime, timedelta, timezone
27import logging
28import os
29from typing import Any, cast, Dict, Generator, List, Optional, TYPE_CHECKING
30import uuid
32# Third-Party
33import jsonschema
34from sqlalchemy import Boolean, Column, create_engine, DateTime, event, Float, ForeignKey, func, Index
35from sqlalchemy import inspect as sa_inspect
36from sqlalchemy import Integer, JSON, make_url, MetaData, select, String, Table, text, Text, UniqueConstraint
37from sqlalchemy.engine import Engine
38from sqlalchemy.event import listen
39from sqlalchemy.exc import OperationalError, ProgrammingError, SQLAlchemyError
40from sqlalchemy.ext.hybrid import hybrid_property
41from sqlalchemy.orm import DeclarativeBase, joinedload, Mapped, mapped_column, relationship, Session, sessionmaker
42from sqlalchemy.orm.attributes import get_history
43from sqlalchemy.pool import NullPool, QueuePool
44from sqlalchemy.types import TypeDecorator
46# First-Party
47from mcpgateway.common.validators import SecurityValidator
48from mcpgateway.config import settings
49from mcpgateway.utils.create_slug import slugify
50from mcpgateway.utils.db_isready import wait_for_db_ready
52logger = logging.getLogger(__name__)
54if TYPE_CHECKING:
55 # First-Party
56 from mcpgateway.common.models import ResourceContent
58# ResourceContent will be imported locally where needed to avoid circular imports
59# EmailUser models moved to this file to avoid circular imports
61# ---------------------------------------------------------------------------
62# 1. Parse the URL so we can inspect backend ("postgresql", "sqlite", ...)
63# and the specific driver ("psycopg", "asyncpg", empty string = default).
64# ---------------------------------------------------------------------------
65url = make_url(settings.database_url)
66backend = url.get_backend_name() # e.g. 'postgresql', 'sqlite'
67driver = url.get_driver_name() or "default"
69# Start with an empty dict and add options only when the driver can accept
70# them; this prevents unexpected TypeError at connect time.
71connect_args: dict[str, object] = {}
73# ---------------------------------------------------------------------------
74# 2. PostgreSQL (synchronous psycopg3)
75# The keep-alive parameters below are recognised by libpq and let the
76# kernel detect broken network links quickly.
77#
78# Additionally, support PostgreSQL-specific options like search_path
79# via the 'options' query parameter in DATABASE_URL.
80# Example: postgresql+psycopg://user:pass@host/db?options=-c%20search_path=mcp_gateway
81#
82# IMPORTANT: Use postgresql+psycopg:// (not postgresql://) for psycopg3.
83# ---------------------------------------------------------------------------
84if backend == "postgresql" and driver in ("psycopg", "default", ""):
85 connect_args.update(
86 keepalives=1, # enable TCP keep-alive probes
87 keepalives_idle=30, # seconds of idleness before first probe
88 keepalives_interval=5, # seconds between probes
89 keepalives_count=5, # drop the link after N failed probes
90 # psycopg3: prepare_threshold controls automatic server-side prepared statements
91 # After N executions of the same query, psycopg3 prepares it server-side
92 # This significantly improves performance for frequently-executed queries
93 prepare_threshold=settings.db_prepare_threshold,
94 )
96 # Extract and apply PostgreSQL options from URL query parameters
97 # This allows users to specify search_path for custom schema support (Issue #1535)
98 url_options = url.query.get("options")
99 if url_options:
100 connect_args["options"] = url_options
101 logger.info(f"PostgreSQL connection options applied: {url_options}")
103 logger.info(f"psycopg3 prepare_threshold set to {settings.db_prepare_threshold}")
105# ---------------------------------------------------------------------------
106# 3. SQLite (optional) - only one extra flag and it is *SQLite-specific*.
107# ---------------------------------------------------------------------------
108elif backend == "sqlite":
109 # Allow pooled connections to hop across threads.
110 connect_args["check_same_thread"] = False
112# 4. Other backends leave `connect_args` empty.
114# ---------------------------------------------------------------------------
115# 5. Build the Engine with a single, clean connect_args mapping.
116# ---------------------------------------------------------------------------
118# Check for SQLALCHEMY_ECHO environment variable for query debugging
119# This is useful for N+1 detection and performance analysis
120_sqlalchemy_echo = os.getenv("SQLALCHEMY_ECHO", "").lower() in ("true", "1", "yes")
123def build_engine() -> Engine:
124 """Build the SQLAlchemy engine with appropriate settings.
126 This function constructs the SQLAlchemy engine using the database URL
127 and connection arguments determined by the backend type. It also configures
128 the connection pool size and timeout based on application settings.
130 Environment variables:
131 SQLALCHEMY_ECHO: Set to 'true' to log all SQL queries (useful for N+1 detection)
133 Returns:
134 SQLAlchemy Engine instance configured for the specified database.
136 Raises:
137 ValueError: If the database backend is not postgresql or sqlite.
138 """
139 if _sqlalchemy_echo:
140 logger.info("SQLALCHEMY_ECHO enabled - all SQL queries will be logged")
142 if backend == "sqlite":
143 # SQLite supports connection pooling with proper configuration
144 # For SQLite, we use a smaller pool size since it's file-based
145 sqlite_pool_size = min(settings.db_pool_size, 50) # Cap at 50 for SQLite
146 sqlite_max_overflow = min(settings.db_max_overflow, 20) # Cap at 20 for SQLite
148 logger.info("Configuring SQLite with pool_size=%s, max_overflow=%s", sqlite_pool_size, sqlite_max_overflow)
150 return create_engine(
151 settings.database_url,
152 pool_pre_ping=True, # quick liveness check per checkout
153 pool_size=sqlite_pool_size,
154 max_overflow=sqlite_max_overflow,
155 pool_timeout=settings.db_pool_timeout,
156 pool_recycle=settings.db_pool_recycle,
157 # SQLite specific optimizations
158 poolclass=QueuePool, # Explicit pool class
159 connect_args=connect_args,
160 # Log pool events in debug mode
161 echo_pool=settings.log_level == "DEBUG",
162 # Log all SQL queries when SQLALCHEMY_ECHO=true (useful for N+1 detection)
163 echo=_sqlalchemy_echo,
164 )
166 if backend != "postgresql":
167 raise ValueError(f"Unsupported database backend: '{backend}'. Only 'postgresql' and 'sqlite' are supported.")
169 # Determine if PgBouncer is in use (detected via URL or explicit config)
170 is_pgbouncer = "pgbouncer" in settings.database_url.lower()
172 # Determine pool class based on configuration
173 # - "auto": NullPool with PgBouncer (recommended), QueuePool otherwise
174 # - "null": Always NullPool (delegate pooling to PgBouncer/external pooler)
175 # - "queue": Always QueuePool (application-side pooling)
176 use_null_pool = False
177 if settings.db_pool_class == "null":
178 use_null_pool = True
179 logger.info("Using NullPool (explicit configuration)")
180 elif settings.db_pool_class == "auto" and is_pgbouncer:
181 use_null_pool = True
182 logger.info("PgBouncer detected - using NullPool (recommended: let PgBouncer handle pooling)")
183 elif settings.db_pool_class == "queue":
184 logger.info("Using QueuePool (explicit configuration)")
185 else:
186 logger.info("Using QueuePool with pool_size=%s, max_overflow=%s", settings.db_pool_size, settings.db_max_overflow)
188 # Determine pre_ping setting
189 # - "auto": Enabled for non-PgBouncer with QueuePool, disabled otherwise
190 # - "true": Always enable (validates connections, catches stale connections)
191 # - "false": Always disable
192 if settings.db_pool_pre_ping == "true":
193 use_pre_ping = True
194 logger.info("pool_pre_ping enabled (explicit configuration)")
195 elif settings.db_pool_pre_ping == "false":
196 use_pre_ping = False
197 logger.info("pool_pre_ping disabled (explicit configuration)")
198 else: # "auto"
199 # With NullPool, pre_ping is not needed (no pooled connections to validate)
200 # With QueuePool + PgBouncer, pre_ping helps detect stale connections
201 use_pre_ping = not use_null_pool and not is_pgbouncer
202 if is_pgbouncer and not use_null_pool:
203 logger.info("PgBouncer with QueuePool - consider enabling DB_POOL_PRE_PING=true to detect stale connections")
205 # Build engine with appropriate pool configuration
206 if use_null_pool:
207 return create_engine(
208 settings.database_url,
209 poolclass=NullPool,
210 connect_args=connect_args,
211 echo=_sqlalchemy_echo,
212 )
214 return create_engine(
215 settings.database_url,
216 pool_pre_ping=use_pre_ping,
217 pool_size=settings.db_pool_size,
218 max_overflow=settings.db_max_overflow,
219 pool_timeout=settings.db_pool_timeout,
220 pool_recycle=settings.db_pool_recycle,
221 connect_args=connect_args,
222 echo=_sqlalchemy_echo,
223 )
226engine = build_engine()
228# Initialize SQLAlchemy instrumentation for observability
229if settings.observability_enabled:
230 try:
231 # First-Party
232 from mcpgateway.instrumentation import instrument_sqlalchemy
234 instrument_sqlalchemy(engine)
235 logger.info("SQLAlchemy instrumentation enabled for observability")
236 except ImportError:
237 logger.warning("Failed to import SQLAlchemy instrumentation")
240# ---------------------------------------------------------------------------
241# 6. Function to return UTC timestamp
242# ---------------------------------------------------------------------------
243def utc_now() -> datetime:
244 """Return the current Coordinated Universal Time (UTC).
246 Returns:
247 datetime: A timezone-aware `datetime` whose `tzinfo` is
248 `datetime.timezone.utc`.
250 Examples:
251 >>> from mcpgateway.db import utc_now
252 >>> now = utc_now()
253 >>> now.tzinfo is not None
254 True
255 >>> str(now.tzinfo)
256 'UTC'
257 >>> isinstance(now, datetime)
258 True
259 """
260 return datetime.now(timezone.utc)
263class TokenEncryptionWriteError(ValueError):
264 """Raised when OAuth token encryption fails during DB write binding."""
267class EncryptedText(TypeDecorator): # pylint: disable=too-many-ancestors
268 """Text type that applies best-effort encryption/decryption at ORM boundary.
270 This preserves compatibility with service-layer encryption:
271 - Pre-encrypted values pass through unchanged.
272 - Plaintext values are encrypted when possible before persistence.
273 - On read, encrypted values are decrypted for runtime usage.
274 """
276 impl = Text
277 cache_ok = True
279 @property
280 def python_type(self):
281 """Return the Python type represented by this SQLAlchemy type.
283 Returns:
284 type: Python ``str`` type.
285 """
286 return str
288 @staticmethod
289 def _get_encryption():
290 """Resolve encryption service for column-level token protection.
292 Returns:
293 Optional[EncryptionService]: Encryption service instance when configured,
294 otherwise ``None``.
295 """
296 secret = getattr(settings, "auth_encryption_secret", None)
297 if not secret:
298 return None
299 try:
300 # First-Party
301 from mcpgateway.services.encryption_service import get_encryption_service # pylint: disable=import-outside-toplevel
303 return get_encryption_service(secret)
304 except Exception as exc:
305 logger.debug("Unable to initialize encryption service for EncryptedText: %s", exc)
306 return None
308 def process_literal_param(self, value, _dialect): # pylint: disable=unused-argument
309 """Render literal SQL parameter value via encrypted bind processing.
311 Args:
312 value (Any): Raw value from SQLAlchemy.
313 _dialect: SQLAlchemy dialect (unused).
315 Returns:
316 Any: Bound parameter value after encryption handling.
317 """
318 processed = self.process_bind_param(value, _dialect)
319 return processed
321 def process_bind_param(self, value, _dialect): # pylint: disable=unused-argument
322 """Encrypt plaintext values before persistence when encryption is available.
324 Args:
325 value (Any): Raw value from SQLAlchemy.
326 _dialect: SQLAlchemy dialect (unused).
328 Returns:
329 Any: Encrypted value for persistence or unchanged value when no
330 encryption is applied.
332 Raises:
333 TokenEncryptionWriteError: If encryption is configured and token
334 encryption fails.
335 """
336 if value in (None, "") or not isinstance(value, str):
337 return value
339 encryption = self._get_encryption()
340 if not encryption:
341 return value
343 try:
344 if encryption.is_encrypted(value):
345 return value
346 return encryption.encrypt_secret(value)
347 except Exception as exc:
348 logger.warning("EncryptedText bind encryption failed; rejecting token write")
349 logger.debug("EncryptedText bind encryption exception: %s", exc)
350 raise TokenEncryptionWriteError("OAuth token encryption failed during write") from exc
352 def process_result_value(self, value, _dialect): # pylint: disable=unused-argument
353 """Decrypt stored encrypted values when reading rows.
355 Args:
356 value (Any): Raw value loaded from database.
357 _dialect: SQLAlchemy dialect (unused).
359 Returns:
360 Any: Decrypted value when encrypted, otherwise unchanged.
361 """
362 if value in (None, "") or not isinstance(value, str):
363 return value
365 encryption = self._get_encryption()
366 if not encryption:
367 return value
369 try:
370 if not encryption.is_encrypted(value):
371 return value
372 decrypted = encryption.decrypt_secret_or_plaintext(value)
373 return decrypted if decrypted is not None else value
374 except Exception as exc:
375 logger.warning("EncryptedText result decryption failed, returning stored value")
376 logger.debug("EncryptedText result decryption exception: %s", exc)
377 return value
380# Configure SQLite for better concurrency if using SQLite
381if backend == "sqlite":
383 @event.listens_for(engine, "connect")
384 def set_sqlite_pragma(dbapi_conn, _connection_record):
385 """Set SQLite pragmas for better concurrency.
387 This is critical for running with multiple gunicorn workers.
388 WAL mode allows multiple readers and a single writer concurrently.
390 Args:
391 dbapi_conn: The raw DBAPI connection.
392 _connection_record: A SQLAlchemy-specific object that maintains
393 information about the connection's context.
394 """
395 cursor = dbapi_conn.cursor()
396 # Enable WAL mode for better concurrency
397 cursor.execute("PRAGMA journal_mode=WAL")
398 # Configure SQLite lock wait upper bound (ms) to prevent prolonged blocking under contention
399 cursor.execute(f"PRAGMA busy_timeout={settings.db_sqlite_busy_timeout}")
400 # Synchronous=NORMAL is safe with WAL mode and improves performance
401 cursor.execute("PRAGMA synchronous=NORMAL")
402 # Increase cache size for better performance (negative value = KB)
403 cursor.execute("PRAGMA cache_size=-64000") # 64MB cache
404 # Enable foreign key constraints for ON DELETE CASCADE support
405 cursor.execute("PRAGMA foreign_keys=ON")
406 cursor.close()
409# ---------------------------------------------------------------------------
410# Resilient Session class for graceful error recovery
411# ---------------------------------------------------------------------------
412class ResilientSession(Session):
413 """A Session subclass that auto-rollbacks on connection errors.
415 When a database operation fails due to a connection error (e.g., PgBouncer
416 query_wait_timeout), this session automatically rolls back to clear the
417 invalid transaction state. This prevents cascading PendingRollbackError
418 failures when multiple queries run within the same request.
420 Without this, the first failed query leaves the session in a "needs rollback"
421 state, and all subsequent queries fail with PendingRollbackError before
422 even attempting to use the database.
423 """
425 # Error types that indicate connection issues requiring rollback
426 _connection_error_patterns = (
427 "query_wait_timeout",
428 "server closed the connection unexpectedly",
429 "connection reset by peer",
430 "connection timed out",
431 "could not receive data from server",
432 "could not send data to server",
433 "terminating connection",
434 "no connection to the server",
435 )
437 def _is_connection_error(self, exception: Exception) -> bool:
438 """Check if an exception indicates a broken database connection.
440 Args:
441 exception: The exception to check.
443 Returns:
444 True if the exception indicates a connection error, False otherwise.
445 """
446 exc_name = type(exception).__name__
447 exc_msg = str(exception).lower()
449 # Check for known connection error types
450 if exc_name in ("ProtocolViolation", "OperationalError", "InterfaceError"):
451 return True
453 # Check for connection error patterns in message
454 for pattern in self._connection_error_patterns:
455 if pattern in exc_msg:
456 return True
458 return False
460 def _safe_rollback(self) -> None:
461 """Attempt to rollback, invalidating the session if rollback fails."""
462 try:
463 self.rollback()
464 except Exception:
465 try:
466 self.invalidate()
467 except Exception:
468 pass # nosec B110 - Best effort cleanup on connection failure
470 def execute(self, statement, params=None, **kw):
471 """Execute a statement with automatic rollback on connection errors.
473 Wraps the parent execute method to catch connection errors and
474 automatically rollback the session to prevent PendingRollbackError cascade.
476 Args:
477 statement: The SQL statement to execute.
478 params: Optional parameters for the statement.
479 **kw: Additional keyword arguments passed to Session.execute().
481 Returns:
482 The result of the execute operation.
484 Raises:
485 Exception: Re-raises any exception after rolling back on connection errors.
486 """
487 try:
488 return super().execute(statement, params, **kw)
489 except Exception as e:
490 if self._is_connection_error(e):
491 logger.warning(
492 "Connection error during execute, auto-rolling back session: %s",
493 type(e).__name__,
494 )
495 self._safe_rollback()
496 raise
498 def scalar(self, statement, params=None, **kw):
499 """Execute and return a scalar with automatic rollback on connection errors.
501 Wraps the parent scalar method to catch connection errors and
502 automatically rollback the session to prevent PendingRollbackError cascade.
504 Args:
505 statement: The SQL statement to execute.
506 params: Optional parameters for the statement.
507 **kw: Additional keyword arguments passed to Session.scalar().
509 Returns:
510 The scalar result of the query.
512 Raises:
513 Exception: Re-raises any exception after rolling back on connection errors.
514 """
515 try:
516 return super().scalar(statement, params, **kw)
517 except Exception as e:
518 if self._is_connection_error(e):
519 logger.warning(
520 "Connection error during scalar, auto-rolling back session: %s",
521 type(e).__name__,
522 )
523 self._safe_rollback()
524 raise
526 def scalars(self, statement, params=None, **kw):
527 """Execute and return scalars with automatic rollback on connection errors.
529 Wraps the parent scalars method to catch connection errors and
530 automatically rollback the session to prevent PendingRollbackError cascade.
532 Args:
533 statement: The SQL statement to execute.
534 params: Optional parameters for the statement.
535 **kw: Additional keyword arguments passed to Session.scalars().
537 Returns:
538 The scalars result of the query.
540 Raises:
541 Exception: Re-raises any exception after rolling back on connection errors.
542 """
543 try:
544 return super().scalars(statement, params, **kw)
545 except Exception as e:
546 if self._is_connection_error(e):
547 logger.warning(
548 "Connection error during scalars, auto-rolling back session: %s",
549 type(e).__name__,
550 )
551 self._safe_rollback()
552 raise
555# Session factory using ResilientSession
556# expire_on_commit=False prevents SQLAlchemy from expiring ORM objects after commit,
557# allowing continued access to attributes without re-querying the database.
558# This is essential when commits happen during read operations (e.g., to release transactions).
559SessionLocal = sessionmaker(class_=ResilientSession, autocommit=False, autoflush=False, expire_on_commit=False, bind=engine)
562@event.listens_for(ResilientSession, "after_transaction_end")
563def end_transaction_cleanup(_session, _transaction):
564 """Ensure connection is properly released after transaction ends.
566 This event fires after COMMIT or ROLLBACK, ensuring the connection
567 is returned to PgBouncer cleanly with no open transaction.
569 Args:
570 _session: The SQLAlchemy session that ended the transaction.
571 _transaction: The transaction that was ended.
572 """
573 # The transaction has already ended - nothing to do here
574 # This is just for monitoring/logging if needed
577@event.listens_for(ResilientSession, "before_commit")
578def before_commit_handler(session):
579 """Handler before commit to ensure transaction is in good state.
581 This is called before COMMIT, ensuring any pending work is flushed.
582 If the flush fails, the exception is propagated so the commit also fails
583 and the caller's error handling (e.g. get_db rollback) can clean up properly.
585 Args:
586 session: The SQLAlchemy session about to commit.
587 """
588 session.flush()
591# ---------------------------------------------------------------------------
592# Pool event listeners for connection resilience
593# These handlers ensure broken connections are properly invalidated and
594# discarded from the pool, preventing "poisoned" connections from causing
595# cascading failures (e.g., PendingRollbackError after PgBouncer timeout).
596#
597# Key issue: PgBouncer returns ProtocolViolation (SQL error 08P01) for
598# query_wait_timeout, but SQLAlchemy doesn't recognize this as a disconnect
599# by default. We must explicitly mark these errors as disconnects so the
600# connection pool properly invalidates these connections.
601#
602# References:
603# - https://github.com/zodb/relstorage/issues/412
604# - https://docs.sqlalchemy.org/en/20/core/pooling.html#custom-legacy-pessimistic-ping
605# ---------------------------------------------------------------------------
606@event.listens_for(engine, "handle_error")
607def handle_pool_error(exception_context):
608 """Mark PgBouncer and connection errors as disconnects for proper pool invalidation.
610 This event fires when an error occurs during query execution. By marking
611 certain errors as disconnects (is_disconnect=True), SQLAlchemy will:
612 1. Invalidate the current connection (discard from pool)
613 2. Invalidate all other pooled connections older than current time
615 Without this, PgBouncer errors like query_wait_timeout result in
616 ProtocolViolation which is classified as DatabaseError, not a disconnect.
617 The connection stays in the pool and causes PendingRollbackError on reuse.
619 Args:
620 exception_context: SQLAlchemy ExceptionContext with error details.
621 """
622 original = exception_context.original_exception
623 if original is None:
624 return
626 # Get the exception class name and message for pattern matching
627 exc_class = type(original).__name__
628 exc_msg = str(original).lower()
630 # List of error patterns that indicate the connection is broken
631 # and should be treated as a disconnect for pool invalidation
632 disconnect_patterns = [
633 # PgBouncer errors
634 "query_wait_timeout",
635 "server_login_retry",
636 "client_login_timeout",
637 "client_idle_timeout",
638 "idle_transaction_timeout",
639 "server closed the connection unexpectedly",
640 "connection reset by peer",
641 "connection timed out",
642 "no connection to the server",
643 "terminating connection",
644 "connection has been closed unexpectedly",
645 # PostgreSQL errors indicating dead connection
646 "could not receive data from server",
647 "could not send data to server",
648 "ssl connection has been closed unexpectedly",
649 "canceling statement due to conflict with recovery",
650 ]
652 # Check for ProtocolViolation or OperationalError with disconnect patterns
653 is_connection_error = exc_class in ("ProtocolViolation", "OperationalError", "InterfaceError", "DatabaseError")
655 if is_connection_error:
656 for pattern in disconnect_patterns:
657 if pattern in exc_msg:
658 exception_context.is_disconnect = True
659 logger.warning(
660 "Connection error detected, marking as disconnect for pool invalidation: %s: %s",
661 exc_class,
662 pattern,
663 )
664 return
666 # Also treat ProtocolViolation from PgBouncer as disconnect even without message match
667 # PgBouncer sends 08P01 PROTOCOL_VIOLATION for various connection issues
668 if exc_class == "ProtocolViolation":
669 exception_context.is_disconnect = True
670 logger.warning(
671 "ProtocolViolation detected (likely PgBouncer), marking as disconnect: %s",
672 exc_msg[:200],
673 )
676@event.listens_for(engine, "checkin")
677def reset_connection_on_checkin(dbapi_connection, _connection_record):
678 """Reset connection state when returned to pool.
680 This ensures transactions are properly closed before the connection
681 is returned to PgBouncer, preventing 'idle in transaction' buildup.
682 With PgBouncer in transaction mode, connections stays reserved until
683 the transaction ends - this rollback releases them immediately.
685 Args:
686 dbapi_connection: The raw DBAPI connection being checked in.
687 _connection_record: The connection record tracking this connection.
688 """
689 try:
690 # Issue a rollback to close any open transaction
691 # This is safe for both read and write operations:
692 # - For reads: rollback has no effect but closes the transaction
693 # - For writes: they should already be committed by the application
694 dbapi_connection.rollback()
695 except Exception as e:
696 # Connection may be invalid - log and try to force close
697 logger.debug("Connection checkin rollback failed: %s", e)
698 try:
699 # Try to close the raw connection to release it from PgBouncer
700 dbapi_connection.close()
701 except Exception: # nosec B110
702 pass # Nothing more we can do
705@event.listens_for(engine, "reset")
706def reset_connection_on_reset(dbapi_connection, _connection_record, _reset_state):
707 """Reset connection state when the pool resets a connection.
709 This handles the case where a connection is being reset before reuse.
711 Args:
712 dbapi_connection: The raw DBAPI connection being reset.
713 _connection_record: The connection record tracking this connection.
714 """
715 try:
716 dbapi_connection.rollback()
717 except Exception: # nosec B110
718 pass # Connection may be invalid
721def _refresh_gateway_slugs_batched(session: Session, batch_size: int) -> None:
722 """Refresh gateway slugs in small batches to reduce memory usage.
724 Args:
725 session: Active SQLAlchemy session.
726 batch_size: Maximum number of rows to process per batch.
727 """
729 last_id: Optional[str] = None
731 while True:
732 query = session.query(Gateway).order_by(Gateway.id)
733 if last_id is not None:
734 query = query.filter(Gateway.id > last_id)
736 gateways = query.limit(batch_size).all()
737 if not gateways:
738 break
740 updated = False
741 for gateway in gateways:
742 new_slug = slugify(gateway.name)
743 if gateway.slug != new_slug:
744 gateway.slug = new_slug
745 updated = True
747 if updated:
748 session.commit()
750 # Free ORM state from memory between batches
751 session.expire_all()
752 last_id = gateways[-1].id
755def _refresh_tool_names_batched(session: Session, batch_size: int) -> None:
756 """Refresh tool names in batches with eager-loaded gateways.
758 Uses joinedload(Tool.gateway) to avoid N+1 queries when accessing the
759 gateway relationship while regenerating tool names.
761 Args:
762 session: Active SQLAlchemy session.
763 batch_size: Maximum number of rows to process per batch.
764 """
766 last_id: Optional[str] = None
767 separator = settings.gateway_tool_name_separator
769 while True:
770 stmt = select(Tool).options(joinedload(Tool.gateway)).order_by(Tool.id).limit(batch_size)
771 if last_id is not None:
772 stmt = stmt.where(Tool.id > last_id)
774 tools = session.execute(stmt).scalars().all()
775 if not tools:
776 break
778 updated = False
779 for tool in tools:
780 # Prefer custom_name_slug when available; fall back to original_name
781 name_slug_source = getattr(tool, "custom_name_slug", None) or tool.original_name
782 name_slug = slugify(name_slug_source)
784 if tool.gateway:
785 gateway_slug = slugify(tool.gateway.name)
786 new_name = f"{gateway_slug}{separator}{name_slug}"
787 else:
788 new_name = name_slug
790 if tool.name != new_name:
791 tool.name = new_name
792 updated = True
794 if updated:
795 session.commit()
797 # Free ORM state from memory between batches
798 session.expire_all()
799 last_id = tools[-1].id
802def _refresh_prompt_names_batched(session: Session, batch_size: int) -> None:
803 """Refresh prompt names in batches with eager-loaded gateways.
805 Uses joinedload(Prompt.gateway) to avoid N+1 queries when accessing the
806 gateway relationship while regenerating prompt names.
808 Args:
809 session: Active SQLAlchemy session.
810 batch_size: Maximum number of rows to process per batch.
811 """
812 last_id: Optional[str] = None
813 separator = settings.gateway_tool_name_separator
815 while True:
816 stmt = select(Prompt).options(joinedload(Prompt.gateway)).order_by(Prompt.id).limit(batch_size)
817 if last_id is not None:
818 stmt = stmt.where(Prompt.id > last_id)
820 prompts = session.execute(stmt).scalars().all()
821 if not prompts:
822 break
824 updated = False
825 for prompt in prompts:
826 name_slug_source = getattr(prompt, "custom_name_slug", None) or prompt.original_name
827 name_slug = slugify(name_slug_source)
829 if prompt.gateway:
830 gateway_slug = slugify(prompt.gateway.name)
831 new_name = f"{gateway_slug}{separator}{name_slug}"
832 else:
833 new_name = name_slug
835 if prompt.name != new_name:
836 prompt.name = new_name
837 updated = True
839 if updated:
840 session.commit()
842 session.expire_all()
843 last_id = prompts[-1].id
846def refresh_slugs_on_startup(batch_size: Optional[int] = None) -> None:
847 """Refresh slugs for all gateways and tool names on startup.
849 This implementation avoids loading all rows into memory at once by
850 streaming through the tables in batches and eager-loading tool.gateway
851 relationships to prevent N+1 query patterns.
853 Args:
854 batch_size: Optional maximum number of rows to process per batch. If
855 not provided, the value is taken from
856 ``settings.slug_refresh_batch_size`` with a default of ``1000``.
857 """
859 effective_batch_size = batch_size or getattr(settings, "slug_refresh_batch_size", 1000)
861 try:
862 with cast(Any, SessionLocal)() as session:
863 # Skip if tables don't exist yet (fresh database)
864 try:
865 _refresh_gateway_slugs_batched(session, effective_batch_size)
866 except (OperationalError, ProgrammingError) as e:
867 # Table doesn't exist yet - expected on fresh database
868 logger.info("Gateway table not found, skipping slug refresh: %s", e)
869 return
871 try:
872 _refresh_tool_names_batched(session, effective_batch_size)
873 except (OperationalError, ProgrammingError) as e:
874 # Table doesn't exist yet - expected on fresh database
875 logger.info("Tool table not found, skipping tool name refresh: %s", e)
877 try:
878 _refresh_prompt_names_batched(session, effective_batch_size)
879 except (OperationalError, ProgrammingError) as e:
880 # Table doesn't exist yet - expected on fresh database
881 logger.info("Prompt table not found, skipping prompt name refresh: %s", e)
883 except SQLAlchemyError as e:
884 logger.warning("Failed to refresh slugs on startup (database error): %s", e)
885 except Exception as e:
886 logger.warning("Failed to refresh slugs on startup (unexpected error): %s", e)
889def _compute_metrics_summary(
890 raw_metrics: List[Any],
891 hourly_metrics: List[Any],
892 session: Optional[Any] = None,
893 entity_id: Optional[str] = None,
894 raw_metric_class: Optional[Any] = None,
895 hourly_metric_class: Optional[Any] = None,
896) -> Dict[str, Any]:
897 """Compute aggregated metrics from both raw and hourly tables without double-counting.
899 This function prevents double-counting by including raw metrics only from hours
900 that have no corresponding hourly aggregate. This correctly handles:
901 - Normal operation (hourly rollup complete, raw data retained or deleted)
902 - Rollup lag (completed hour not yet rolled up)
903 - Rollup disabled or failed
905 The approach mirrors ``aggregate_metrics_combined()`` in metrics_query_service.py.
907 Args:
908 raw_metrics: List of raw metric objects loaded in memory (or None if using session)
909 hourly_metrics: List of hourly aggregate objects loaded in memory (or None if using session)
910 session: SQLAlchemy session for database queries (required if raw_metrics/hourly_metrics not loaded)
911 entity_id: ID of the entity (tool/resource/prompt/server/agent) for SQL query
912 raw_metric_class: ORM class for raw metrics (e.g., ToolMetric) for SQL query
913 hourly_metric_class: ORM class for hourly metrics (e.g., ToolMetricsHourly) for SQL query
915 Returns:
916 Dict with keys: total_executions, successful_executions, failed_executions,
917 failure_rate, min_response_time, max_response_time, avg_response_time,
918 last_execution_time
920 Raises:
921 ValueError: If both in-memory and SQL query parameters are incomplete
922 """
923 # Determine if we're using in-memory or SQL query path
924 use_memory = raw_metrics is not None and hourly_metrics is not None
926 if use_memory:
927 # ============================================================
928 # IN-MEMORY PATH: Iterate over loaded objects
929 # ============================================================
931 # Build set of hours already covered by hourly aggregates
932 covered_hours: set[datetime] = set()
933 for h in hourly_metrics:
934 hs = h.hour_start if h.hour_start.tzinfo is not None else h.hour_start.replace(tzinfo=timezone.utc)
935 covered_hours.add(hs)
937 total = 0
938 successful = 0
939 min_rt: Optional[float] = None
940 max_rt: Optional[float] = None
941 sum_rt = 0.0
942 last_time: Optional[datetime] = None
944 # Include raw metrics only from hours NOT covered by hourly aggregates
945 for m in raw_metrics:
946 metric_ts = m.timestamp if m.timestamp.tzinfo is not None else m.timestamp.replace(tzinfo=timezone.utc)
947 metric_hour = metric_ts.replace(minute=0, second=0, microsecond=0)
948 if metric_hour in covered_hours:
949 continue # Already counted in hourly aggregates
951 total += 1
952 if m.is_success:
953 successful += 1
954 rt = m.response_time
955 if min_rt is None or rt < min_rt:
956 min_rt = rt
957 if max_rt is None or rt > max_rt:
958 max_rt = rt
959 sum_rt += rt
960 if last_time is None or metric_ts > last_time:
961 last_time = metric_ts
963 # Process hourly aggregated metrics (completed hours)
964 for h in hourly_metrics:
965 total += h.total_count
966 successful += h.success_count
967 if h.min_response_time is not None:
968 if min_rt is None or h.min_response_time < min_rt:
969 min_rt = h.min_response_time
970 if h.max_response_time is not None:
971 if max_rt is None or h.max_response_time > max_rt:
972 max_rt = h.max_response_time
973 if h.avg_response_time is not None and h.total_count > 0:
974 sum_rt += h.avg_response_time * h.total_count
975 hs = h.hour_start if h.hour_start.tzinfo is not None else h.hour_start.replace(tzinfo=timezone.utc)
976 if last_time is None or hs > last_time:
977 last_time = hs
979 failed = total - successful
980 return {
981 "total_executions": total,
982 "successful_executions": successful,
983 "failed_executions": failed,
984 "failure_rate": failed / total if total > 0 else 0.0,
985 "min_response_time": min_rt,
986 "max_response_time": max_rt,
987 "avg_response_time": sum_rt / total if total > 0 else None,
988 "last_execution_time": last_time,
989 }
991 # ============================================================
992 # SQL QUERY PATH: hourly aggregates + uncovered raw metrics
993 # ============================================================
994 if session is None or entity_id is None or raw_metric_class is None or hourly_metric_class is None:
995 raise ValueError("For SQL query path, must provide: session, entity_id, raw_metric_class, hourly_metric_class")
997 # Third-Party
998 from sqlalchemy import case # pylint: disable=import-outside-toplevel
1000 # Determine the foreign key column name (tool_id, resource_id, etc.)
1001 class_name = raw_metric_class.__name__
1002 if class_name.endswith("Metric"):
1003 entity_type = class_name[:-6].lower() # ToolMetric -> tool
1004 fk_column_name = f"{entity_type}_id"
1005 else:
1006 raise ValueError(f"Cannot determine foreign key column for {class_name}")
1008 fk_column_raw = getattr(raw_metric_class, fk_column_name)
1009 fk_column_hourly = getattr(hourly_metric_class, fk_column_name)
1011 # Query 1: All hourly aggregates for this entity (includes max hour_start)
1012 hourly_result = (
1013 session.query(
1014 func.sum(hourly_metric_class.total_count), # pylint: disable=not-callable
1015 func.sum(hourly_metric_class.success_count), # pylint: disable=not-callable
1016 func.min(hourly_metric_class.min_response_time), # pylint: disable=not-callable
1017 func.max(hourly_metric_class.max_response_time), # pylint: disable=not-callable
1018 func.sum(hourly_metric_class.avg_response_time * hourly_metric_class.total_count), # weighted sum
1019 func.max(hourly_metric_class.hour_start), # pylint: disable=not-callable
1020 )
1021 .filter(fk_column_hourly == entity_id)
1022 .one()
1023 )
1025 hourly_total = hourly_result[0] or 0
1026 hourly_successful = hourly_result[1] or 0
1027 hourly_min_rt = hourly_result[2]
1028 hourly_max_rt = hourly_result[3]
1029 hourly_weighted_sum_rt = hourly_result[4] or 0.0
1030 hourly_last_bucket = hourly_result[5]
1032 # Query 2: Raw metrics from hours NOT covered by hourly aggregates.
1033 # Use max_hour_start to determine the boundary: hourly data covers
1034 # up to max_hour_start + 1h, raw data covers everything after that.
1035 # When no hourly data exists, all raw metrics are counted.
1036 raw_query = session.query(
1037 func.count(raw_metric_class.id), # pylint: disable=not-callable
1038 func.sum(case((raw_metric_class.is_success.is_(True), 1), else_=0)),
1039 func.min(raw_metric_class.response_time), # pylint: disable=not-callable
1040 func.max(raw_metric_class.response_time), # pylint: disable=not-callable
1041 func.sum(raw_metric_class.response_time), # pylint: disable=not-callable
1042 func.max(raw_metric_class.timestamp), # pylint: disable=not-callable
1043 ).filter(fk_column_raw == entity_id)
1044 if hourly_last_bucket is not None:
1045 # Only include raw metrics from after the last rolled-up hour
1046 hourly_coverage_end = hourly_last_bucket + timedelta(hours=1)
1047 raw_query = raw_query.filter(raw_metric_class.timestamp >= hourly_coverage_end)
1049 raw_result = raw_query.one()
1051 raw_total = raw_result[0] or 0
1052 raw_successful = raw_result[1] or 0
1053 raw_min_rt = raw_result[2]
1054 raw_max_rt = raw_result[3]
1055 raw_sum_rt = raw_result[4] or 0.0
1056 raw_last_time = raw_result[5]
1058 # Aggregate totals
1059 total = hourly_total + raw_total
1060 successful = hourly_successful + raw_successful
1061 failed = total - successful
1063 # Min/max across both sources
1064 min_rt = None
1065 if raw_min_rt is not None and hourly_min_rt is not None:
1066 min_rt = min(raw_min_rt, hourly_min_rt)
1067 elif raw_min_rt is not None:
1068 min_rt = raw_min_rt
1069 elif hourly_min_rt is not None:
1070 min_rt = hourly_min_rt
1072 max_rt = None
1073 if raw_max_rt is not None and hourly_max_rt is not None:
1074 max_rt = max(raw_max_rt, hourly_max_rt)
1075 elif raw_max_rt is not None:
1076 max_rt = raw_max_rt
1077 elif hourly_max_rt is not None:
1078 max_rt = hourly_max_rt
1080 # Weighted average response time
1081 avg_rt = None
1082 if total > 0:
1083 avg_rt = (hourly_weighted_sum_rt + raw_sum_rt) / total
1085 # Last execution time: most recent (use hour_start for hourly, consistent with aggregate_metrics_combined)
1086 last_time = None
1087 if raw_last_time is not None and hourly_last_bucket is not None:
1088 last_time = max(raw_last_time, hourly_last_bucket)
1089 elif raw_last_time is not None:
1090 last_time = raw_last_time
1091 elif hourly_last_bucket is not None:
1092 last_time = hourly_last_bucket
1094 return {
1095 "total_executions": total,
1096 "successful_executions": successful,
1097 "failed_executions": failed,
1098 "failure_rate": failed / total if total > 0 else 0.0,
1099 "min_response_time": min_rt,
1100 "max_response_time": max_rt,
1101 "avg_response_time": avg_rt,
1102 "last_execution_time": last_time,
1103 }
1106class Base(DeclarativeBase):
1107 """Base class for all models."""
1109 # Naming convention for foreign keys
1110 metadata = MetaData(
1111 naming_convention={
1112 "fk": "fk_%(table_name)s_%(column_0_name)s",
1113 "pk": "pk_%(table_name)s",
1114 "ix": "ix_%(table_name)s_%(column_0_name)s",
1115 "uq": "uq_%(table_name)s_%(column_0_name)s",
1116 "ck": "ck_%(table_name)s_%(constraint_name)s",
1117 }
1118 )
1121# ---------------------------------------------------------------------------
1122# RBAC Models - SQLAlchemy Database Models
1123# ---------------------------------------------------------------------------
1126class Role(Base):
1127 """Role model for RBAC system."""
1129 __tablename__ = "roles"
1131 # Primary key
1132 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
1134 # Role metadata
1135 name: Mapped[str] = mapped_column(String(255), nullable=False)
1136 description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
1137 scope: Mapped[str] = mapped_column(String(20), nullable=False) # 'global', 'team', 'personal'
1139 # Permissions and inheritance
1140 permissions: Mapped[List[str]] = mapped_column(JSON, nullable=False, default=list)
1141 inherits_from: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("roles.id"), nullable=True)
1143 # Metadata
1144 created_by: Mapped[str] = mapped_column(String(255), ForeignKey("email_users.email"), nullable=False)
1145 is_system_role: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
1146 is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
1148 # Timestamps
1149 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utc_now)
1150 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utc_now, onupdate=utc_now)
1152 # Relationships
1153 parent_role: Mapped[Optional["Role"]] = relationship("Role", remote_side=[id], backref="child_roles")
1154 user_assignments: Mapped[List["UserRole"]] = relationship("UserRole", back_populates="role", cascade="all, delete-orphan")
1156 def get_effective_permissions(self) -> List[str]:
1157 """Get all permissions including inherited ones.
1159 Returns:
1160 List of permission strings including inherited permissions
1161 """
1162 effective_permissions = set(self.permissions)
1163 if self.parent_role:
1164 effective_permissions.update(self.parent_role.get_effective_permissions())
1165 return sorted(list(effective_permissions))
1168class UserRole(Base):
1169 """User role assignment model."""
1171 __tablename__ = "user_roles"
1173 # Primary key
1174 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
1176 # Assignment details
1177 user_email: Mapped[str] = mapped_column(String(255), ForeignKey("email_users.email"), nullable=False)
1178 role_id: Mapped[str] = mapped_column(String(36), ForeignKey("roles.id"), nullable=False)
1179 scope: Mapped[str] = mapped_column(String(20), nullable=False) # 'global', 'team', 'personal'
1180 scope_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True) # Team ID if team-scoped
1182 # Grant metadata
1183 granted_by: Mapped[str] = mapped_column(String(255), ForeignKey("email_users.email"), nullable=False)
1184 granted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utc_now)
1185 expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
1186 is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
1187 grant_source: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None)
1189 # Relationships
1190 role: Mapped["Role"] = relationship("Role", back_populates="user_assignments")
1192 def is_expired(self) -> bool:
1193 """Check if the role assignment has expired.
1195 Returns:
1196 True if assignment has expired, False otherwise
1197 """
1198 if self.expires_at is None:
1199 return False
1200 expires_at = self.expires_at
1201 if expires_at.tzinfo is None:
1202 expires_at = expires_at.replace(tzinfo=timezone.utc)
1204 return utc_now() > expires_at
1207class PermissionAuditLog(Base):
1208 """Permission audit log model."""
1210 __tablename__ = "permission_audit_log"
1212 # Primary key
1213 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
1215 # Audit metadata
1216 timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utc_now)
1217 user_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
1219 # Permission details
1220 permission: Mapped[str] = mapped_column(String(100), nullable=False)
1221 resource_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
1222 resource_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
1223 team_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
1225 # Result
1226 granted: Mapped[bool] = mapped_column(Boolean, nullable=False)
1227 roles_checked: Mapped[Optional[Dict]] = mapped_column(JSON, nullable=True)
1229 # Request metadata
1230 ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) # IPv6 max length
1231 user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
1234# Permission constants for the system
1235class Permissions:
1236 """System permission constants."""
1238 # User permissions
1239 USERS_CREATE = "users.create"
1240 USERS_READ = "users.read"
1241 USERS_UPDATE = "users.update"
1242 USERS_DELETE = "users.delete"
1243 USERS_INVITE = "users.invite"
1245 # Team permissions
1246 TEAMS_CREATE = "teams.create"
1247 TEAMS_READ = "teams.read"
1248 TEAMS_UPDATE = "teams.update"
1249 TEAMS_DELETE = "teams.delete"
1250 TEAMS_JOIN = "teams.join"
1251 TEAMS_MANAGE_MEMBERS = "teams.manage_members"
1253 # Tool permissions
1254 TOOLS_CREATE = "tools.create"
1255 TOOLS_READ = "tools.read"
1256 TOOLS_UPDATE = "tools.update"
1257 TOOLS_DELETE = "tools.delete"
1258 TOOLS_EXECUTE = "tools.execute"
1260 # Resource permissions
1261 RESOURCES_CREATE = "resources.create"
1262 RESOURCES_READ = "resources.read"
1263 RESOURCES_UPDATE = "resources.update"
1264 RESOURCES_DELETE = "resources.delete"
1265 RESOURCES_SHARE = "resources.share"
1267 # Gateway permissions
1268 GATEWAYS_CREATE = "gateways.create"
1269 GATEWAYS_READ = "gateways.read"
1270 GATEWAYS_UPDATE = "gateways.update"
1271 GATEWAYS_DELETE = "gateways.delete"
1273 # Prompt permissions
1274 PROMPTS_CREATE = "prompts.create"
1275 PROMPTS_READ = "prompts.read"
1276 PROMPTS_UPDATE = "prompts.update"
1277 PROMPTS_DELETE = "prompts.delete"
1278 PROMPTS_EXECUTE = "prompts.execute"
1280 # MCP method permission prefixes — used by token_catalog_service (generation-time)
1281 # and token_scoping middleware (runtime) to auto-grant servers.use transport access.
1282 MCP_METHOD_PREFIXES = ("tools.", "resources.", "prompts.")
1284 # LLM proxy permissions
1285 LLM_READ = "llm.read"
1286 LLM_INVOKE = "llm.invoke"
1288 # Server permissions
1289 SERVERS_CREATE = "servers.create"
1290 SERVERS_READ = "servers.read"
1291 SERVERS_USE = "servers.use"
1292 SERVERS_UPDATE = "servers.update"
1293 SERVERS_DELETE = "servers.delete"
1294 SERVERS_MANAGE = "servers.manage"
1296 # Token permissions
1297 TOKENS_CREATE = "tokens.create"
1298 TOKENS_READ = "tokens.read"
1299 TOKENS_UPDATE = "tokens.update"
1300 TOKENS_REVOKE = "tokens.revoke"
1302 # Admin permissions
1303 ADMIN_SYSTEM_CONFIG = "admin.system_config"
1304 ADMIN_USER_MANAGEMENT = "admin.user_management"
1305 ADMIN_SECURITY_AUDIT = "admin.security_audit"
1306 ADMIN_OVERVIEW = "admin.overview"
1307 ADMIN_DASHBOARD = "admin.dashboard"
1308 ADMIN_EVENTS = "admin.events"
1309 ADMIN_GRPC = "admin.grpc"
1310 ADMIN_PLUGINS = "admin.plugins"
1311 ADMIN_METRICS = "admin.metrics"
1312 ADMIN_EXPORT = "admin.export"
1313 ADMIN_IMPORT = "admin.import"
1314 ADMIN_SSO_PROVIDERS_CREATE = "admin.sso_providers:create"
1315 ADMIN_SSO_PROVIDERS_READ = "admin.sso_providers:read"
1316 ADMIN_SSO_PROVIDERS_UPDATE = "admin.sso_providers:update"
1317 ADMIN_SSO_PROVIDERS_DELETE = "admin.sso_providers:delete"
1319 # Observability and audit read permissions
1320 LOGS_READ = "logs:read"
1321 METRICS_READ = "metrics:read"
1322 AUDIT_READ = "audit:read"
1323 SECURITY_READ = "security:read"
1325 # A2A Agent permissions
1326 A2A_CREATE = "a2a.create"
1327 A2A_READ = "a2a.read"
1328 A2A_UPDATE = "a2a.update"
1329 A2A_DELETE = "a2a.delete"
1330 A2A_INVOKE = "a2a.invoke"
1332 # Tag permissions
1333 TAGS_READ = "tags.read"
1334 TAGS_CREATE = "tags.create"
1335 TAGS_UPDATE = "tags.update"
1336 TAGS_DELETE = "tags.delete"
1338 # Special permissions
1339 ALL_PERMISSIONS = "*" # Wildcard for all permissions
1341 @classmethod
1342 def get_all_permissions(cls) -> List[str]:
1343 """Get list of all defined permissions.
1345 Returns:
1346 List of all permission strings defined in the class
1347 """
1348 permissions = []
1349 for attr_name in dir(cls):
1350 if not attr_name.startswith("_") and attr_name.isupper() and attr_name != "ALL_PERMISSIONS":
1351 attr_value = getattr(cls, attr_name)
1352 if isinstance(attr_value, str):
1353 permissions.append(attr_value)
1354 return sorted(permissions)
1356 @classmethod
1357 def get_permissions_by_resource(cls) -> Dict[str, List[str]]:
1358 """Get permissions organized by resource type.
1360 Returns:
1361 Dictionary mapping resource types to their permissions
1362 """
1363 resource_permissions = {}
1364 for permission in cls.get_all_permissions():
1365 if "." in permission:
1366 resource_type = permission.split(".", 1)[0]
1367 elif ":" in permission:
1368 resource_type = permission.split(":", 1)[0]
1369 else:
1370 resource_type = permission
1371 if resource_type not in resource_permissions:
1372 resource_permissions[resource_type] = []
1373 resource_permissions[resource_type].append(permission)
1374 return resource_permissions
1377# ---------------------------------------------------------------------------
1378# Email-based User Authentication Models
1379# ---------------------------------------------------------------------------
1382class EmailUser(Base):
1383 """Email-based user model for authentication.
1385 This model provides email-based authentication as the foundation
1386 for all multi-user features. Users are identified by email addresses
1387 instead of usernames.
1389 Attributes:
1390 email (str): Primary key, unique email identifier
1391 password_hash (str): Argon2id hashed password
1392 full_name (str): Optional display name for professional appearance
1393 is_admin (bool): Admin privileges flag
1394 is_active (bool): Account status flag
1395 auth_provider (str): Authentication provider ('local', 'github', etc.)
1396 password_hash_type (str): Type of password hash used
1397 failed_login_attempts (int): Count of failed login attempts
1398 locked_until (datetime): Account lockout expiration
1399 created_at (datetime): Account creation timestamp
1400 updated_at (datetime): Last account update timestamp
1401 last_login (datetime): Last successful login timestamp
1402 email_verified_at (datetime): Email verification timestamp
1404 Examples:
1405 >>> user = EmailUser(
1406 ... email="alice@example.com",
1407 ... password_hash="$argon2id$v=19$m=65536,t=3,p=1$...",
1408 ... full_name="Alice Smith",
1409 ... is_admin=False
1410 ... )
1411 >>> user.email
1412 'alice@example.com'
1413 >>> user.is_email_verified()
1414 False
1415 >>> user.is_account_locked()
1416 False
1417 """
1419 __tablename__ = "email_users"
1421 # Core identity fields
1422 email: Mapped[str] = mapped_column(String(255), primary_key=True, index=True)
1423 password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
1424 full_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
1425 is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
1426 # Track how admin status was granted: "sso" (synced from IdP), "manual" (Admin UI), "api" (API grant), or None (legacy)
1427 admin_origin: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
1429 # Status fields
1430 is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
1431 email_verified_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
1433 # Security fields
1434 auth_provider: Mapped[str] = mapped_column(String(50), default="local", nullable=False)
1435 password_hash_type: Mapped[str] = mapped_column(String(20), default="argon2id", nullable=False)
1436 failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
1437 locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
1438 password_change_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
1439 password_changed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=True)
1441 # Timestamps
1442 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
1443 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now, nullable=False)
1444 last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
1446 def __repr__(self) -> str:
1447 """String representation of the user.
1449 Returns:
1450 str: String representation of EmailUser instance
1451 """
1452 return f"<EmailUser(email='{self.email}', full_name='{self.full_name}', is_admin={self.is_admin})>"
1454 def is_email_verified(self) -> bool:
1455 """Check if the user's email is verified.
1457 Returns:
1458 bool: True if email is verified, False otherwise
1460 Examples:
1461 >>> user = EmailUser(email="test@example.com")
1462 >>> user.is_email_verified()
1463 False
1464 >>> user.email_verified_at = utc_now()
1465 >>> user.is_email_verified()
1466 True
1467 """
1468 return self.email_verified_at is not None
1470 def is_account_locked(self) -> bool:
1471 """Check if the account is currently locked.
1473 Returns:
1474 bool: True if account is locked, False otherwise
1476 Examples:
1477 >>> from datetime import timedelta
1478 >>> user = EmailUser(email="test@example.com")
1479 >>> user.is_account_locked()
1480 False
1481 >>> user.locked_until = utc_now() + timedelta(hours=1)
1482 >>> user.is_account_locked()
1483 True
1484 """
1485 if self.locked_until is None:
1486 return False
1487 locked_until = self.locked_until
1488 if locked_until.tzinfo is None:
1489 # Treat naive datetimes as UTC (SQLite strips timezone info)
1490 locked_until = locked_until.replace(tzinfo=timezone.utc)
1491 if utc_now() >= locked_until:
1492 # Lockout expired: reset counters so users get a fresh attempt window.
1493 self.failed_login_attempts = 0
1494 self.locked_until = None
1495 return False
1496 return True
1498 def get_display_name(self) -> str:
1499 """Get the user's display name.
1501 Returns the full_name if available, otherwise extracts
1502 the local part from the email address.
1504 Returns:
1505 str: Display name for the user
1507 Examples:
1508 >>> user = EmailUser(email="john@example.com", full_name="John Doe")
1509 >>> user.get_display_name()
1510 'John Doe'
1511 >>> user_no_name = EmailUser(email="jane@example.com")
1512 >>> user_no_name.get_display_name()
1513 'jane'
1514 """
1515 if self.full_name:
1516 return self.full_name
1517 return self.email.split("@")[0]
1519 def reset_failed_attempts(self) -> None:
1520 """Reset failed login attempts counter.
1522 Called after successful authentication to reset the
1523 failed attempts counter and clear any account lockout.
1525 Examples:
1526 >>> user = EmailUser(email="test@example.com", failed_login_attempts=3)
1527 >>> user.reset_failed_attempts()
1528 >>> user.failed_login_attempts
1529 0
1530 >>> user.locked_until is None
1531 True
1532 """
1533 self.failed_login_attempts = 0
1534 self.locked_until = None
1535 self.last_login = utc_now()
1537 def increment_failed_attempts(self, max_attempts: int = 5, lockout_duration_minutes: int = 30) -> bool:
1538 """Increment failed login attempts and potentially lock account.
1540 Args:
1541 max_attempts: Maximum allowed failed attempts before lockout
1542 lockout_duration_minutes: Duration of lockout in minutes
1544 Returns:
1545 bool: True if account is now locked, False otherwise
1547 Examples:
1548 >>> user = EmailUser(email="test@example.com", password_hash="test", failed_login_attempts=0)
1549 >>> user.increment_failed_attempts(max_attempts=3)
1550 False
1551 >>> user.failed_login_attempts
1552 1
1553 >>> for _ in range(2):
1554 ... user.increment_failed_attempts(max_attempts=3)
1555 False
1556 True
1557 >>> user.is_account_locked()
1558 True
1559 """
1560 self.failed_login_attempts += 1
1562 if self.failed_login_attempts >= max_attempts:
1563 self.locked_until = utc_now() + timedelta(minutes=lockout_duration_minutes)
1564 return True
1566 return False
1568 # Team relationships
1569 team_memberships: Mapped[List["EmailTeamMember"]] = relationship("EmailTeamMember", foreign_keys="EmailTeamMember.user_email", back_populates="user")
1570 created_teams: Mapped[List["EmailTeam"]] = relationship("EmailTeam", foreign_keys="EmailTeam.created_by", back_populates="creator")
1571 sent_invitations: Mapped[List["EmailTeamInvitation"]] = relationship("EmailTeamInvitation", foreign_keys="EmailTeamInvitation.invited_by", back_populates="inviter")
1573 # API token relationships
1574 api_tokens: Mapped[List["EmailApiToken"]] = relationship("EmailApiToken", back_populates="user", cascade="all, delete-orphan")
1576 def get_teams(self) -> List["EmailTeam"]:
1577 """Get all teams this user is a member of.
1579 Returns:
1580 List[EmailTeam]: List of teams the user belongs to
1582 Examples:
1583 >>> user = EmailUser(email="user@example.com")
1584 >>> teams = user.get_teams()
1585 >>> isinstance(teams, list)
1586 True
1587 """
1588 return [membership.team for membership in self.team_memberships if membership.is_active]
1590 def get_personal_team(self) -> Optional["EmailTeam"]:
1591 """Get the user's personal team.
1593 Returns:
1594 EmailTeam: The user's personal team or None if not found
1596 Examples:
1597 >>> user = EmailUser(email="user@example.com")
1598 >>> personal_team = user.get_personal_team()
1599 """
1600 for team in self.created_teams:
1601 if team.is_personal and team.is_active:
1602 return team
1603 return None
1605 def is_team_member(self, team_id: str) -> bool:
1606 """Check if user is a member of the specified team.
1608 Args:
1609 team_id: ID of the team to check
1611 Returns:
1612 bool: True if user is a member, False otherwise
1614 Examples:
1615 >>> user = EmailUser(email="user@example.com")
1616 >>> user.is_team_member("team-123")
1617 False
1618 """
1619 return any(membership.team_id == team_id and membership.is_active for membership in self.team_memberships)
1621 def get_team_role(self, team_id: str) -> Optional[str]:
1622 """Get user's role in a specific team.
1624 Args:
1625 team_id: ID of the team to check
1627 Returns:
1628 str: User's role or None if not a member
1630 Examples:
1631 >>> user = EmailUser(email="user@example.com")
1632 >>> role = user.get_team_role("team-123")
1633 """
1634 for membership in self.team_memberships:
1635 if membership.team_id == team_id and membership.is_active:
1636 return membership.role
1637 return None
1640class EmailAuthEvent(Base):
1641 """Authentication event logging for email users.
1643 This model tracks all authentication attempts for auditing,
1644 security monitoring, and compliance purposes.
1646 Attributes:
1647 id (int): Primary key
1648 timestamp (datetime): Event timestamp
1649 user_email (str): Email of the user
1650 event_type (str): Type of authentication event
1651 success (bool): Whether the authentication was successful
1652 ip_address (str): Client IP address
1653 user_agent (str): Client user agent string
1654 failure_reason (str): Reason for authentication failure
1655 details (dict): Additional event details as JSON
1657 Examples:
1658 >>> event = EmailAuthEvent(
1659 ... user_email="alice@example.com",
1660 ... event_type="login",
1661 ... success=True,
1662 ... ip_address="192.168.1.100"
1663 ... )
1664 >>> event.event_type
1665 'login'
1666 >>> event.success
1667 True
1668 """
1670 __tablename__ = "email_auth_events"
1672 # Primary key
1673 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
1675 # Event details
1676 timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
1677 user_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
1678 event_type: Mapped[str] = mapped_column(String(50), nullable=False)
1679 success: Mapped[bool] = mapped_column(Boolean, nullable=False)
1681 # Client information
1682 ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) # IPv6 compatible
1683 user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
1685 # Failure information
1686 failure_reason: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
1687 details: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON string
1689 def __repr__(self) -> str:
1690 """String representation of the auth event.
1692 Returns:
1693 str: String representation of EmailAuthEvent instance
1694 """
1695 return f"<EmailAuthEvent(user_email='{self.user_email}', event_type='{self.event_type}', success={self.success})>"
1697 @classmethod
1698 def create_login_attempt(
1699 cls,
1700 user_email: str,
1701 success: bool,
1702 ip_address: Optional[str] = None,
1703 user_agent: Optional[str] = None,
1704 failure_reason: Optional[str] = None,
1705 ) -> "EmailAuthEvent":
1706 """Create a login attempt event.
1708 Args:
1709 user_email: Email address of the user
1710 success: Whether the login was successful
1711 ip_address: Client IP address
1712 user_agent: Client user agent
1713 failure_reason: Reason for failure (if applicable)
1715 Returns:
1716 EmailAuthEvent: New authentication event
1718 Examples:
1719 >>> event = EmailAuthEvent.create_login_attempt(
1720 ... user_email="user@example.com",
1721 ... success=True,
1722 ... ip_address="192.168.1.1"
1723 ... )
1724 >>> event.event_type
1725 'login'
1726 >>> event.success
1727 True
1728 """
1729 return cls(user_email=user_email, event_type="login", success=success, ip_address=ip_address, user_agent=user_agent, failure_reason=failure_reason)
1731 @classmethod
1732 def create_registration_event(
1733 cls,
1734 user_email: str,
1735 success: bool,
1736 ip_address: Optional[str] = None,
1737 user_agent: Optional[str] = None,
1738 failure_reason: Optional[str] = None,
1739 ) -> "EmailAuthEvent":
1740 """Create a registration event.
1742 Args:
1743 user_email: Email address of the user
1744 success: Whether the registration was successful
1745 ip_address: Client IP address
1746 user_agent: Client user agent
1747 failure_reason: Reason for failure (if applicable)
1749 Returns:
1750 EmailAuthEvent: New authentication event
1751 """
1752 return cls(user_email=user_email, event_type="registration", success=success, ip_address=ip_address, user_agent=user_agent, failure_reason=failure_reason)
1754 @classmethod
1755 def create_password_change_event(
1756 cls,
1757 user_email: str,
1758 success: bool,
1759 ip_address: Optional[str] = None,
1760 user_agent: Optional[str] = None,
1761 ) -> "EmailAuthEvent":
1762 """Create a password change event.
1764 Args:
1765 user_email: Email address of the user
1766 success: Whether the password change was successful
1767 ip_address: Client IP address
1768 user_agent: Client user agent
1770 Returns:
1771 EmailAuthEvent: New authentication event
1772 """
1773 return cls(user_email=user_email, event_type="password_change", success=success, ip_address=ip_address, user_agent=user_agent)
1776class PasswordResetToken(Base):
1777 """One-time password reset token record.
1779 Stores only a SHA-256 hash of the user-facing token. Tokens are one-time use
1780 and expire after a configured duration.
1781 """
1783 __tablename__ = "password_reset_tokens"
1785 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
1786 user_email: Mapped[str] = mapped_column(String(255), ForeignKey("email_users.email", ondelete="CASCADE"), nullable=False, index=True)
1787 token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
1788 expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
1789 used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
1790 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
1791 ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
1792 user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
1794 user: Mapped["EmailUser"] = relationship("EmailUser")
1796 __table_args__ = (Index("ix_password_reset_tokens_expires_at", "expires_at"),)
1798 def is_expired(self) -> bool:
1799 """Return whether the reset token has expired.
1801 Returns:
1802 bool: True when `expires_at` is in the past.
1803 """
1804 if self.expires_at is None:
1805 return False
1806 expires_at = self.expires_at
1807 if expires_at.tzinfo is None:
1808 expires_at = expires_at.replace(tzinfo=timezone.utc)
1810 return expires_at <= utc_now()
1812 def is_used(self) -> bool:
1813 """Return whether the reset token was already consumed.
1815 Returns:
1816 bool: True when `used_at` is set.
1817 """
1818 return self.used_at is not None
1821class EmailTeam(Base):
1822 """Email-based team model for multi-team collaboration.
1824 This model represents teams that users can belong to, with automatic
1825 personal team creation and role-based access control.
1827 Attributes:
1828 id (str): Primary key UUID
1829 name (str): Team display name
1830 slug (str): URL-friendly team identifier
1831 description (str): Team description
1832 created_by (str): Email of the user who created the team
1833 is_personal (bool): Whether this is a personal team
1834 visibility (str): Team visibility (private, public)
1835 max_members (int): Maximum number of team members allowed
1836 created_at (datetime): Team creation timestamp
1837 updated_at (datetime): Last update timestamp
1838 is_active (bool): Whether the team is active
1840 Examples:
1841 >>> team = EmailTeam(
1842 ... name="Engineering Team",
1843 ... slug="engineering-team",
1844 ... created_by="admin@example.com",
1845 ... is_personal=False
1846 ... )
1847 >>> team.name
1848 'Engineering Team'
1849 >>> team.is_personal
1850 False
1851 """
1853 __tablename__ = "email_teams"
1855 # Primary key
1856 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
1858 # Basic team information
1859 name: Mapped[str] = mapped_column(String(255), nullable=False)
1860 slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
1861 description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
1862 created_by: Mapped[str] = mapped_column(String(255), ForeignKey("email_users.email"), nullable=False)
1864 # Team settings
1865 is_personal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
1866 visibility: Mapped[str] = mapped_column(String(20), default="public", nullable=False)
1867 max_members: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
1869 # Timestamps
1870 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
1871 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now, nullable=False)
1872 is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
1874 # Relationships
1875 members: Mapped[List["EmailTeamMember"]] = relationship("EmailTeamMember", back_populates="team", cascade="all, delete-orphan")
1876 invitations: Mapped[List["EmailTeamInvitation"]] = relationship("EmailTeamInvitation", back_populates="team", cascade="all, delete-orphan")
1877 api_tokens: Mapped[List["EmailApiToken"]] = relationship("EmailApiToken", back_populates="team", cascade="all, delete-orphan")
1878 creator: Mapped["EmailUser"] = relationship("EmailUser", foreign_keys=[created_by])
1880 # Index for search and pagination performance
1881 __table_args__ = (Index("ix_email_teams_name_id", "name", "id"),)
1883 def __repr__(self) -> str:
1884 """String representation of the team.
1886 Returns:
1887 str: String representation of EmailTeam instance
1888 """
1889 return f"<EmailTeam(id='{self.id}', name='{self.name}', is_personal={self.is_personal})>"
1891 def get_member_count(self) -> int:
1892 """Get the current number of team members.
1894 Uses direct SQL COUNT to avoid loading all members into memory.
1896 Returns:
1897 int: Number of active team members
1899 Examples:
1900 >>> team = EmailTeam(name="Test Team", slug="test-team", created_by="admin@example.com")
1901 >>> team.get_member_count()
1902 0
1903 """
1904 # Third-Party
1905 from sqlalchemy.orm import object_session # pylint: disable=import-outside-toplevel
1907 session = object_session(self)
1908 if session is None:
1909 # Fallback for detached objects (e.g., in doctests)
1910 return len([m for m in self.members if m.is_active])
1912 count = session.query(func.count(EmailTeamMember.id)).filter(EmailTeamMember.team_id == self.id, EmailTeamMember.is_active.is_(True)).scalar() # pylint: disable=not-callable
1913 return count or 0
1915 def is_member(self, user_email: str) -> bool:
1916 """Check if a user is a member of this team.
1918 Uses direct SQL EXISTS to avoid loading all members into memory.
1920 Args:
1921 user_email: Email address to check
1923 Returns:
1924 bool: True if user is an active member, False otherwise
1926 Examples:
1927 >>> team = EmailTeam(name="Test Team", slug="test-team", created_by="admin@example.com")
1928 >>> team.is_member("admin@example.com")
1929 False
1930 """
1931 # Third-Party
1932 from sqlalchemy.orm import object_session # pylint: disable=import-outside-toplevel
1934 session = object_session(self)
1935 if session is None:
1936 # Fallback for detached objects (e.g., in doctests)
1937 return any(m.user_email == user_email and m.is_active for m in self.members)
1939 exists = session.query(EmailTeamMember.id).filter(EmailTeamMember.team_id == self.id, EmailTeamMember.user_email == user_email, EmailTeamMember.is_active.is_(True)).first()
1940 return exists is not None
1942 def get_member_role(self, user_email: str) -> Optional[str]:
1943 """Get the role of a user in this team.
1945 Uses direct SQL query to avoid loading all members into memory.
1947 Args:
1948 user_email: Email address to check
1950 Returns:
1951 str: User's role or None if not a member
1953 Examples:
1954 >>> team = EmailTeam(name="Test Team", slug="test-team", created_by="admin@example.com")
1955 >>> team.get_member_role("admin@example.com")
1956 """
1957 # Third-Party
1958 from sqlalchemy.orm import object_session # pylint: disable=import-outside-toplevel
1960 session = object_session(self)
1961 if session is None:
1962 # Fallback for detached objects (e.g., in doctests)
1963 for member in self.members:
1964 if member.user_email == user_email and member.is_active:
1965 return member.role
1966 return None
1968 member = session.query(EmailTeamMember.role).filter(EmailTeamMember.team_id == self.id, EmailTeamMember.user_email == user_email, EmailTeamMember.is_active.is_(True)).first()
1969 return member[0] if member else None
1972class EmailTeamMember(Base):
1973 """Team membership model linking users to teams with roles.
1975 This model represents the many-to-many relationship between users and teams
1976 with additional role information and audit trails.
1978 Attributes:
1979 id (str): Primary key UUID
1980 team_id (str): Foreign key to email_teams
1981 user_email (str): Foreign key to email_users
1982 role (str): Member role (owner, member)
1983 joined_at (datetime): When the user joined the team
1984 invited_by (str): Email of the user who invited this member
1985 is_active (bool): Whether the membership is active
1986 grant_source (str): Origin of the grant (e.g., 'sso', 'manual', 'bootstrap', 'auto')
1988 Examples:
1989 >>> member = EmailTeamMember(
1990 ... team_id="team-123",
1991 ... user_email="user@example.com",
1992 ... role="member",
1993 ... invited_by="admin@example.com"
1994 ... )
1995 >>> member.role
1996 'member'
1997 """
1999 __tablename__ = "email_team_members"
2001 # Primary key
2002 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
2004 # Foreign keys
2005 team_id: Mapped[str] = mapped_column(String(36), ForeignKey("email_teams.id", ondelete="CASCADE"), nullable=False)
2006 user_email: Mapped[str] = mapped_column(String(255), ForeignKey("email_users.email"), nullable=False)
2008 # Membership details
2009 role: Mapped[str] = mapped_column(String(50), default="member", nullable=False)
2010 joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
2011 invited_by: Mapped[Optional[str]] = mapped_column(String(255), ForeignKey("email_users.email"), nullable=True)
2012 is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
2013 grant_source: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None)
2015 # Relationships
2016 team: Mapped["EmailTeam"] = relationship("EmailTeam", back_populates="members")
2017 user: Mapped["EmailUser"] = relationship("EmailUser", foreign_keys=[user_email])
2018 inviter: Mapped[Optional["EmailUser"]] = relationship("EmailUser", foreign_keys=[invited_by])
2020 # Unique constraint to prevent duplicate memberships
2021 __table_args__ = (UniqueConstraint("team_id", "user_email", name="uq_team_member"),)
2023 def __repr__(self) -> str:
2024 """String representation of the team member.
2026 Returns:
2027 str: String representation of EmailTeamMember instance
2028 """
2029 return f"<EmailTeamMember(team_id='{self.team_id}', user_email='{self.user_email}', role='{self.role}')>"
2032# Team member history model
2033class EmailTeamMemberHistory(Base):
2034 """
2035 History of team member actions (add, remove, reactivate, role change).
2037 This model records every membership-related event for audit and compliance.
2038 Each record tracks the team, user, role, action type, actor, and timestamp.
2040 Attributes:
2041 id (str): Primary key UUID
2042 team_id (str): Foreign key to email_teams
2043 user_email (str): Foreign key to email_users
2044 role (str): Role at the time of action
2045 action (str): Action type ("added", "removed", "reactivated", "role_changed")
2046 action_by (str): Email of the user who performed the action
2047 action_timestamp (datetime): When the action occurred
2049 Examples:
2050 >>> from mcpgateway.db import EmailTeamMemberHistory, utc_now
2051 >>> history = EmailTeamMemberHistory(
2052 ... team_id="team-123",
2053 ... user_email="user@example.com",
2054 ... role="member",
2055 ... action="added",
2056 ... action_by="admin@example.com",
2057 ... action_timestamp=utc_now()
2058 ... )
2059 >>> history.action
2060 'added'
2061 >>> history.role
2062 'member'
2063 >>> isinstance(history.action_timestamp, type(utc_now()))
2064 True
2065 """
2067 __tablename__ = "email_team_member_history"
2069 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
2070 team_member_id: Mapped[str] = mapped_column(String(36), ForeignKey("email_team_members.id", ondelete="CASCADE"), nullable=False)
2071 team_id: Mapped[str] = mapped_column(String(36), ForeignKey("email_teams.id"), nullable=False)
2072 user_email: Mapped[str] = mapped_column(String(255), ForeignKey("email_users.email"), nullable=False)
2073 role: Mapped[str] = mapped_column(String(50), default="member", nullable=False)
2074 action: Mapped[str] = mapped_column(String(50), nullable=False) # e.g. "added", "removed", "reactivated", "role_changed"
2075 action_by: Mapped[Optional[str]] = mapped_column(String(255), ForeignKey("email_users.email"), nullable=True)
2076 action_timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
2078 team_member: Mapped["EmailTeamMember"] = relationship("EmailTeamMember")
2079 team: Mapped["EmailTeam"] = relationship("EmailTeam")
2080 user: Mapped["EmailUser"] = relationship("EmailUser", foreign_keys=[user_email])
2081 actor: Mapped[Optional["EmailUser"]] = relationship("EmailUser", foreign_keys=[action_by])
2083 def __repr__(self) -> str:
2084 """
2085 Return a string representation of the EmailTeamMemberHistory instance.
2087 Returns:
2088 str: A string summarizing the team member history record.
2090 Examples:
2091 >>> from mcpgateway.db import EmailTeamMemberHistory, utc_now
2092 >>> history = EmailTeamMemberHistory(
2093 ... team_member_id="tm-123",
2094 ... team_id="team-123",
2095 ... user_email="user@example.com",
2096 ... role="member",
2097 ... action="added",
2098 ... action_by="admin@example.com",
2099 ... action_timestamp=utc_now()
2100 ... )
2101 >>> isinstance(repr(history), str)
2102 True
2103 """
2104 return f"<EmailTeamMemberHistory(team_member_id='{self.team_member_id}', team_id='{self.team_id}', user_email='{self.user_email}', role='{self.role}', action='{self.action}', action_by='{self.action_by}', action_timestamp='{self.action_timestamp}')>"
2107class EmailTeamInvitation(Base):
2108 """Team invitation model for managing team member invitations.
2110 This model tracks invitations sent to users to join teams, including
2111 expiration dates and invitation tokens.
2113 Attributes:
2114 id (str): Primary key UUID
2115 team_id (str): Foreign key to email_teams
2116 email (str): Email address of the invited user
2117 role (str): Role the user will have when they accept
2118 invited_by (str): Email of the user who sent the invitation
2119 invited_at (datetime): When the invitation was sent
2120 expires_at (datetime): When the invitation expires
2121 token (str): Unique invitation token
2122 is_active (bool): Whether the invitation is still active
2124 Examples:
2125 >>> invitation = EmailTeamInvitation(
2126 ... team_id="team-123",
2127 ... email="newuser@example.com",
2128 ... role="member",
2129 ... invited_by="admin@example.com"
2130 ... )
2131 >>> invitation.role
2132 'member'
2133 """
2135 __tablename__ = "email_team_invitations"
2137 # Primary key
2138 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
2140 # Foreign keys
2141 team_id: Mapped[str] = mapped_column(String(36), ForeignKey("email_teams.id", ondelete="CASCADE"), nullable=False)
2143 # Invitation details
2144 email: Mapped[str] = mapped_column(String(255), nullable=False)
2145 role: Mapped[str] = mapped_column(String(50), default="member", nullable=False)
2146 invited_by: Mapped[str] = mapped_column(String(255), ForeignKey("email_users.email"), nullable=False)
2148 # Timing
2149 invited_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
2150 expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
2152 # Security
2153 token: Mapped[str] = mapped_column(String(500), unique=True, nullable=False)
2154 is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
2156 # Relationships
2157 team: Mapped["EmailTeam"] = relationship("EmailTeam", back_populates="invitations")
2158 inviter: Mapped["EmailUser"] = relationship("EmailUser", foreign_keys=[invited_by])
2160 def __repr__(self) -> str:
2161 """String representation of the team invitation.
2163 Returns:
2164 str: String representation of EmailTeamInvitation instance
2165 """
2166 return f"<EmailTeamInvitation(team_id='{self.team_id}', email='{self.email}', role='{self.role}')>"
2168 def is_expired(self) -> bool:
2169 """Check if the invitation has expired.
2171 Returns:
2172 bool: True if the invitation has expired, False otherwise
2174 Examples:
2175 >>> from datetime import timedelta
2176 >>> invitation = EmailTeamInvitation(
2177 ... team_id="team-123",
2178 ... email="user@example.com",
2179 ... role="member",
2180 ... invited_by="admin@example.com",
2181 ... expires_at=utc_now() + timedelta(days=7)
2182 ... )
2183 >>> invitation.is_expired()
2184 False
2185 """
2186 now = utc_now()
2187 expires_at = self.expires_at
2189 # Handle timezone awareness mismatch
2190 if now.tzinfo is not None and expires_at.tzinfo is None:
2191 expires_at = expires_at.replace(tzinfo=timezone.utc)
2192 elif now.tzinfo is None and expires_at.tzinfo is not None:
2193 now = now.replace(tzinfo=timezone.utc)
2195 return now > expires_at
2197 def is_valid(self) -> bool:
2198 """Check if the invitation is valid (active and not expired).
2200 Returns:
2201 bool: True if the invitation is valid, False otherwise
2203 Examples:
2204 >>> from datetime import timedelta
2205 >>> invitation = EmailTeamInvitation(
2206 ... team_id="team-123",
2207 ... email="user@example.com",
2208 ... role="member",
2209 ... invited_by="admin@example.com",
2210 ... expires_at=utc_now() + timedelta(days=7),
2211 ... is_active=True
2212 ... )
2213 >>> invitation.is_valid()
2214 True
2215 """
2216 return self.is_active and not self.is_expired()
2219class EmailTeamJoinRequest(Base):
2220 """Team join request model for managing public team join requests.
2222 This model tracks user requests to join public teams, including
2223 approval workflow and expiration dates.
2225 Attributes:
2226 id (str): Primary key UUID
2227 team_id (str): Foreign key to email_teams
2228 user_email (str): Email of the user requesting to join
2229 message (str): Optional message from the user
2230 status (str): Request status (pending, approved, rejected, expired)
2231 requested_at (datetime): When the request was made
2232 expires_at (datetime): When the request expires
2233 reviewed_at (datetime): When the request was reviewed
2234 reviewed_by (str): Email of user who reviewed the request
2235 notes (str): Optional admin notes
2236 """
2238 __tablename__ = "email_team_join_requests"
2240 # Primary key
2241 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
2243 # Foreign keys
2244 team_id: Mapped[str] = mapped_column(String(36), ForeignKey("email_teams.id", ondelete="CASCADE"), nullable=False)
2245 user_email: Mapped[str] = mapped_column(String(255), ForeignKey("email_users.email"), nullable=False)
2247 # Request details
2248 message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
2249 status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False)
2251 # Timing
2252 requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
2253 expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
2254 reviewed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
2255 reviewed_by: Mapped[Optional[str]] = mapped_column(String(255), ForeignKey("email_users.email"), nullable=True)
2256 notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
2258 # Relationships
2259 team: Mapped["EmailTeam"] = relationship("EmailTeam")
2260 user: Mapped["EmailUser"] = relationship("EmailUser", foreign_keys=[user_email])
2261 reviewer: Mapped[Optional["EmailUser"]] = relationship("EmailUser", foreign_keys=[reviewed_by])
2263 # Unique constraint to prevent duplicate requests
2264 __table_args__ = (UniqueConstraint("team_id", "user_email", name="uq_team_join_request"),)
2266 def __repr__(self) -> str:
2267 """String representation of the team join request.
2269 Returns:
2270 str: String representation of the team join request.
2271 """
2272 return f"<EmailTeamJoinRequest(team_id='{self.team_id}', user_email='{self.user_email}', status='{self.status}')>"
2274 def is_expired(self) -> bool:
2275 """Check if the join request has expired.
2277 Returns:
2278 bool: True if the request has expired, False otherwise.
2279 """
2280 now = utc_now()
2281 expires_at = self.expires_at
2283 # Handle timezone awareness mismatch
2284 if now.tzinfo is not None and expires_at.tzinfo is None:
2285 expires_at = expires_at.replace(tzinfo=timezone.utc)
2286 elif now.tzinfo is None and expires_at.tzinfo is not None:
2287 now = now.replace(tzinfo=timezone.utc)
2289 return now > expires_at
2291 def is_pending(self) -> bool:
2292 """Check if the join request is still pending.
2294 Returns:
2295 bool: True if the request is pending and not expired, False otherwise.
2296 """
2297 return self.status == "pending" and not self.is_expired()
2300class PendingUserApproval(Base):
2301 """Model for pending SSO user registrations awaiting admin approval.
2303 This model stores information about users who have authenticated via SSO
2304 but require admin approval before their account is fully activated.
2306 Attributes:
2307 id (str): Primary key
2308 email (str): Email address of the pending user
2309 full_name (str): Full name from SSO provider
2310 auth_provider (str): SSO provider (github, google, etc.)
2311 sso_metadata (dict): Additional metadata from SSO provider
2312 requested_at (datetime): When the approval was requested
2313 expires_at (datetime): When the approval request expires
2314 approved_by (str): Email of admin who approved (if approved)
2315 approved_at (datetime): When the approval was granted
2316 status (str): Current status (pending, approved, rejected, expired)
2317 rejection_reason (str): Reason for rejection (if applicable)
2318 admin_notes (str): Notes from admin review
2320 Examples:
2321 >>> from datetime import timedelta
2322 >>> approval = PendingUserApproval(
2323 ... email="newuser@example.com",
2324 ... full_name="New User",
2325 ... auth_provider="github",
2326 ... expires_at=utc_now() + timedelta(days=30),
2327 ... status="pending"
2328 ... )
2329 >>> approval.status
2330 'pending'
2331 """
2333 __tablename__ = "pending_user_approvals"
2335 # Primary key
2336 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
2338 # User details
2339 email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
2340 full_name: Mapped[str] = mapped_column(String(255), nullable=False)
2341 auth_provider: Mapped[str] = mapped_column(String(50), nullable=False)
2342 sso_metadata: Mapped[Optional[Dict]] = mapped_column(JSON, nullable=True)
2344 # Request details
2345 requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
2346 expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
2348 # Approval details
2349 approved_by: Mapped[Optional[str]] = mapped_column(String(255), ForeignKey("email_users.email"), nullable=True)
2350 approved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
2351 status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False) # pending, approved, rejected, expired
2352 rejection_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
2353 admin_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
2355 # Relationships
2356 approver: Mapped[Optional["EmailUser"]] = relationship("EmailUser", foreign_keys=[approved_by])
2358 def __repr__(self) -> str:
2359 """String representation of the pending approval.
2361 Returns:
2362 str: String representation of PendingUserApproval instance
2363 """
2364 return f"<PendingUserApproval(email='{self.email}', status='{self.status}', provider='{self.auth_provider}')>"
2366 def is_expired(self) -> bool:
2367 """Check if the approval request has expired.
2369 Returns:
2370 bool: True if the approval request has expired
2371 """
2372 now = utc_now()
2373 expires_at = self.expires_at
2375 # Handle timezone awareness mismatch
2376 if now.tzinfo is not None and expires_at.tzinfo is None:
2377 expires_at = expires_at.replace(tzinfo=timezone.utc)
2378 elif now.tzinfo is None and expires_at.tzinfo is not None:
2379 now = now.replace(tzinfo=timezone.utc)
2381 return now > expires_at
2383 def approve(self, admin_email: str, notes: Optional[str] = None) -> None:
2384 """Approve the user registration.
2386 Args:
2387 admin_email: Email of the admin approving the request
2388 notes: Optional admin notes
2389 """
2390 self.status = "approved"
2391 self.approved_by = admin_email
2392 self.approved_at = utc_now()
2393 self.admin_notes = notes
2395 def reject(self, admin_email: str, reason: str, notes: Optional[str] = None) -> None:
2396 """Reject the user registration.
2398 Args:
2399 admin_email: Email of the admin rejecting the request
2400 reason: Reason for rejection
2401 notes: Optional admin notes
2402 """
2403 self.status = "rejected"
2404 self.approved_by = admin_email
2405 self.approved_at = utc_now()
2406 self.rejection_reason = reason
2407 self.admin_notes = notes
2410# Association table for servers and tools
2411server_tool_association = Table(
2412 "server_tool_association",
2413 Base.metadata,
2414 Column("server_id", String(36), ForeignKey("servers.id"), primary_key=True),
2415 Column("tool_id", String(36), ForeignKey("tools.id"), primary_key=True),
2416)
2418# Association table for servers and resources
2419server_resource_association = Table(
2420 "server_resource_association",
2421 Base.metadata,
2422 Column("server_id", String(36), ForeignKey("servers.id"), primary_key=True),
2423 Column("resource_id", String(36), ForeignKey("resources.id"), primary_key=True),
2424)
2426# Association table for servers and prompts
2427server_prompt_association = Table(
2428 "server_prompt_association",
2429 Base.metadata,
2430 Column("server_id", String(36), ForeignKey("servers.id"), primary_key=True),
2431 Column("prompt_id", String(36), ForeignKey("prompts.id"), primary_key=True),
2432)
2434# Association table for servers and A2A agents
2435server_a2a_association = Table(
2436 "server_a2a_association",
2437 Base.metadata,
2438 Column("server_id", String(36), ForeignKey("servers.id"), primary_key=True),
2439 Column("a2a_agent_id", String(36), ForeignKey("a2a_agents.id"), primary_key=True),
2440)
2443class GlobalConfig(Base):
2444 """Global configuration settings.
2446 Attributes:
2447 id (int): Primary key
2448 passthrough_headers (List[str]): List of headers allowed to be passed through globally
2449 """
2451 __tablename__ = "global_config"
2453 id: Mapped[int] = mapped_column(Integer, primary_key=True)
2454 passthrough_headers: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True) # Store list of strings as JSON array
2457class ToolMetric(Base):
2458 """
2459 ORM model for recording individual metrics for tool executions.
2461 Each record in this table corresponds to a single tool invocation and records:
2462 - timestamp (datetime): When the invocation occurred.
2463 - response_time (float): The execution time in seconds.
2464 - is_success (bool): True if the execution succeeded, False otherwise.
2465 - error_message (Optional[str]): Error message if the execution failed.
2467 Aggregated metrics (such as total executions, successful/failed counts, failure rate,
2468 minimum, maximum, and average response times, and last execution time) should be computed
2469 on the fly using SQL aggregate functions over the rows in this table.
2470 """
2472 __tablename__ = "tool_metrics"
2474 id: Mapped[int] = mapped_column(primary_key=True)
2475 tool_id: Mapped[str] = mapped_column(String(36), ForeignKey("tools.id"), nullable=False, index=True)
2476 timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, index=True)
2477 response_time: Mapped[float] = mapped_column(Float, nullable=False)
2478 is_success: Mapped[bool] = mapped_column(Boolean, nullable=False)
2479 error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
2481 # Relationship back to the Tool model.
2482 tool: Mapped["Tool"] = relationship("Tool", back_populates="metrics")
2485class ResourceMetric(Base):
2486 """
2487 ORM model for recording metrics for resource invocations.
2489 Attributes:
2490 id (int): Primary key.
2491 resource_id (str): Foreign key linking to the resource.
2492 timestamp (datetime): The time when the invocation occurred.
2493 response_time (float): The response time in seconds.
2494 is_success (bool): True if the invocation succeeded, False otherwise.
2495 error_message (Optional[str]): Error message if the invocation failed.
2496 """
2498 __tablename__ = "resource_metrics"
2500 id: Mapped[int] = mapped_column(primary_key=True)
2501 resource_id: Mapped[str] = mapped_column(String(36), ForeignKey("resources.id"), nullable=False, index=True)
2502 timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, index=True)
2503 response_time: Mapped[float] = mapped_column(Float, nullable=False)
2504 is_success: Mapped[bool] = mapped_column(Boolean, nullable=False)
2505 error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
2507 # Relationship back to the Resource model.
2508 resource: Mapped["Resource"] = relationship("Resource", back_populates="metrics")
2511class ServerMetric(Base):
2512 """
2513 ORM model for recording metrics for server invocations.
2515 Attributes:
2516 id (int): Primary key.
2517 server_id (str): Foreign key linking to the server.
2518 timestamp (datetime): The time when the invocation occurred.
2519 response_time (float): The response time in seconds.
2520 is_success (bool): True if the invocation succeeded, False otherwise.
2521 error_message (Optional[str]): Error message if the invocation failed.
2522 """
2524 __tablename__ = "server_metrics"
2526 id: Mapped[int] = mapped_column(primary_key=True)
2527 server_id: Mapped[str] = mapped_column(String(36), ForeignKey("servers.id"), nullable=False, index=True)
2528 timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, index=True)
2529 response_time: Mapped[float] = mapped_column(Float, nullable=False)
2530 is_success: Mapped[bool] = mapped_column(Boolean, nullable=False)
2531 error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
2533 # Relationship back to the Server model.
2534 server: Mapped["Server"] = relationship("Server", back_populates="metrics")
2537class PromptMetric(Base):
2538 """
2539 ORM model for recording metrics for prompt invocations.
2541 Attributes:
2542 id (int): Primary key.
2543 prompt_id (str): Foreign key linking to the prompt.
2544 timestamp (datetime): The time when the invocation occurred.
2545 response_time (float): The response time in seconds.
2546 is_success (bool): True if the invocation succeeded, False otherwise.
2547 error_message (Optional[str]): Error message if the invocation failed.
2548 """
2550 __tablename__ = "prompt_metrics"
2552 id: Mapped[int] = mapped_column(primary_key=True)
2553 prompt_id: Mapped[str] = mapped_column(String(36), ForeignKey("prompts.id"), nullable=False, index=True)
2554 timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, index=True)
2555 response_time: Mapped[float] = mapped_column(Float, nullable=False)
2556 is_success: Mapped[bool] = mapped_column(Boolean, nullable=False)
2557 error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
2559 # Relationship back to the Prompt model.
2560 prompt: Mapped["Prompt"] = relationship("Prompt", back_populates="metrics")
2563class A2AAgentMetric(Base):
2564 """
2565 ORM model for recording metrics for A2A agent interactions.
2567 Attributes:
2568 id (int): Primary key.
2569 a2a_agent_id (str): Foreign key linking to the A2A agent.
2570 timestamp (datetime): The time when the interaction occurred.
2571 response_time (float): The response time in seconds.
2572 is_success (bool): True if the interaction succeeded, False otherwise.
2573 error_message (Optional[str]): Error message if the interaction failed.
2574 interaction_type (str): Type of interaction (invoke, query, etc.).
2575 """
2577 __tablename__ = "a2a_agent_metrics"
2579 id: Mapped[int] = mapped_column(primary_key=True)
2580 a2a_agent_id: Mapped[str] = mapped_column(String(36), ForeignKey("a2a_agents.id"), nullable=False, index=True)
2581 timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, index=True)
2582 response_time: Mapped[float] = mapped_column(Float, nullable=False)
2583 is_success: Mapped[bool] = mapped_column(Boolean, nullable=False)
2584 error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
2585 interaction_type: Mapped[str] = mapped_column(String(50), nullable=False, default="invoke")
2587 # Relationship back to the A2AAgent model.
2588 a2a_agent: Mapped["A2AAgent"] = relationship("A2AAgent", back_populates="metrics")
2591# ===================================
2592# Metrics Hourly Rollup Tables
2593# These tables store pre-aggregated hourly summaries for efficient historical queries.
2594# Raw metrics can be cleaned up after rollup, reducing storage while preserving trends.
2595# ===================================
2598class ToolMetricsHourly(Base):
2599 """
2600 Hourly rollup of tool metrics for efficient historical trend analysis.
2602 This table stores pre-aggregated metrics per tool per hour, enabling fast
2603 queries for dashboards and reports without scanning millions of raw metrics.
2605 Attributes:
2606 id: Primary key.
2607 tool_id: Foreign key to the tool (nullable for deleted tools).
2608 tool_name: Tool name snapshot (preserved even if tool is deleted).
2609 hour_start: Start of the aggregation hour (UTC).
2610 total_count: Total invocations during this hour.
2611 success_count: Successful invocations.
2612 failure_count: Failed invocations.
2613 min_response_time: Minimum response time in seconds.
2614 max_response_time: Maximum response time in seconds.
2615 avg_response_time: Average response time in seconds.
2616 p50_response_time: 50th percentile (median) response time.
2617 p95_response_time: 95th percentile response time.
2618 p99_response_time: 99th percentile response time.
2619 created_at: When this rollup was created.
2620 """
2622 __tablename__ = "tool_metrics_hourly"
2623 __table_args__ = (
2624 UniqueConstraint("tool_id", "hour_start", name="uq_tool_metrics_hourly_tool_hour"),
2625 Index("ix_tool_metrics_hourly_hour_start", "hour_start"),
2626 )
2628 id: Mapped[int] = mapped_column(primary_key=True)
2629 tool_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("tools.id", ondelete="SET NULL"), nullable=True, index=True)
2630 tool_name: Mapped[str] = mapped_column(String(255), nullable=False)
2631 hour_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
2632 total_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
2633 success_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
2634 failure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
2635 min_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2636 max_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2637 avg_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2638 p50_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2639 p95_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2640 p99_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2641 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
2644class ResourceMetricsHourly(Base):
2645 """Hourly rollup of resource metrics for efficient historical trend analysis."""
2647 __tablename__ = "resource_metrics_hourly"
2648 __table_args__ = (
2649 UniqueConstraint("resource_id", "hour_start", name="uq_resource_metrics_hourly_resource_hour"),
2650 Index("ix_resource_metrics_hourly_hour_start", "hour_start"),
2651 )
2653 id: Mapped[int] = mapped_column(primary_key=True)
2654 resource_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("resources.id", ondelete="SET NULL"), nullable=True, index=True)
2655 resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
2656 hour_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
2657 total_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
2658 success_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
2659 failure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
2660 min_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2661 max_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2662 avg_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2663 p50_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2664 p95_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2665 p99_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2666 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
2669class PromptMetricsHourly(Base):
2670 """Hourly rollup of prompt metrics for efficient historical trend analysis."""
2672 __tablename__ = "prompt_metrics_hourly"
2673 __table_args__ = (
2674 UniqueConstraint("prompt_id", "hour_start", name="uq_prompt_metrics_hourly_prompt_hour"),
2675 Index("ix_prompt_metrics_hourly_hour_start", "hour_start"),
2676 )
2678 id: Mapped[int] = mapped_column(primary_key=True)
2679 prompt_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("prompts.id", ondelete="SET NULL"), nullable=True, index=True)
2680 prompt_name: Mapped[str] = mapped_column(String(255), nullable=False)
2681 hour_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
2682 total_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
2683 success_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
2684 failure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
2685 min_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2686 max_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2687 avg_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2688 p50_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2689 p95_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2690 p99_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2691 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
2694class ServerMetricsHourly(Base):
2695 """Hourly rollup of server metrics for efficient historical trend analysis."""
2697 __tablename__ = "server_metrics_hourly"
2698 __table_args__ = (
2699 UniqueConstraint("server_id", "hour_start", name="uq_server_metrics_hourly_server_hour"),
2700 Index("ix_server_metrics_hourly_hour_start", "hour_start"),
2701 )
2703 id: Mapped[int] = mapped_column(primary_key=True)
2704 server_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("servers.id", ondelete="SET NULL"), nullable=True, index=True)
2705 server_name: Mapped[str] = mapped_column(String(255), nullable=False)
2706 hour_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
2707 total_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
2708 success_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
2709 failure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
2710 min_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2711 max_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2712 avg_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2713 p50_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2714 p95_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2715 p99_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2716 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
2719class A2AAgentMetricsHourly(Base):
2720 """Hourly rollup of A2A agent metrics for efficient historical trend analysis."""
2722 __tablename__ = "a2a_agent_metrics_hourly"
2723 __table_args__ = (
2724 UniqueConstraint("a2a_agent_id", "hour_start", "interaction_type", name="uq_a2a_agent_metrics_hourly_agent_hour_type"),
2725 Index("ix_a2a_agent_metrics_hourly_hour_start", "hour_start"),
2726 )
2728 id: Mapped[int] = mapped_column(primary_key=True)
2729 a2a_agent_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("a2a_agents.id", ondelete="SET NULL"), nullable=True, index=True)
2730 agent_name: Mapped[str] = mapped_column(String(255), nullable=False)
2731 hour_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
2732 interaction_type: Mapped[str] = mapped_column(String(50), nullable=False, default="invoke")
2733 total_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
2734 success_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
2735 failure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
2736 min_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2737 max_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2738 avg_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2739 p50_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2740 p95_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2741 p99_response_time: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2742 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
2745# ===================================
2746# Observability Models (OpenTelemetry-style traces, spans, events)
2747# ===================================
2750class ObservabilityTrace(Base):
2751 """
2752 ORM model for observability traces (similar to OpenTelemetry traces).
2754 A trace represents a complete request flow through the system. It contains
2755 one or more spans representing individual operations.
2757 Attributes:
2758 trace_id (str): Unique trace identifier (UUID or OpenTelemetry trace ID format).
2759 name (str): Human-readable name for the trace (e.g., "POST /tools/invoke").
2760 start_time (datetime): When the trace started.
2761 end_time (datetime): When the trace ended (optional, set when completed).
2762 duration_ms (float): Total duration in milliseconds.
2763 status (str): Trace status (success, error, timeout).
2764 status_message (str): Optional status message or error description.
2765 http_method (str): HTTP method for the request (GET, POST, etc.).
2766 http_url (str): Full URL of the request.
2767 http_status_code (int): HTTP response status code.
2768 user_email (str): User who initiated the request (if authenticated).
2769 user_agent (str): Client user agent string.
2770 ip_address (str): Client IP address.
2771 attributes (dict): Additional trace attributes (JSON).
2772 resource_attributes (dict): Resource attributes (service name, version, etc.).
2773 created_at (datetime): Trace creation timestamp.
2774 """
2776 __tablename__ = "observability_traces"
2778 # Primary key
2779 trace_id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
2781 # Trace metadata
2782 name: Mapped[str] = mapped_column(String(255), nullable=False)
2783 start_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
2784 end_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
2785 duration_ms: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2786 status: Mapped[str] = mapped_column(String(20), nullable=False, default="unset") # unset, ok, error
2787 status_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
2789 # HTTP request context
2790 http_method: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
2791 http_url: Mapped[Optional[str]] = mapped_column(String(767), nullable=True)
2792 http_status_code: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
2794 # User context
2795 user_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
2796 user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
2797 ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
2799 # Attributes (flexible key-value storage)
2800 attributes: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True, default=dict)
2801 resource_attributes: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True, default=dict)
2803 # Timestamps
2804 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
2806 # Relationships
2807 spans: Mapped[List["ObservabilitySpan"]] = relationship("ObservabilitySpan", back_populates="trace", cascade="all, delete-orphan")
2809 # Indexes for performance
2810 __table_args__ = (
2811 Index("idx_observability_traces_start_time", "start_time"),
2812 Index("idx_observability_traces_user_email", "user_email"),
2813 Index("idx_observability_traces_status", "status"),
2814 Index("idx_observability_traces_http_status_code", "http_status_code"),
2815 )
2818class ObservabilitySpan(Base):
2819 """
2820 ORM model for observability spans (similar to OpenTelemetry spans).
2822 A span represents a single operation within a trace. Spans can be nested
2823 to represent hierarchical operations.
2825 Attributes:
2826 span_id (str): Unique span identifier.
2827 trace_id (str): Parent trace ID.
2828 parent_span_id (str): Parent span ID (for nested spans).
2829 name (str): Span name (e.g., "database_query", "tool_invocation").
2830 kind (str): Span kind (internal, server, client, producer, consumer).
2831 start_time (datetime): When the span started.
2832 end_time (datetime): When the span ended.
2833 duration_ms (float): Span duration in milliseconds.
2834 status (str): Span status (success, error).
2835 status_message (str): Optional status message.
2836 attributes (dict): Span attributes (JSON).
2837 resource_name (str): Name of the resource being operated on.
2838 resource_type (str): Type of resource (tool, resource, prompt, gateway, etc.).
2839 resource_id (str): ID of the specific resource.
2840 created_at (datetime): Span creation timestamp.
2841 """
2843 __tablename__ = "observability_spans"
2845 # Primary key
2846 span_id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
2848 # Trace relationship
2849 trace_id: Mapped[str] = mapped_column(String(36), ForeignKey("observability_traces.trace_id", ondelete="CASCADE"), nullable=False, index=True)
2850 parent_span_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("observability_spans.span_id", ondelete="CASCADE"), nullable=True, index=True)
2852 # Span metadata
2853 name: Mapped[str] = mapped_column(String(255), nullable=False)
2854 kind: Mapped[str] = mapped_column(String(20), nullable=False, default="internal") # internal, server, client, producer, consumer
2855 start_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
2856 end_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
2857 duration_ms: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
2858 status: Mapped[str] = mapped_column(String(20), nullable=False, default="unset")
2859 status_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
2861 # Attributes
2862 attributes: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True, default=dict)
2864 # Resource context
2865 resource_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
2866 resource_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, index=True) # tool, resource, prompt, gateway, a2a_agent
2867 resource_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True, index=True)
2869 # Timestamps
2870 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
2872 # Relationships
2873 trace: Mapped["ObservabilityTrace"] = relationship("ObservabilityTrace", back_populates="spans")
2874 parent_span: Mapped[Optional["ObservabilitySpan"]] = relationship("ObservabilitySpan", remote_side=[span_id], backref="child_spans")
2875 events: Mapped[List["ObservabilityEvent"]] = relationship("ObservabilityEvent", back_populates="span", cascade="all, delete-orphan")
2877 # Indexes for performance
2878 __table_args__ = (
2879 Index("idx_observability_spans_trace_id", "trace_id"),
2880 Index("idx_observability_spans_parent_span_id", "parent_span_id"),
2881 Index("idx_observability_spans_start_time", "start_time"),
2882 Index("idx_observability_spans_resource_type", "resource_type"),
2883 Index("idx_observability_spans_resource_name", "resource_name"),
2884 )
2887class ObservabilityEvent(Base):
2888 """
2889 ORM model for observability events (logs within spans).
2891 Events represent discrete occurrences within a span, such as log messages,
2892 exceptions, or state changes.
2894 Attributes:
2895 id (int): Auto-incrementing primary key.
2896 span_id (str): Parent span ID.
2897 name (str): Event name (e.g., "exception", "log", "checkpoint").
2898 timestamp (datetime): When the event occurred.
2899 attributes (dict): Event attributes (JSON).
2900 severity (str): Log severity level (debug, info, warning, error, critical).
2901 message (str): Event message.
2902 exception_type (str): Exception class name (if event is an exception).
2903 exception_message (str): Exception message.
2904 exception_stacktrace (str): Exception stacktrace.
2905 created_at (datetime): Event creation timestamp.
2906 """
2908 __tablename__ = "observability_events"
2910 # Primary key
2911 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
2913 # Span relationship
2914 span_id: Mapped[str] = mapped_column(String(36), ForeignKey("observability_spans.span_id", ondelete="CASCADE"), nullable=False, index=True)
2916 # Event metadata
2917 name: Mapped[str] = mapped_column(String(255), nullable=False)
2918 timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utc_now, index=True)
2919 attributes: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True, default=dict)
2921 # Log fields
2922 severity: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, index=True) # debug, info, warning, error, critical
2923 message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
2925 # Exception fields
2926 exception_type: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
2927 exception_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
2928 exception_stacktrace: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
2930 # Timestamps
2931 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
2933 # Relationships
2934 span: Mapped["ObservabilitySpan"] = relationship("ObservabilitySpan", back_populates="events")
2936 # Indexes for performance
2937 __table_args__ = (
2938 Index("idx_observability_events_span_id", "span_id"),
2939 Index("idx_observability_events_timestamp", "timestamp"),
2940 Index("idx_observability_events_severity", "severity"),
2941 )
2944class ObservabilityMetric(Base):
2945 """
2946 ORM model for observability metrics (time-series numerical data).
2948 Metrics represent numerical measurements over time, such as request rates,
2949 error rates, latencies, and custom business metrics.
2951 Attributes:
2952 id (int): Auto-incrementing primary key.
2953 name (str): Metric name (e.g., "http.request.duration", "tool.invocation.count").
2954 metric_type (str): Metric type (counter, gauge, histogram).
2955 value (float): Metric value.
2956 timestamp (datetime): When the metric was recorded.
2957 unit (str): Metric unit (ms, count, bytes, etc.).
2958 attributes (dict): Metric attributes/labels (JSON).
2959 resource_type (str): Type of resource (tool, resource, prompt, etc.).
2960 resource_id (str): ID of the specific resource.
2961 trace_id (str): Associated trace ID (optional).
2962 created_at (datetime): Metric creation timestamp.
2963 """
2965 __tablename__ = "observability_metrics"
2967 # Primary key
2968 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
2970 # Metric metadata
2971 name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
2972 metric_type: Mapped[str] = mapped_column(String(20), nullable=False) # counter, gauge, histogram
2973 value: Mapped[float] = mapped_column(Float, nullable=False)
2974 timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utc_now, index=True)
2975 unit: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
2977 # Attributes/labels
2978 attributes: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True, default=dict)
2980 # Resource context
2981 resource_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, index=True)
2982 resource_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True, index=True)
2984 # Trace association (optional)
2985 trace_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("observability_traces.trace_id", ondelete="SET NULL"), nullable=True, index=True)
2987 # Timestamps
2988 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
2990 # Indexes for performance
2991 __table_args__ = (
2992 Index("idx_observability_metrics_name_timestamp", "name", "timestamp"),
2993 Index("idx_observability_metrics_resource_type", "resource_type"),
2994 Index("idx_observability_metrics_trace_id", "trace_id"),
2995 )
2998class ObservabilitySavedQuery(Base):
2999 """
3000 ORM model for saved observability queries (filter presets).
3002 Allows users to save their filter configurations for quick access and
3003 historical query tracking. Queries can be personal or shared with the team.
3005 Attributes:
3006 id (int): Auto-incrementing primary key.
3007 name (str): User-given name for the saved query.
3008 description (str): Optional description of what this query finds.
3009 user_email (str): Email of the user who created this query.
3010 filter_config (dict): JSON containing all filter values (time_range, status_filter, etc.).
3011 is_shared (bool): Whether this query is visible to other users.
3012 created_at (datetime): When the query was created.
3013 updated_at (datetime): When the query was last modified.
3014 last_used_at (datetime): When the query was last executed.
3015 use_count (int): How many times this query has been used.
3016 """
3018 __tablename__ = "observability_saved_queries"
3020 # Primary key
3021 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
3023 # Query metadata
3024 name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
3025 description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
3026 user_email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
3028 # Filter configuration (stored as JSON)
3029 filter_config: Mapped[Dict[str, Any]] = mapped_column(JSON, nullable=False)
3031 # Sharing settings
3032 is_shared: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
3034 # Timestamps and usage tracking
3035 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
3036 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now, nullable=False)
3037 last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
3038 use_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
3040 # Indexes for performance
3041 __table_args__ = (
3042 Index("idx_observability_saved_queries_user_email", "user_email"),
3043 Index("idx_observability_saved_queries_is_shared", "is_shared"),
3044 Index("idx_observability_saved_queries_created_at", "created_at"),
3045 )
3048# ---------------------------------------------------------------------------
3049# Performance Monitoring Models
3050# ---------------------------------------------------------------------------
3053class PerformanceSnapshot(Base):
3054 """
3055 ORM model for point-in-time performance snapshots.
3057 Stores comprehensive system, request, and worker metrics at regular intervals
3058 for historical analysis and trend detection.
3060 Attributes:
3061 id (int): Auto-incrementing primary key.
3062 timestamp (datetime): When the snapshot was taken.
3063 host (str): Hostname of the machine.
3064 worker_id (str): Worker identifier (PID or UUID).
3065 metrics_json (dict): JSON blob containing all metrics data.
3066 created_at (datetime): Record creation timestamp.
3067 """
3069 __tablename__ = "performance_snapshots"
3071 # Primary key
3072 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
3074 # Snapshot metadata
3075 timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False, index=True)
3076 host: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
3077 worker_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
3079 # Metrics data (JSON blob)
3080 metrics_json: Mapped[Dict[str, Any]] = mapped_column(JSON, nullable=False)
3082 # Timestamps
3083 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
3085 # Indexes for efficient querying
3086 __table_args__ = (
3087 Index("idx_performance_snapshots_timestamp", "timestamp"),
3088 Index("idx_performance_snapshots_host_timestamp", "host", "timestamp"),
3089 Index("idx_performance_snapshots_created_at", "created_at"),
3090 )
3093class PerformanceAggregate(Base):
3094 """
3095 ORM model for aggregated performance metrics.
3097 Stores hourly and daily aggregations of performance data for efficient
3098 historical reporting and trend analysis.
3100 Attributes:
3101 id (int): Auto-incrementing primary key.
3102 period_start (datetime): Start of the aggregation period.
3103 period_end (datetime): End of the aggregation period.
3104 period_type (str): Type of aggregation (hourly, daily).
3105 host (str): Hostname (None for cluster-wide aggregates).
3106 Various aggregate metrics for requests and resources.
3107 created_at (datetime): Record creation timestamp.
3108 """
3110 __tablename__ = "performance_aggregates"
3112 # Primary key
3113 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
3115 # Period metadata
3116 period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
3117 period_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
3118 period_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) # hourly, daily
3119 host: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
3121 # Request aggregates
3122 requests_total: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
3123 requests_2xx: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
3124 requests_4xx: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
3125 requests_5xx: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
3126 avg_response_time_ms: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
3127 p95_response_time_ms: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
3128 peak_requests_per_second: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
3130 # Resource aggregates
3131 avg_cpu_percent: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
3132 avg_memory_percent: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
3133 peak_cpu_percent: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
3134 peak_memory_percent: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
3136 # Timestamps
3137 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
3139 # Indexes and constraints
3140 __table_args__ = (
3141 Index("idx_performance_aggregates_period", "period_type", "period_start"),
3142 Index("idx_performance_aggregates_host_period", "host", "period_type", "period_start"),
3143 UniqueConstraint("period_type", "period_start", "host", name="uq_performance_aggregate_period_host"),
3144 )
3147class Tool(Base):
3148 """
3149 ORM model for a registered Tool.
3151 Supports both local tools and federated tools from other gateways.
3152 The integration_type field indicates the tool format:
3153 - "MCP" for MCP-compliant tools (default)
3154 - "REST" for REST tools
3156 Additionally, this model provides computed properties for aggregated metrics based
3157 on the associated ToolMetric records. These include:
3158 - execution_count: Total number of invocations.
3159 - successful_executions: Count of successful invocations.
3160 - failed_executions: Count of failed invocations.
3161 - failure_rate: Ratio of failed invocations to total invocations.
3162 - min_response_time: Fastest recorded response time.
3163 - max_response_time: Slowest recorded response time.
3164 - avg_response_time: Mean response time.
3165 - last_execution_time: Timestamp of the most recent invocation.
3167 The property `metrics_summary` returns a dictionary with these aggregated values.
3169 Team association is handled via the `email_team` relationship (default lazy loading)
3170 which only includes active teams. For list operations, use explicit joinedload()
3171 to eager load team names. The `team` property provides convenient access to
3172 the team name:
3173 - team: Returns the team name if the tool belongs to an active team, otherwise None.
3175 The following fields have been added to support tool invocation configuration:
3176 - request_type: HTTP method to use when invoking the tool.
3177 - auth_type: Type of authentication ("basic", "bearer", or None).
3178 - auth_username: Username for basic authentication.
3179 - auth_password: Password for basic authentication.
3180 - auth_token: Token for bearer token authentication.
3181 - auth_header_key: header key for authentication.
3182 - auth_header_value: header value for authentication.
3183 """
3185 __tablename__ = "tools"
3187 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
3188 original_name: Mapped[str] = mapped_column(String(255), nullable=False)
3189 url: Mapped[str] = mapped_column(String(767), nullable=True)
3190 original_description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
3191 description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
3192 integration_type: Mapped[str] = mapped_column(String(20), default="MCP")
3193 request_type: Mapped[str] = mapped_column(String(20), default="SSE")
3194 headers: Mapped[Optional[Dict[str, str]]] = mapped_column(JSON)
3195 input_schema: Mapped[Dict[str, Any]] = mapped_column(JSON)
3196 output_schema: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
3197 annotations: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, default=lambda: {})
3198 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
3199 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
3200 enabled: Mapped[bool] = mapped_column(default=True)
3201 reachable: Mapped[bool] = mapped_column(default=True)
3202 jsonpath_filter: Mapped[str] = mapped_column(Text, default="")
3203 tags: Mapped[List[str]] = mapped_column(JSON, default=list, nullable=False)
3205 # Comprehensive metadata for audit tracking
3206 created_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3207 created_from_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
3208 created_via: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
3209 created_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
3211 modified_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3212 modified_from_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
3213 modified_via: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
3214 modified_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
3216 import_batch_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
3217 federation_source: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3218 version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
3220 # Request type and authentication fields
3221 auth_type: Mapped[Optional[str]] = mapped_column(String(20), default=None) # "basic", "bearer", or None
3222 auth_value: Mapped[Optional[str]] = mapped_column(Text, default=None)
3224 # custom_name,custom_name_slug, display_name
3225 custom_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=False)
3226 custom_name_slug: Mapped[Optional[str]] = mapped_column(String(255), nullable=False)
3227 display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3228 title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3230 # Passthrough REST fields
3231 base_url: Mapped[Optional[str]] = mapped_column(String, nullable=True)
3232 path_template: Mapped[Optional[str]] = mapped_column(String, nullable=True)
3233 query_mapping: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
3234 header_mapping: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
3235 timeout_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, default=None)
3236 expose_passthrough: Mapped[bool] = mapped_column(Boolean, default=True)
3237 allowlist: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)
3238 plugin_chain_pre: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)
3239 plugin_chain_post: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)
3241 # Federation relationship with a local gateway
3242 gateway_id: Mapped[Optional[str]] = mapped_column(ForeignKey("gateways.id", ondelete="CASCADE"))
3243 # gateway_slug: Mapped[Optional[str]] = mapped_column(ForeignKey("gateways.slug"))
3244 gateway: Mapped["Gateway"] = relationship("Gateway", primaryjoin="Tool.gateway_id == Gateway.id", foreign_keys=[gateway_id], back_populates="tools")
3245 # federated_with = relationship("Gateway", secondary=tool_gateway_table, back_populates="federated_tools")
3247 # Many-to-many relationship with Servers
3248 servers: Mapped[List["Server"]] = relationship("Server", secondary=server_tool_association, back_populates="tools")
3250 # Relationship with ToolMetric records
3251 metrics: Mapped[List["ToolMetric"]] = relationship("ToolMetric", back_populates="tool", cascade="all, delete-orphan")
3252 metrics_hourly: Mapped[List["ToolMetricsHourly"]] = relationship(
3253 "ToolMetricsHourly",
3254 primaryjoin="Tool.id == foreign(ToolMetricsHourly.tool_id)",
3255 viewonly=True,
3256 )
3258 # Team scoping fields for resource organization
3259 team_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("email_teams.id", ondelete="SET NULL"), nullable=True)
3260 owner_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3261 visibility: Mapped[str] = mapped_column(String(20), nullable=False, default="public")
3263 # Relationship for loading team names (only active teams)
3264 # Uses default lazy loading - team name is only loaded when accessed
3265 # For list/admin views, use explicit joinedload(DbTool.email_team) for single-query loading
3266 # This avoids adding overhead to hot paths like tool invocation that don't need team names
3267 email_team: Mapped[Optional["EmailTeam"]] = relationship(
3268 "EmailTeam",
3269 primaryjoin="and_(Tool.team_id == EmailTeam.id, EmailTeam.is_active == True)",
3270 foreign_keys=[team_id],
3271 )
3273 @property
3274 def team(self) -> Optional[str]:
3275 """Return the team name from the eagerly-loaded email_team relationship.
3277 Returns:
3278 Optional[str]: The team name if the tool belongs to an active team, otherwise None.
3279 """
3280 return self.email_team.name if self.email_team else None
3282 # @property
3283 # def gateway_slug(self) -> str:
3284 # return self.gateway.slug
3286 _computed_name: Mapped[str] = mapped_column("name", String(255), nullable=False) # Stored column
3288 @hybrid_property
3289 def name(self) -> str:
3290 """Return the display/lookup name computed from gateway and custom slug.
3292 Returns:
3293 str: Display/lookup name to use for this tool.
3294 """
3295 # Instance access resolves Column to Python value; cast ensures static acceptance
3296 if getattr(self, "_computed_name", None):
3297 return cast(str, getattr(self, "_computed_name"))
3298 custom_name_slug = slugify(getattr(self, "custom_name_slug"))
3299 if getattr(self, "gateway_id", None):
3300 gateway_slug = slugify(self.gateway.name) # type: ignore[attr-defined]
3301 return f"{gateway_slug}{settings.gateway_tool_name_separator}{custom_name_slug}"
3302 return custom_name_slug
3304 @name.setter
3305 def name(self, value: str) -> None:
3306 """Setter for the stored name column.
3308 Args:
3309 value: Explicit name to persist to the underlying column.
3310 """
3311 setattr(self, "_computed_name", value)
3313 @name.expression
3314 @classmethod
3315 def name(cls) -> Any:
3316 """SQL expression for name used in queries (backs onto stored column).
3318 Returns:
3319 Any: SQLAlchemy expression referencing the stored name column.
3320 """
3321 return cls._computed_name
3323 __table_args__ = (
3324 UniqueConstraint("gateway_id", "original_name", name="uq_gateway_id__original_name"),
3325 UniqueConstraint("team_id", "owner_email", "name", name="uq_team_owner_email_name_tool"),
3326 Index("idx_tools_created_at_id", "created_at", "id"),
3327 )
3329 @hybrid_property
3330 def gateway_slug(self) -> Optional[str]:
3331 """Python accessor returning the related gateway's slug if available.
3333 Returns:
3334 Optional[str]: The gateway slug, or None if no gateway relation.
3335 """
3336 return self.gateway.slug if self.gateway else None
3338 @gateway_slug.expression
3339 @classmethod
3340 def gateway_slug(cls) -> Any:
3341 """SQL expression to select current gateway slug for this tool.
3343 Returns:
3344 Any: SQLAlchemy scalar subquery selecting the gateway slug.
3345 """
3346 return select(Gateway.slug).where(Gateway.id == cls.gateway_id).scalar_subquery()
3348 def _metrics_loaded(self) -> bool:
3349 """Check if metrics relationship is loaded without triggering lazy load.
3351 Returns:
3352 bool: True if metrics are loaded, False otherwise.
3353 """
3354 return "metrics" in sa_inspect(self).dict
3356 def _get_metric_counts(self) -> tuple[int, int, int]:
3357 """Get total, successful, and failed metric counts in a single operation.
3359 When metrics are already loaded, computes from memory in O(n).
3360 When not loaded, uses a single SQL query with conditional aggregation.
3362 Note: For bulk operations, use metrics_summary which computes all fields
3363 in a single pass, or ensure metrics are preloaded via selectinload.
3365 Returns:
3366 tuple[int, int, int]: (total, successful, failed) counts.
3367 """
3368 # If metrics are loaded, compute from memory in a single pass
3369 if self._metrics_loaded():
3370 total = 0
3371 successful = 0
3372 for m in self.metrics:
3373 total += 1
3374 if m.is_success:
3375 successful += 1
3376 return (total, successful, total - successful)
3378 # Use single SQL query with conditional aggregation
3379 # Third-Party
3380 from sqlalchemy import case # pylint: disable=import-outside-toplevel
3381 from sqlalchemy.orm import object_session # pylint: disable=import-outside-toplevel
3383 session = object_session(self)
3384 if session is None:
3385 return (0, 0, 0)
3387 result = (
3388 session.query(
3389 func.count(ToolMetric.id), # pylint: disable=not-callable
3390 func.sum(case((ToolMetric.is_success.is_(True), 1), else_=0)),
3391 )
3392 .filter(ToolMetric.tool_id == self.id)
3393 .one()
3394 )
3396 total = result[0] or 0
3397 successful = result[1] or 0
3398 return (total, successful, total - successful)
3400 @hybrid_property
3401 def execution_count(self) -> int:
3402 """Number of ToolMetric records associated with this tool instance.
3404 Note: Each property access may trigger a SQL query if metrics aren't loaded.
3405 For reading multiple metric fields, use metrics_summary or preload metrics.
3407 Returns:
3408 int: Count of ToolMetric records for this tool.
3409 """
3410 return self._get_metric_counts()[0]
3412 @execution_count.expression
3413 @classmethod
3414 def execution_count(cls) -> Any:
3415 """SQL expression that counts ToolMetric rows for this tool.
3417 Returns:
3418 Any: SQLAlchemy labeled count expression for tool metrics.
3419 """
3420 return select(func.count(ToolMetric.id)).where(ToolMetric.tool_id == cls.id).correlate(cls).scalar_subquery().label("execution_count") # pylint: disable=not-callable
3422 @property
3423 def successful_executions(self) -> int:
3424 """Count of successful tool executions.
3426 Returns:
3427 int: The count of successful tool executions.
3428 """
3429 return self._get_metric_counts()[1]
3431 @property
3432 def failed_executions(self) -> int:
3433 """Count of failed tool executions.
3435 Returns:
3436 int: The count of failed tool executions.
3437 """
3438 return self._get_metric_counts()[2]
3440 @property
3441 def failure_rate(self) -> float:
3442 """Failure rate as a float between 0 and 1.
3444 Returns:
3445 float: The failure rate as a value between 0 and 1.
3446 """
3447 total, _, failed = self._get_metric_counts()
3448 return failed / total if total > 0 else 0.0
3450 @property
3451 def min_response_time(self) -> Optional[float]:
3452 """Minimum response time among all tool executions.
3454 Returns None if metrics are not loaded (use metrics_summary for SQL fallback).
3456 Returns:
3457 Optional[float]: The minimum response time, or None.
3458 """
3459 if not self._metrics_loaded():
3460 return None
3461 times: List[float] = [m.response_time for m in self.metrics]
3462 return min(times) if times else None
3464 @property
3465 def max_response_time(self) -> Optional[float]:
3466 """Maximum response time among all tool executions.
3468 Returns None if metrics are not loaded (use metrics_summary for SQL fallback).
3470 Returns:
3471 Optional[float]: The maximum response time, or None.
3472 """
3473 if not self._metrics_loaded():
3474 return None
3475 times: List[float] = [m.response_time for m in self.metrics]
3476 return max(times) if times else None
3478 @property
3479 def avg_response_time(self) -> Optional[float]:
3480 """Average response time among all tool executions.
3482 Returns None if metrics are not loaded (use metrics_summary for SQL fallback).
3484 Returns:
3485 Optional[float]: The average response time, or None.
3486 """
3487 if not self._metrics_loaded():
3488 return None
3489 times: List[float] = [m.response_time for m in self.metrics]
3490 return sum(times) / len(times) if times else None
3492 @property
3493 def last_execution_time(self) -> Optional[datetime]:
3494 """Timestamp of the most recent tool execution.
3496 Returns None if metrics are not loaded (use metrics_summary for SQL fallback).
3498 Returns:
3499 Optional[datetime]: The timestamp of the most recent execution, or None.
3500 """
3501 if not self._metrics_loaded():
3502 return None
3503 if not self.metrics:
3504 return None
3505 return max(m.timestamp for m in self.metrics)
3507 @property
3508 def metrics_summary(self) -> Dict[str, Any]:
3509 """Aggregated metrics for the tool combining raw and hourly data without double-counting.
3511 When metrics are loaded: computes from memory (raw + hourly)
3512 When not loaded: uses SQL queries with time partitioning
3514 Returns:
3515 Dict[str, Any]: Dictionary containing aggregated metrics:
3516 - total_executions, successful_executions, failed_executions
3517 - failure_rate, min/max/avg_response_time, last_execution_time
3518 """
3519 # Try in-memory path first
3520 if self._metrics_loaded():
3521 try:
3522 hourly_metrics = self.metrics_hourly
3523 except AttributeError:
3524 hourly_metrics = [] # Relationship not loaded
3525 return _compute_metrics_summary(raw_metrics=self.metrics, hourly_metrics=hourly_metrics)
3527 # SQL query path
3528 # Third-Party
3529 from sqlalchemy.orm import object_session # pylint: disable=import-outside-toplevel
3531 session = object_session(self)
3532 if session is None:
3533 return {
3534 "total_executions": 0,
3535 "successful_executions": 0,
3536 "failed_executions": 0,
3537 "failure_rate": 0.0,
3538 "min_response_time": None,
3539 "max_response_time": None,
3540 "avg_response_time": None,
3541 "last_execution_time": None,
3542 }
3544 return _compute_metrics_summary(
3545 raw_metrics=None,
3546 hourly_metrics=None,
3547 session=session,
3548 entity_id=self.id,
3549 raw_metric_class=ToolMetric,
3550 hourly_metric_class=ToolMetricsHourly,
3551 )
3554class Resource(Base):
3555 """
3556 ORM model for a registered Resource.
3558 Resources represent content that can be read by clients.
3559 Supports subscriptions for real-time updates.
3560 Additionally, this model provides a relationship with ResourceMetric records
3561 to capture invocation metrics (such as execution counts, response times, and failures).
3562 """
3564 __tablename__ = "resources"
3566 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
3567 uri: Mapped[str] = mapped_column(String(767), nullable=False)
3568 name: Mapped[str] = mapped_column(String(255), nullable=False)
3569 description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
3570 title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3571 mime_type: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3572 size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
3573 uri_template: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # URI template for parameterized resources
3574 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
3575 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
3576 # is_active: Mapped[bool] = mapped_column(default=True)
3577 enabled: Mapped[bool] = mapped_column(default=True)
3578 tags: Mapped[List[str]] = mapped_column(JSON, default=list, nullable=False)
3580 # Comprehensive metadata for audit tracking
3581 created_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3582 created_from_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
3583 created_via: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
3584 created_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
3586 modified_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3587 modified_from_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
3588 modified_via: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
3589 modified_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
3591 import_batch_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
3592 federation_source: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3593 version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
3595 metrics: Mapped[List["ResourceMetric"]] = relationship("ResourceMetric", back_populates="resource", cascade="all, delete-orphan")
3596 metrics_hourly: Mapped[List["ResourceMetricsHourly"]] = relationship(
3597 "ResourceMetricsHourly",
3598 primaryjoin="Resource.id == foreign(ResourceMetricsHourly.resource_id)",
3599 viewonly=True,
3600 )
3602 # Content storage - can be text or binary
3603 text_content: Mapped[Optional[str]] = mapped_column(Text)
3604 binary_content: Mapped[Optional[bytes]]
3606 # Subscription tracking
3607 subscriptions: Mapped[List["ResourceSubscription"]] = relationship("ResourceSubscription", back_populates="resource", cascade="all, delete-orphan")
3609 gateway_id: Mapped[Optional[str]] = mapped_column(ForeignKey("gateways.id", ondelete="CASCADE"))
3610 gateway: Mapped["Gateway"] = relationship("Gateway", back_populates="resources")
3611 # federated_with = relationship("Gateway", secondary=resource_gateway_table, back_populates="federated_resources")
3613 # Many-to-many relationship with Servers
3614 servers: Mapped[List["Server"]] = relationship("Server", secondary=server_resource_association, back_populates="resources")
3615 __table_args__ = (
3616 UniqueConstraint("team_id", "owner_email", "gateway_id", "uri", name="uq_team_owner_gateway_uri_resource"),
3617 Index("uq_team_owner_uri_resource_local", "team_id", "owner_email", "uri", unique=True, postgresql_where=text("gateway_id IS NULL"), sqlite_where=text("gateway_id IS NULL")),
3618 Index("idx_resources_created_at_id", "created_at", "id"),
3619 )
3621 @property
3622 def content(self) -> "ResourceContent":
3623 """
3624 Returns the resource content in the appropriate format.
3626 If text content exists, returns a ResourceContent with text.
3627 Otherwise, if binary content exists, returns a ResourceContent with blob data.
3628 Raises a ValueError if no content is available.
3630 Returns:
3631 ResourceContent: The resource content with appropriate format (text or blob).
3633 Raises:
3634 ValueError: If the resource has no content available.
3636 Examples:
3637 >>> resource = Resource(uri="test://example", name="test")
3638 >>> resource.text_content = "Hello, World!"
3639 >>> content = resource.content
3640 >>> content.text
3641 'Hello, World!'
3642 >>> content.type
3643 'resource'
3645 >>> binary_resource = Resource(uri="test://binary", name="binary")
3646 >>> binary_resource.binary_content = b"\\x00\\x01\\x02"
3647 >>> binary_content = binary_resource.content
3648 >>> binary_content.blob
3649 b'\\x00\\x01\\x02'
3651 >>> empty_resource = Resource(uri="test://empty", name="empty")
3652 >>> try:
3653 ... empty_resource.content
3654 ... except ValueError as e:
3655 ... str(e)
3656 'Resource has no content'
3657 """
3659 # Local import to avoid circular import
3660 # First-Party
3661 from mcpgateway.common.models import ResourceContent # pylint: disable=import-outside-toplevel
3663 if self.text_content is not None:
3664 return ResourceContent(
3665 type="resource",
3666 id=str(self.id),
3667 uri=self.uri,
3668 mime_type=self.mime_type,
3669 text=self.text_content,
3670 )
3671 if self.binary_content is not None:
3672 return ResourceContent(
3673 type="resource",
3674 id=str(self.id),
3675 uri=self.uri,
3676 mime_type=self.mime_type or "application/octet-stream",
3677 blob=self.binary_content,
3678 )
3679 raise ValueError("Resource has no content")
3681 def _metrics_loaded(self) -> bool:
3682 """Check if metrics relationship is loaded without triggering lazy load.
3684 Returns:
3685 bool: True if metrics are loaded, False otherwise.
3686 """
3687 return "metrics" in sa_inspect(self).dict
3689 def _get_metric_counts(self) -> tuple[int, int, int]:
3690 """Get total, successful, and failed metric counts in a single operation.
3692 When metrics are already loaded, computes from memory in O(n).
3693 When not loaded, uses a single SQL query with conditional aggregation.
3695 Returns:
3696 tuple[int, int, int]: (total, successful, failed) counts.
3697 """
3698 if self._metrics_loaded():
3699 total = 0
3700 successful = 0
3701 for m in self.metrics:
3702 total += 1
3703 if m.is_success:
3704 successful += 1
3705 return (total, successful, total - successful)
3707 # Third-Party
3708 from sqlalchemy import case # pylint: disable=import-outside-toplevel
3709 from sqlalchemy.orm import object_session # pylint: disable=import-outside-toplevel
3711 session = object_session(self)
3712 if session is None:
3713 return (0, 0, 0)
3715 result = (
3716 session.query(
3717 func.count(ResourceMetric.id), # pylint: disable=not-callable
3718 func.sum(case((ResourceMetric.is_success.is_(True), 1), else_=0)),
3719 )
3720 .filter(ResourceMetric.resource_id == self.id)
3721 .one()
3722 )
3724 total = result[0] or 0
3725 successful = result[1] or 0
3726 return (total, successful, total - successful)
3728 @hybrid_property
3729 def execution_count(self) -> int:
3730 """Number of ResourceMetric records associated with this resource instance.
3732 Returns:
3733 int: Count of ResourceMetric records for this resource.
3734 """
3735 return self._get_metric_counts()[0]
3737 @execution_count.expression
3738 @classmethod
3739 def execution_count(cls) -> Any:
3740 """SQL expression that counts ResourceMetric rows for this resource.
3742 Returns:
3743 Any: SQLAlchemy labeled count expression for resource metrics.
3744 """
3745 return select(func.count(ResourceMetric.id)).where(ResourceMetric.resource_id == cls.id).correlate(cls).scalar_subquery().label("execution_count") # pylint: disable=not-callable
3747 @property
3748 def successful_executions(self) -> int:
3749 """Count of successful resource invocations.
3751 Returns:
3752 int: The count of successful resource invocations.
3753 """
3754 return self._get_metric_counts()[1]
3756 @property
3757 def failed_executions(self) -> int:
3758 """Count of failed resource invocations.
3760 Returns:
3761 int: The count of failed resource invocations.
3762 """
3763 return self._get_metric_counts()[2]
3765 @property
3766 def failure_rate(self) -> float:
3767 """Failure rate as a float between 0 and 1.
3769 Returns:
3770 float: The failure rate as a value between 0 and 1.
3771 """
3772 total, _, failed = self._get_metric_counts()
3773 return failed / total if total > 0 else 0.0
3775 @property
3776 def min_response_time(self) -> Optional[float]:
3777 """Minimum response time among all resource invocations.
3779 Returns None if metrics are not loaded. Note: counts may be non-zero
3780 (via SQL) while timing is None. Use service layer converters for
3781 consistent metrics, or preload metrics via selectinload.
3783 Returns:
3784 Optional[float]: The minimum response time, or None.
3785 """
3786 if not self._metrics_loaded():
3787 return None
3788 times: List[float] = [m.response_time for m in self.metrics]
3789 return min(times) if times else None
3791 @property
3792 def max_response_time(self) -> Optional[float]:
3793 """Maximum response time among all resource invocations.
3795 Returns None if metrics are not loaded. Note: counts may be non-zero
3796 (via SQL) while timing is None. Use service layer converters for
3797 consistent metrics, or preload metrics via selectinload.
3799 Returns:
3800 Optional[float]: The maximum response time, or None.
3801 """
3802 if not self._metrics_loaded():
3803 return None
3804 times: List[float] = [m.response_time for m in self.metrics]
3805 return max(times) if times else None
3807 @property
3808 def avg_response_time(self) -> Optional[float]:
3809 """Average response time among all resource invocations.
3811 Returns None if metrics are not loaded. Note: counts may be non-zero
3812 (via SQL) while timing is None. Use service layer converters for
3813 consistent metrics, or preload metrics via selectinload.
3815 Returns:
3816 Optional[float]: The average response time, or None.
3817 """
3818 if not self._metrics_loaded():
3819 return None
3820 times: List[float] = [m.response_time for m in self.metrics]
3821 return sum(times) / len(times) if times else None
3823 @property
3824 def last_execution_time(self) -> Optional[datetime]:
3825 """Timestamp of the most recent resource invocation.
3827 Returns None if metrics are not loaded. Note: counts may be non-zero
3828 (via SQL) while timing is None. Use service layer converters for
3829 consistent metrics, or preload metrics via selectinload.
3831 Returns:
3832 Optional[datetime]: The timestamp of the most recent invocation, or None.
3833 """
3834 if not self._metrics_loaded():
3835 return None
3836 if not self.metrics:
3837 return None
3838 return max(m.timestamp for m in self.metrics)
3840 @property
3841 def metrics_summary(self) -> Dict[str, Any]:
3842 """Aggregated metrics for the resource combining raw and hourly data without double-counting.
3844 When metrics are loaded: computes from memory (raw + hourly)
3845 When not loaded: uses SQL queries with time partitioning
3847 Returns:
3848 Dict[str, Any]: Dictionary containing aggregated metrics:
3849 - total_executions, successful_executions, failed_executions
3850 - failure_rate, min/max/avg_response_time, last_execution_time
3851 """
3852 # Try in-memory path first
3853 if self._metrics_loaded():
3854 try:
3855 hourly_metrics = self.metrics_hourly
3856 except AttributeError:
3857 hourly_metrics = []
3858 return _compute_metrics_summary(raw_metrics=self.metrics, hourly_metrics=hourly_metrics)
3860 # SQL query path
3861 # Third-Party
3862 from sqlalchemy.orm import object_session # pylint: disable=import-outside-toplevel
3864 session = object_session(self)
3865 if session is None:
3866 return {
3867 "total_executions": 0,
3868 "successful_executions": 0,
3869 "failed_executions": 0,
3870 "failure_rate": 0.0,
3871 "min_response_time": None,
3872 "max_response_time": None,
3873 "avg_response_time": None,
3874 "last_execution_time": None,
3875 }
3877 return _compute_metrics_summary(
3878 raw_metrics=None,
3879 hourly_metrics=None,
3880 session=session,
3881 entity_id=self.id,
3882 raw_metric_class=ResourceMetric,
3883 hourly_metric_class=ResourceMetricsHourly,
3884 )
3886 # Team scoping fields for resource organization
3887 team_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("email_teams.id", ondelete="SET NULL"), nullable=True)
3888 owner_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3889 visibility: Mapped[str] = mapped_column(String(20), nullable=False, default="public")
3892class ResourceSubscription(Base):
3893 """Tracks subscriptions to resource updates."""
3895 __tablename__ = "resource_subscriptions"
3897 id: Mapped[int] = mapped_column(primary_key=True)
3898 resource_id: Mapped[str] = mapped_column(ForeignKey("resources.id"))
3899 subscriber_id: Mapped[str] = mapped_column(String(255), nullable=False) # Client identifier
3900 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
3901 last_notification: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
3903 resource: Mapped["Resource"] = relationship(back_populates="subscriptions")
3906class ToolOpsTestCases(Base):
3907 """
3908 ORM model for a registered Tool test cases.
3910 Represents a tool and the generated test cases.
3911 Includes:
3912 - tool_id: unique tool identifier
3913 - test_cases: generated test cases.
3914 - run_status: status of test case generation
3915 """
3917 __tablename__ = "toolops_test_cases"
3919 tool_id: Mapped[str] = mapped_column(String(255), primary_key=True)
3920 test_cases: Mapped[Dict[str, Any]] = mapped_column(JSON)
3921 run_status: Mapped[str] = mapped_column(String(255), nullable=False)
3924class Prompt(Base):
3925 """
3926 ORM model for a registered Prompt template.
3928 Represents a prompt template along with its argument schema.
3929 Supports rendering and invocation of prompts.
3930 Additionally, this model provides computed properties for aggregated metrics based
3931 on the associated PromptMetric records. These include:
3932 - execution_count: Total number of prompt invocations.
3933 - successful_executions: Count of successful invocations.
3934 - failed_executions: Count of failed invocations.
3935 - failure_rate: Ratio of failed invocations to total invocations.
3936 - min_response_time: Fastest recorded response time.
3937 - max_response_time: Slowest recorded response time.
3938 - avg_response_time: Mean response time.
3939 - last_execution_time: Timestamp of the most recent invocation.
3940 """
3942 __tablename__ = "prompts"
3944 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
3945 original_name: Mapped[str] = mapped_column(String(255), nullable=False)
3946 custom_name: Mapped[str] = mapped_column(String(255), nullable=False)
3947 custom_name_slug: Mapped[str] = mapped_column(String(255), nullable=False)
3948 display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3949 title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3950 name: Mapped[str] = mapped_column(String(255), nullable=False)
3951 description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
3952 template: Mapped[str] = mapped_column(Text)
3953 argument_schema: Mapped[Dict[str, Any]] = mapped_column(JSON)
3954 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
3955 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
3956 # is_active: Mapped[bool] = mapped_column(default=True)
3957 enabled: Mapped[bool] = mapped_column(default=True)
3958 tags: Mapped[List[str]] = mapped_column(JSON, default=list, nullable=False)
3960 # Comprehensive metadata for audit tracking
3961 created_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3962 created_from_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
3963 created_via: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
3964 created_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
3966 modified_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3967 modified_from_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
3968 modified_via: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
3969 modified_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
3971 import_batch_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
3972 federation_source: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3973 version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
3975 metrics: Mapped[List["PromptMetric"]] = relationship("PromptMetric", back_populates="prompt", cascade="all, delete-orphan")
3976 metrics_hourly: Mapped[List["PromptMetricsHourly"]] = relationship(
3977 "PromptMetricsHourly",
3978 primaryjoin="Prompt.id == foreign(PromptMetricsHourly.prompt_id)",
3979 viewonly=True,
3980 )
3982 gateway_id: Mapped[Optional[str]] = mapped_column(ForeignKey("gateways.id", ondelete="CASCADE"))
3983 gateway: Mapped["Gateway"] = relationship("Gateway", back_populates="prompts")
3984 # federated_with = relationship("Gateway", secondary=prompt_gateway_table, back_populates="federated_prompts")
3986 # Many-to-many relationship with Servers
3987 servers: Mapped[List["Server"]] = relationship("Server", secondary=server_prompt_association, back_populates="prompts")
3989 # Team scoping fields for resource organization
3990 team_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("email_teams.id", ondelete="SET NULL"), nullable=True)
3991 owner_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
3992 visibility: Mapped[str] = mapped_column(String(20), nullable=False, default="public")
3994 __table_args__ = (
3995 UniqueConstraint("team_id", "owner_email", "gateway_id", "name", name="uq_team_owner_gateway_name_prompt"),
3996 UniqueConstraint("gateway_id", "original_name", name="uq_gateway_id__original_name_prompt"),
3997 Index("uq_team_owner_name_prompt_local", "team_id", "owner_email", "name", unique=True, postgresql_where=text("gateway_id IS NULL"), sqlite_where=text("gateway_id IS NULL")),
3998 Index("idx_prompts_created_at_id", "created_at", "id"),
3999 )
4001 @hybrid_property
4002 def gateway_slug(self) -> Optional[str]:
4003 """Return the related gateway's slug if available.
4005 Returns:
4006 Optional[str]: Gateway slug or None when no gateway is attached.
4007 """
4008 return self.gateway.slug if self.gateway else None
4010 @gateway_slug.expression
4011 @classmethod
4012 def gateway_slug(cls) -> Any:
4013 """SQL expression to select current gateway slug for this prompt.
4015 Returns:
4016 Any: SQLAlchemy scalar subquery selecting the gateway slug.
4017 """
4018 return select(Gateway.slug).where(Gateway.id == cls.gateway_id).scalar_subquery()
4020 def validate_arguments(self, args: Dict[str, str]) -> None:
4021 """
4022 Validate prompt arguments against the argument schema.
4024 Args:
4025 args (Dict[str, str]): Dictionary of arguments to validate.
4027 Raises:
4028 ValueError: If the arguments do not conform to the schema.
4030 Examples:
4031 >>> prompt = Prompt(
4032 ... name="test_prompt",
4033 ... template="Hello {name}",
4034 ... argument_schema={
4035 ... "type": "object",
4036 ... "properties": {
4037 ... "name": {"type": "string"}
4038 ... },
4039 ... "required": ["name"]
4040 ... }
4041 ... )
4042 >>> prompt.validate_arguments({"name": "Alice"}) # No exception
4043 >>> try:
4044 ... prompt.validate_arguments({"age": 25}) # Missing required field
4045 ... except ValueError as e:
4046 ... "name" in str(e)
4047 True
4048 """
4049 try:
4050 jsonschema.validate(args, self.argument_schema)
4051 except jsonschema.exceptions.ValidationError as e:
4052 raise ValueError(f"Invalid prompt arguments: {str(e)}") from e
4054 def _metrics_loaded(self) -> bool:
4055 """Check if metrics relationship is loaded without triggering lazy load.
4057 Returns:
4058 bool: True if metrics are loaded, False otherwise.
4059 """
4060 return "metrics" in sa_inspect(self).dict
4062 def _get_metric_counts(self) -> tuple[int, int, int]:
4063 """Get total, successful, and failed metric counts in a single operation.
4065 When metrics are already loaded, computes from memory in O(n).
4066 When not loaded, uses a single SQL query with conditional aggregation.
4068 Returns:
4069 tuple[int, int, int]: (total, successful, failed) counts.
4070 """
4071 if self._metrics_loaded():
4072 total = 0
4073 successful = 0
4074 for m in self.metrics:
4075 total += 1
4076 if m.is_success:
4077 successful += 1
4078 return (total, successful, total - successful)
4080 # Third-Party
4081 from sqlalchemy import case # pylint: disable=import-outside-toplevel
4082 from sqlalchemy.orm import object_session # pylint: disable=import-outside-toplevel
4084 session = object_session(self)
4085 if session is None:
4086 return (0, 0, 0)
4088 result = (
4089 session.query(
4090 func.count(PromptMetric.id), # pylint: disable=not-callable
4091 func.sum(case((PromptMetric.is_success.is_(True), 1), else_=0)),
4092 )
4093 .filter(PromptMetric.prompt_id == self.id)
4094 .one()
4095 )
4097 total = result[0] or 0
4098 successful = result[1] or 0
4099 return (total, successful, total - successful)
4101 @hybrid_property
4102 def execution_count(self) -> int:
4103 """Number of PromptMetric records associated with this prompt instance.
4105 Returns:
4106 int: Count of PromptMetric records for this prompt.
4107 """
4108 return self._get_metric_counts()[0]
4110 @execution_count.expression
4111 @classmethod
4112 def execution_count(cls) -> Any:
4113 """SQL expression that counts PromptMetric rows for this prompt.
4115 Returns:
4116 Any: SQLAlchemy labeled count expression for prompt metrics.
4117 """
4118 return select(func.count(PromptMetric.id)).where(PromptMetric.prompt_id == cls.id).correlate(cls).scalar_subquery().label("execution_count") # pylint: disable=not-callable
4120 @property
4121 def successful_executions(self) -> int:
4122 """Count of successful prompt invocations.
4124 Returns:
4125 int: The count of successful prompt invocations.
4126 """
4127 return self._get_metric_counts()[1]
4129 @property
4130 def failed_executions(self) -> int:
4131 """Count of failed prompt invocations.
4133 Returns:
4134 int: The count of failed prompt invocations.
4135 """
4136 return self._get_metric_counts()[2]
4138 @property
4139 def failure_rate(self) -> float:
4140 """Failure rate as a float between 0 and 1.
4142 Returns:
4143 float: The failure rate as a value between 0 and 1.
4144 """
4145 total, _, failed = self._get_metric_counts()
4146 return failed / total if total > 0 else 0.0
4148 @property
4149 def min_response_time(self) -> Optional[float]:
4150 """Minimum response time among all prompt invocations.
4152 Returns None if metrics are not loaded. Note: counts may be non-zero
4153 (via SQL) while timing is None. Use service layer converters for
4154 consistent metrics, or preload metrics via selectinload.
4156 Returns:
4157 Optional[float]: The minimum response time, or None.
4158 """
4159 if not self._metrics_loaded():
4160 return None
4161 times: List[float] = [m.response_time for m in self.metrics]
4162 return min(times) if times else None
4164 @property
4165 def max_response_time(self) -> Optional[float]:
4166 """Maximum response time among all prompt invocations.
4168 Returns None if metrics are not loaded. Note: counts may be non-zero
4169 (via SQL) while timing is None. Use service layer converters for
4170 consistent metrics, or preload metrics via selectinload.
4172 Returns:
4173 Optional[float]: The maximum response time, or None.
4174 """
4175 if not self._metrics_loaded():
4176 return None
4177 times: List[float] = [m.response_time for m in self.metrics]
4178 return max(times) if times else None
4180 @property
4181 def avg_response_time(self) -> Optional[float]:
4182 """Average response time among all prompt invocations.
4184 Returns None if metrics are not loaded. Note: counts may be non-zero
4185 (via SQL) while timing is None. Use service layer converters for
4186 consistent metrics, or preload metrics via selectinload.
4188 Returns:
4189 Optional[float]: The average response time, or None.
4190 """
4191 if not self._metrics_loaded():
4192 return None
4193 times: List[float] = [m.response_time for m in self.metrics]
4194 return sum(times) / len(times) if times else None
4196 @property
4197 def last_execution_time(self) -> Optional[datetime]:
4198 """Timestamp of the most recent prompt invocation.
4200 Returns None if metrics are not loaded. Note: counts may be non-zero
4201 (via SQL) while timing is None. Use service layer converters for
4202 consistent metrics, or preload metrics via selectinload.
4204 Returns:
4205 Optional[datetime]: The timestamp of the most recent invocation, or None if no invocations exist.
4206 """
4207 if not self._metrics_loaded():
4208 return None
4209 if not self.metrics:
4210 return None
4211 return max(m.timestamp for m in self.metrics)
4213 @property
4214 def metrics_summary(self) -> Dict[str, Any]:
4215 """Aggregated metrics for the prompt combining raw and hourly data without double-counting.
4217 When metrics are loaded: computes from memory (raw + hourly)
4218 When not loaded: uses SQL queries with time partitioning
4220 Returns:
4221 Dict[str, Any]: Dictionary containing aggregated metrics:
4222 - total_executions, successful_executions, failed_executions
4223 - failure_rate, min/max/avg_response_time, last_execution_time
4224 """
4225 # Try in-memory path first
4226 if self._metrics_loaded():
4227 try:
4228 hourly_metrics = self.metrics_hourly
4229 except AttributeError:
4230 hourly_metrics = []
4231 return _compute_metrics_summary(raw_metrics=self.metrics, hourly_metrics=hourly_metrics)
4233 # SQL query path
4234 # Third-Party
4235 from sqlalchemy.orm import object_session # pylint: disable=import-outside-toplevel
4237 session = object_session(self)
4238 if session is None:
4239 return {
4240 "total_executions": 0,
4241 "successful_executions": 0,
4242 "failed_executions": 0,
4243 "failure_rate": 0.0,
4244 "min_response_time": None,
4245 "max_response_time": None,
4246 "avg_response_time": None,
4247 "last_execution_time": None,
4248 }
4250 return _compute_metrics_summary(
4251 raw_metrics=None,
4252 hourly_metrics=None,
4253 session=session,
4254 entity_id=self.id,
4255 raw_metric_class=PromptMetric,
4256 hourly_metric_class=PromptMetricsHourly,
4257 )
4260class Server(Base):
4261 """
4262 ORM model for MCP Servers Catalog.
4264 Represents a server that composes catalog items (tools, resources, prompts).
4265 Additionally, this model provides computed properties for aggregated metrics based
4266 on the associated ServerMetric records. These include:
4267 - execution_count: Total number of invocations.
4268 - successful_executions: Count of successful invocations.
4269 - failed_executions: Count of failed invocations.
4270 - failure_rate: Ratio of failed invocations to total invocations.
4271 - min_response_time: Fastest recorded response time.
4272 - max_response_time: Slowest recorded response time.
4273 - avg_response_time: Mean response time.
4274 - last_execution_time: Timestamp of the most recent invocation.
4275 """
4277 __tablename__ = "servers"
4279 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
4280 name: Mapped[str] = mapped_column(String(255), nullable=False)
4281 description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
4282 icon: Mapped[Optional[str]] = mapped_column(String(767), nullable=True)
4283 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
4284 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
4285 # is_active: Mapped[bool] = mapped_column(default=True)
4286 enabled: Mapped[bool] = mapped_column(default=True)
4287 tags: Mapped[List[str]] = mapped_column(JSON, default=list, nullable=False)
4289 # Comprehensive metadata for audit tracking
4290 created_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
4291 created_from_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
4292 created_via: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
4293 created_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
4295 modified_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
4296 modified_from_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
4297 modified_via: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
4298 modified_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
4300 import_batch_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
4301 federation_source: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
4302 version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
4304 metrics: Mapped[List["ServerMetric"]] = relationship("ServerMetric", back_populates="server", cascade="all, delete-orphan")
4305 metrics_hourly: Mapped[List["ServerMetricsHourly"]] = relationship(
4306 "ServerMetricsHourly",
4307 primaryjoin="Server.id == foreign(ServerMetricsHourly.server_id)",
4308 viewonly=True,
4309 )
4311 # Many-to-many relationships for associated items
4312 tools: Mapped[List["Tool"]] = relationship("Tool", secondary=server_tool_association, back_populates="servers")
4313 resources: Mapped[List["Resource"]] = relationship("Resource", secondary=server_resource_association, back_populates="servers")
4314 prompts: Mapped[List["Prompt"]] = relationship("Prompt", secondary=server_prompt_association, back_populates="servers")
4315 a2a_agents: Mapped[List["A2AAgent"]] = relationship("A2AAgent", secondary=server_a2a_association, back_populates="servers")
4317 # API token relationships
4318 scoped_tokens: Mapped[List["EmailApiToken"]] = relationship("EmailApiToken", back_populates="server")
4320 def _metrics_loaded(self) -> bool:
4321 """Check if metrics relationship is loaded without triggering lazy load.
4323 Returns:
4324 bool: True if metrics are loaded, False otherwise.
4325 """
4326 return "metrics" in sa_inspect(self).dict
4328 def _get_metric_counts(self) -> tuple[int, int, int]:
4329 """Get total, successful, and failed metric counts in a single operation.
4331 When metrics are already loaded, computes from memory in O(n).
4332 When not loaded, uses a single SQL query with conditional aggregation.
4334 Returns:
4335 tuple[int, int, int]: (total, successful, failed) counts.
4336 """
4337 if self._metrics_loaded():
4338 total = 0
4339 successful = 0
4340 for m in self.metrics:
4341 total += 1
4342 if m.is_success:
4343 successful += 1
4344 return (total, successful, total - successful)
4346 # Third-Party
4347 from sqlalchemy import case # pylint: disable=import-outside-toplevel
4348 from sqlalchemy.orm import object_session # pylint: disable=import-outside-toplevel
4350 session = object_session(self)
4351 if session is None:
4352 return (0, 0, 0)
4354 result = (
4355 session.query(
4356 func.count(ServerMetric.id), # pylint: disable=not-callable
4357 func.sum(case((ServerMetric.is_success.is_(True), 1), else_=0)),
4358 )
4359 .filter(ServerMetric.server_id == self.id)
4360 .one()
4361 )
4363 total = result[0] or 0
4364 successful = result[1] or 0
4365 return (total, successful, total - successful)
4367 @hybrid_property
4368 def execution_count(self) -> int:
4369 """Number of ServerMetric records associated with this server instance.
4371 Returns:
4372 int: Count of ServerMetric records for this server.
4373 """
4374 return self._get_metric_counts()[0]
4376 @execution_count.expression
4377 @classmethod
4378 def execution_count(cls) -> Any:
4379 """SQL expression that counts ServerMetric rows for this server.
4381 Returns:
4382 Any: SQLAlchemy labeled count expression for server metrics.
4383 """
4384 return select(func.count(ServerMetric.id)).where(ServerMetric.server_id == cls.id).correlate(cls).scalar_subquery().label("execution_count") # pylint: disable=not-callable
4386 @property
4387 def successful_executions(self) -> int:
4388 """Count of successful server invocations.
4390 Returns:
4391 int: The count of successful server invocations.
4392 """
4393 return self._get_metric_counts()[1]
4395 @property
4396 def failed_executions(self) -> int:
4397 """Count of failed server invocations.
4399 Returns:
4400 int: The count of failed server invocations.
4401 """
4402 return self._get_metric_counts()[2]
4404 @property
4405 def failure_rate(self) -> float:
4406 """Failure rate as a float between 0 and 1.
4408 Returns:
4409 float: The failure rate as a value between 0 and 1.
4410 """
4411 total, _, failed = self._get_metric_counts()
4412 return failed / total if total > 0 else 0.0
4414 @property
4415 def min_response_time(self) -> Optional[float]:
4416 """Minimum response time among all server invocations.
4418 Returns None if metrics are not loaded. Note: counts may be non-zero
4419 (via SQL) while timing is None. Use service layer converters for
4420 consistent metrics, or preload metrics via selectinload.
4422 Returns:
4423 Optional[float]: The minimum response time, or None.
4424 """
4425 if not self._metrics_loaded():
4426 return None
4427 times: List[float] = [m.response_time for m in self.metrics]
4428 return min(times) if times else None
4430 @property
4431 def max_response_time(self) -> Optional[float]:
4432 """Maximum response time among all server invocations.
4434 Returns None if metrics are not loaded. Note: counts may be non-zero
4435 (via SQL) while timing is None. Use service layer converters for
4436 consistent metrics, or preload metrics via selectinload.
4438 Returns:
4439 Optional[float]: The maximum response time, or None.
4440 """
4441 if not self._metrics_loaded():
4442 return None
4443 times: List[float] = [m.response_time for m in self.metrics]
4444 return max(times) if times else None
4446 @property
4447 def avg_response_time(self) -> Optional[float]:
4448 """Average response time among all server invocations.
4450 Returns None if metrics are not loaded. Note: counts may be non-zero
4451 (via SQL) while timing is None. Use service layer converters for
4452 consistent metrics, or preload metrics via selectinload.
4454 Returns:
4455 Optional[float]: The average response time, or None.
4456 """
4457 if not self._metrics_loaded():
4458 return None
4459 times: List[float] = [m.response_time for m in self.metrics]
4460 return sum(times) / len(times) if times else None
4462 @property
4463 def last_execution_time(self) -> Optional[datetime]:
4464 """Timestamp of the most recent server invocation.
4466 Returns None if metrics are not loaded. Note: counts may be non-zero
4467 (via SQL) while timing is None. Use service layer converters for
4468 consistent metrics, or preload metrics via selectinload.
4470 Returns:
4471 Optional[datetime]: The timestamp of the most recent invocation, or None.
4472 """
4473 if not self._metrics_loaded():
4474 return None
4475 if not self.metrics:
4476 return None
4477 return max(m.timestamp for m in self.metrics)
4479 @property
4480 def metrics_summary(self) -> Dict[str, Any]:
4481 """Aggregated metrics for the server combining raw and hourly data without double-counting.
4483 When metrics are loaded: computes from memory (raw + hourly)
4484 When not loaded: uses SQL queries with time partitioning
4486 Returns:
4487 Dict[str, Any]: Dictionary containing aggregated metrics:
4488 - total_executions, successful_executions, failed_executions
4489 - failure_rate, min/max/avg_response_time, last_execution_time
4490 """
4491 # Try in-memory path first
4492 if self._metrics_loaded():
4493 try:
4494 hourly_metrics = self.metrics_hourly
4495 except AttributeError:
4496 hourly_metrics = []
4497 return _compute_metrics_summary(raw_metrics=self.metrics, hourly_metrics=hourly_metrics)
4499 # SQL query path
4500 # Third-Party
4501 from sqlalchemy.orm import object_session # pylint: disable=import-outside-toplevel
4503 session = object_session(self)
4504 if session is None:
4505 return {
4506 "total_executions": 0,
4507 "successful_executions": 0,
4508 "failed_executions": 0,
4509 "failure_rate": 0.0,
4510 "min_response_time": None,
4511 "max_response_time": None,
4512 "avg_response_time": None,
4513 "last_execution_time": None,
4514 }
4516 return _compute_metrics_summary(
4517 raw_metrics=None,
4518 hourly_metrics=None,
4519 session=session,
4520 entity_id=self.id,
4521 raw_metric_class=ServerMetric,
4522 hourly_metric_class=ServerMetricsHourly,
4523 )
4525 # Team scoping fields for resource organization
4526 team_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("email_teams.id", ondelete="SET NULL"), nullable=True)
4527 owner_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
4528 visibility: Mapped[str] = mapped_column(String(20), nullable=False, default="public")
4530 # OAuth 2.0 configuration for RFC 9728 Protected Resource Metadata
4531 # When enabled, MCP clients can authenticate using OAuth with browser-based IDP SSO
4532 oauth_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
4533 oauth_config: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
4535 # Relationship for loading team names (only active teams)
4536 # Uses default lazy loading - team name is only loaded when accessed
4537 # For list/admin views, use explicit joinedload(DbServer.email_team) for single-query loading
4538 # This avoids adding overhead to hot paths that don't need team names
4539 email_team: Mapped[Optional["EmailTeam"]] = relationship(
4540 "EmailTeam",
4541 primaryjoin="and_(Server.team_id == EmailTeam.id, EmailTeam.is_active == True)",
4542 foreign_keys=[team_id],
4543 )
4545 @property
4546 def team(self) -> Optional[str]:
4547 """Return the team name from the `email_team` relationship.
4549 Returns:
4550 Optional[str]: The team name if the server belongs to an active team, otherwise None.
4551 """
4552 return self.email_team.name if self.email_team else None
4554 __table_args__ = (
4555 UniqueConstraint("team_id", "owner_email", "name", name="uq_team_owner_name_server"),
4556 Index("idx_servers_created_at_id", "created_at", "id"),
4557 )
4560class Gateway(Base):
4561 """ORM model for a federated peer Gateway."""
4563 __tablename__ = "gateways"
4565 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
4566 name: Mapped[str] = mapped_column(String(255), nullable=False)
4567 slug: Mapped[str] = mapped_column(String(255), nullable=False)
4568 url: Mapped[str] = mapped_column(String(767), nullable=False)
4569 description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
4570 transport: Mapped[str] = mapped_column(String(20), default="SSE")
4571 capabilities: Mapped[Dict[str, Any]] = mapped_column(JSON)
4572 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
4573 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
4574 enabled: Mapped[bool] = mapped_column(default=True)
4575 reachable: Mapped[bool] = mapped_column(default=True)
4576 last_seen: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
4577 tags: Mapped[List[Dict[str, str]]] = mapped_column(JSON, default=list, nullable=False)
4579 # Comprehensive metadata for audit tracking
4580 created_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
4581 created_from_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
4582 created_via: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
4583 created_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
4585 modified_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
4586 modified_from_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
4587 modified_via: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
4588 modified_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
4590 import_batch_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
4591 federation_source: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
4592 version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
4594 # Header passthrough configuration
4595 passthrough_headers: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True) # Store list of strings as JSON array
4597 # CA certificate
4598 ca_certificate: Mapped[Optional[bytes]] = mapped_column(Text, nullable=True)
4599 ca_certificate_sig: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
4600 signing_algorithm: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, default="ed25519") # e.g., "sha256"
4602 # mTLS client certificate/key for upstream gateway authentication
4603 client_cert: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
4604 client_key: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
4606 # Relationship with local tools this gateway provides
4607 tools: Mapped[List["Tool"]] = relationship(back_populates="gateway", foreign_keys="Tool.gateway_id", cascade="all, delete-orphan", passive_deletes=True)
4609 # Relationship with local prompts this gateway provides
4610 prompts: Mapped[List["Prompt"]] = relationship(back_populates="gateway", cascade="all, delete-orphan", passive_deletes=True)
4612 # Relationship with local resources this gateway provides
4613 resources: Mapped[List["Resource"]] = relationship(back_populates="gateway", cascade="all, delete-orphan", passive_deletes=True)
4615 # # Tools federated from this gateway
4616 # federated_tools: Mapped[List["Tool"]] = relationship(secondary=tool_gateway_table, back_populates="federated_with")
4618 # # Prompts federated from this resource
4619 # federated_resources: Mapped[List["Resource"]] = relationship(secondary=resource_gateway_table, back_populates="federated_with")
4621 # # Prompts federated from this gateway
4622 # federated_prompts: Mapped[List["Prompt"]] = relationship(secondary=prompt_gateway_table, back_populates="federated_with")
4624 # Authorizations
4625 auth_type: Mapped[Optional[str]] = mapped_column(String(20), default=None) # "basic", "bearer", "authheaders", "oauth", "query_param" or None
4626 auth_value: Mapped[Optional[Dict[str, str]]] = mapped_column(JSON)
4627 auth_query_params: Mapped[Optional[Dict[str, str]]] = mapped_column(
4628 JSON,
4629 nullable=True,
4630 default=None,
4631 comment="Encrypted query parameters for auth. Format: {'param_name': 'encrypted_value'}",
4632 )
4634 # OAuth configuration
4635 oauth_config: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True, comment="OAuth 2.0 configuration including grant_type, client_id, encrypted client_secret, URLs, and scopes")
4637 # Team scoping fields for resource organization
4638 team_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("email_teams.id", ondelete="SET NULL"), nullable=True)
4639 owner_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
4640 visibility: Mapped[str] = mapped_column(String(20), nullable=False, default="public")
4642 # Relationship for loading team names (only active teams)
4643 # Uses default lazy loading - team name is only loaded when accessed
4644 # For list/admin views, use explicit joinedload(DbGateway.email_team) for single-query loading
4645 # This avoids adding overhead to hot paths that don't need team names
4646 email_team: Mapped[Optional["EmailTeam"]] = relationship(
4647 "EmailTeam",
4648 primaryjoin="and_(Gateway.team_id == EmailTeam.id, EmailTeam.is_active == True)",
4649 foreign_keys=[team_id],
4650 )
4652 @property
4653 def team(self) -> Optional[str]:
4654 """Return the team name from the `email_team` relationship.
4656 Returns:
4657 Optional[str]: The team name if the gateway belongs to an active team, otherwise None.
4658 """
4659 return self.email_team.name if self.email_team else None
4661 # Per-gateway refresh configuration
4662 refresh_interval_seconds: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, comment="Per-gateway refresh interval in seconds; NULL uses global default")
4663 last_refresh_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, comment="Timestamp of the last successful tools/resources/prompts refresh")
4665 # Gateway mode: 'cache' (default) or 'direct_proxy'
4666 # - 'cache': Tools/resources/prompts are cached in database upon gateway registration (current behavior)
4667 # - 'direct_proxy': All RPC calls are proxied directly to remote MCP server with no database caching
4668 gateway_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="cache", comment="Gateway mode: 'cache' (database caching) or 'direct_proxy' (pass-through mode)")
4670 # Relationship with OAuth tokens
4671 oauth_tokens: Mapped[List["OAuthToken"]] = relationship("OAuthToken", back_populates="gateway", cascade="all, delete-orphan")
4673 # Relationship with registered OAuth clients (DCR)
4675 registered_oauth_clients: Mapped[List["RegisteredOAuthClient"]] = relationship("RegisteredOAuthClient", back_populates="gateway", cascade="all, delete-orphan")
4677 __table_args__ = (
4678 UniqueConstraint("team_id", "owner_email", "slug", name="uq_team_owner_slug_gateway"),
4679 Index("idx_gateways_created_at_id", "created_at", "id"),
4680 )
4683@event.listens_for(Gateway, "after_update")
4684def update_tool_names_on_gateway_update(_mapper, connection, target):
4685 """
4686 If a Gateway's name is updated, efficiently update all of its
4687 child Tools' names with a single SQL statement.
4689 Args:
4690 _mapper: Mapper
4691 connection: Connection
4692 target: Target
4693 """
4694 # 1. Check if the 'name' field was actually part of the update.
4695 # This is a concise way to see if the value has changed.
4696 if not get_history(target, "name").has_changes():
4697 return
4699 logger.info("Gateway name changed for ID %s. Issuing bulk update for tools.", target.id)
4701 # 2. Get a reference to the underlying database table for Tools
4702 tools_table = Tool.__table__
4704 # 3. Prepare the new values
4705 new_gateway_slug = slugify(target.name)
4706 separator = settings.gateway_tool_name_separator
4708 # 4. Construct a single, powerful UPDATE statement using SQLAlchemy Core.
4709 # This is highly efficient as it all happens in the database.
4710 stmt = (
4711 cast(Any, tools_table)
4712 .update()
4713 .where(tools_table.c.gateway_id == target.id)
4714 .values(name=new_gateway_slug + separator + tools_table.c.custom_name_slug)
4715 .execution_options(synchronize_session=False)
4716 )
4718 # 5. Execute the statement using the connection from the ongoing transaction.
4719 connection.execute(stmt)
4722@event.listens_for(Gateway, "after_update")
4723def update_prompt_names_on_gateway_update(_mapper, connection, target):
4724 """Update prompt names when a gateway name changes.
4726 Args:
4727 _mapper: SQLAlchemy mapper for the Gateway model.
4728 connection: Database connection for the update transaction.
4729 target: Gateway instance being updated.
4730 """
4731 if not get_history(target, "name").has_changes():
4732 return
4734 logger.info("Gateway name changed for ID %s. Issuing bulk update for prompts.", target.id)
4736 prompts_table = Prompt.__table__
4737 new_gateway_slug = slugify(target.name)
4738 separator = settings.gateway_tool_name_separator
4740 stmt = (
4741 cast(Any, prompts_table)
4742 .update()
4743 .where(prompts_table.c.gateway_id == target.id)
4744 .values(name=new_gateway_slug + separator + prompts_table.c.custom_name_slug)
4745 .execution_options(synchronize_session=False)
4746 )
4748 connection.execute(stmt)
4751class A2AAgent(Base):
4752 """
4753 ORM model for A2A (Agent-to-Agent) compatible agents.
4755 A2A agents represent external AI agents that can be integrated into the gateway
4756 and exposed as tools within virtual servers. They support standardized
4757 Agent-to-Agent communication protocols for interoperability.
4758 """
4760 __tablename__ = "a2a_agents"
4762 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
4763 name: Mapped[str] = mapped_column(String(255), nullable=False)
4764 slug: Mapped[str] = mapped_column(String(255), nullable=False)
4765 description: Mapped[Optional[str]] = mapped_column(Text)
4766 endpoint_url: Mapped[str] = mapped_column(String(767), nullable=False)
4767 agent_type: Mapped[str] = mapped_column(String(50), nullable=False, default="generic") # e.g., "openai", "anthropic", "custom"
4768 protocol_version: Mapped[str] = mapped_column(String(10), nullable=False, default="1.0")
4769 capabilities: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
4770 # Configuration
4771 config: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
4773 # Authorizations
4774 auth_type: Mapped[Optional[str]] = mapped_column(String(20), default=None) # "basic", "bearer", "authheaders", "oauth", "query_param" or None
4775 auth_value: Mapped[Optional[str]] = mapped_column(Text)
4776 auth_query_params: Mapped[Optional[Dict[str, str]]] = mapped_column(
4777 JSON,
4778 nullable=True,
4779 default=None,
4780 comment="Encrypted query parameters for auth. Format: {'param_name': 'encrypted_value'}",
4781 )
4783 # OAuth configuration
4784 oauth_config: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True, comment="OAuth 2.0 configuration including grant_type, client_id, encrypted client_secret, URLs, and scopes")
4786 # Header passthrough configuration
4787 passthrough_headers: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True) # Store list of strings as JSON array
4789 # Status and metadata
4790 enabled: Mapped[bool] = mapped_column(Boolean, default=True)
4791 reachable: Mapped[bool] = mapped_column(Boolean, default=True)
4792 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
4793 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
4794 last_interaction: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
4796 # Tags for categorization
4797 tags: Mapped[List[str]] = mapped_column(JSON, default=list, nullable=False)
4799 # Comprehensive metadata for audit tracking
4800 created_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
4801 created_from_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
4802 created_via: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
4803 created_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
4805 modified_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
4806 modified_from_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
4807 modified_via: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
4808 modified_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
4810 import_batch_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
4811 federation_source: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
4812 version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
4814 # Team scoping fields for resource organization
4815 team_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("email_teams.id", ondelete="SET NULL"), nullable=True)
4816 owner_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
4817 visibility: Mapped[str] = mapped_column(String(20), nullable=False, default="public")
4819 # Associated tool ID (A2A agents are automatically registered as tools)
4820 tool_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("tools.id", ondelete="SET NULL"), nullable=True)
4822 # Relationships
4823 servers: Mapped[List["Server"]] = relationship("Server", secondary=server_a2a_association, back_populates="a2a_agents")
4824 tool: Mapped[Optional["Tool"]] = relationship("Tool", foreign_keys=[tool_id])
4825 metrics: Mapped[List["A2AAgentMetric"]] = relationship("A2AAgentMetric", back_populates="a2a_agent", cascade="all, delete-orphan")
4826 __table_args__ = (
4827 UniqueConstraint("team_id", "owner_email", "slug", name="uq_team_owner_slug_a2a_agent"),
4828 Index("idx_a2a_agents_created_at_id", "created_at", "id"),
4829 Index("idx_a2a_agents_tool_id", "tool_id"),
4830 )
4832 # Relationship with OAuth tokens
4833 # oauth_tokens: Mapped[List["OAuthToken"]] = relationship("OAuthToken", back_populates="gateway", cascade="all, delete-orphan")
4835 # Relationship with registered OAuth clients (DCR)
4836 # registered_oauth_clients: Mapped[List["RegisteredOAuthClient"]] = relationship("RegisteredOAuthClient", back_populates="gateway", cascade="all, delete-orphan")
4838 def _metrics_loaded(self) -> bool:
4839 """Check if metrics relationship is loaded without triggering lazy load.
4841 Returns:
4842 bool: True if metrics are loaded, False otherwise.
4843 """
4844 return "metrics" in sa_inspect(self).dict
4846 @property
4847 def execution_count(self) -> int:
4848 """Total number of interactions with this agent.
4849 Returns 0 if metrics are not loaded (avoids lazy loading).
4851 Returns:
4852 int: The total count of interactions.
4853 """
4854 if not self._metrics_loaded():
4855 return 0
4856 return len(self.metrics)
4858 @property
4859 def successful_executions(self) -> int:
4860 """Number of successful interactions.
4861 Returns 0 if metrics are not loaded (avoids lazy loading).
4863 Returns:
4864 int: The count of successful interactions.
4865 """
4866 if not self._metrics_loaded():
4867 return 0
4868 return sum(1 for m in self.metrics if m.is_success)
4870 @property
4871 def failed_executions(self) -> int:
4872 """Number of failed interactions.
4873 Returns 0 if metrics are not loaded (avoids lazy loading).
4875 Returns:
4876 int: The count of failed interactions.
4877 """
4878 if not self._metrics_loaded():
4879 return 0
4880 return sum(1 for m in self.metrics if not m.is_success)
4882 @property
4883 def failure_rate(self) -> float:
4884 """Failure rate as a percentage.
4885 Returns 0.0 if metrics are not loaded (avoids lazy loading).
4887 Returns:
4888 float: The failure rate percentage.
4889 """
4890 if not self._metrics_loaded():
4891 return 0.0
4892 if not self.metrics:
4893 return 0.0
4894 return (self.failed_executions / len(self.metrics)) * 100
4896 @property
4897 def avg_response_time(self) -> Optional[float]:
4898 """Average response time in seconds.
4899 Returns None if metrics are not loaded (avoids lazy loading).
4901 Returns:
4902 Optional[float]: The average response time, or None if no metrics.
4903 """
4904 if not self._metrics_loaded():
4905 return None
4906 if not self.metrics:
4907 return None
4908 return sum(m.response_time for m in self.metrics) / len(self.metrics)
4910 @property
4911 def last_execution_time(self) -> Optional[datetime]:
4912 """Timestamp of the most recent interaction.
4913 Returns None if metrics are not loaded (avoids lazy loading).
4915 Returns:
4916 Optional[datetime]: The timestamp of the last interaction, or None if no metrics.
4917 """
4918 if not self._metrics_loaded():
4919 return None
4920 if not self.metrics:
4921 return None
4922 return max(m.timestamp for m in self.metrics)
4924 def __repr__(self) -> str:
4925 """Return a string representation of the A2AAgent instance.
4927 Returns:
4928 str: A formatted string containing the agent's ID, name, and type.
4930 Examples:
4931 >>> agent = A2AAgent(id='123', name='test-agent', agent_type='custom')
4932 >>> repr(agent)
4933 "<A2AAgent(id='123', name='test-agent', agent_type='custom')>"
4934 """
4935 return f"<A2AAgent(id='{self.id}', name='{self.name}', agent_type='{self.agent_type}')>"
4938class GrpcService(Base):
4939 """
4940 ORM model for gRPC services with reflection-based discovery.
4942 gRPC services represent external gRPC servers that can be automatically discovered
4943 via server reflection and exposed as MCP tools. The gateway translates between
4944 gRPC/Protobuf and MCP/JSON protocols.
4945 """
4947 __tablename__ = "grpc_services"
4949 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
4950 name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
4951 slug: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
4952 description: Mapped[Optional[str]] = mapped_column(Text)
4953 target: Mapped[str] = mapped_column(String(767), nullable=False) # host:port format
4955 # Configuration
4956 reflection_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
4957 tls_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
4958 tls_cert_path: Mapped[Optional[str]] = mapped_column(String(767))
4959 tls_key_path: Mapped[Optional[str]] = mapped_column(String(767))
4960 grpc_metadata: Mapped[Dict[str, str]] = mapped_column(JSON, default=dict) # gRPC metadata headers
4962 # Status
4963 enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
4964 reachable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
4966 # Discovery results from reflection
4967 service_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
4968 method_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
4969 discovered_services: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict) # Service descriptors
4970 last_reflection: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
4972 # Tags for categorization
4973 tags: Mapped[List[str]] = mapped_column(JSON, default=list, nullable=False)
4975 # Timestamps
4976 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
4977 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
4979 # Comprehensive metadata for audit tracking
4980 created_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
4981 created_from_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
4982 created_via: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
4983 created_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
4985 modified_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
4986 modified_from_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
4987 modified_via: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
4988 modified_user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
4990 import_batch_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
4991 federation_source: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
4992 version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
4994 # Team scoping fields for resource organization
4995 team_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("email_teams.id", ondelete="SET NULL"), nullable=True)
4996 owner_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
4997 visibility: Mapped[str] = mapped_column(String(20), nullable=False, default="public")
4999 def __repr__(self) -> str:
5000 """Return a string representation of the GrpcService instance.
5002 Returns:
5003 str: A formatted string containing the service's ID, name, and target.
5004 """
5005 return f"<GrpcService(id='{self.id}', name='{self.name}', target='{self.target}')>"
5008class SessionRecord(Base):
5009 """ORM model for sessions from SSE client."""
5011 __tablename__ = "mcp_sessions"
5013 session_id: Mapped[str] = mapped_column(String(255), primary_key=True)
5014 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) # pylint: disable=not-callable
5015 last_accessed: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) # pylint: disable=not-callable
5016 data: Mapped[str] = mapped_column(Text, nullable=True)
5018 messages: Mapped[List["SessionMessageRecord"]] = relationship("SessionMessageRecord", back_populates="session", cascade="all, delete-orphan")
5021class SessionMessageRecord(Base):
5022 """ORM model for messages from SSE client."""
5024 __tablename__ = "mcp_messages"
5026 id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
5027 session_id: Mapped[str] = mapped_column(String(255), ForeignKey("mcp_sessions.session_id"))
5028 message: Mapped[str] = mapped_column(Text, nullable=True)
5029 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) # pylint: disable=not-callable
5030 last_accessed: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) # pylint: disable=not-callable
5032 session: Mapped["SessionRecord"] = relationship("SessionRecord", back_populates="messages")
5035class OAuthToken(Base):
5036 """ORM model for OAuth access and refresh tokens with user association."""
5038 __tablename__ = "oauth_tokens"
5040 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
5041 gateway_id: Mapped[str] = mapped_column(String(36), ForeignKey("gateways.id", ondelete="CASCADE"), nullable=False)
5042 user_id: Mapped[str] = mapped_column(String(255), nullable=False) # OAuth provider's user ID
5043 app_user_email: Mapped[str] = mapped_column(String(255), ForeignKey("email_users.email", ondelete="CASCADE"), nullable=False) # ContextForge user
5044 access_token: Mapped[str] = mapped_column(EncryptedText(), nullable=False)
5045 refresh_token: Mapped[Optional[str]] = mapped_column(EncryptedText(), nullable=True)
5046 token_type: Mapped[str] = mapped_column(String(50), default="Bearer")
5047 expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
5048 scopes: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)
5049 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
5050 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
5052 # Relationships
5053 gateway: Mapped["Gateway"] = relationship("Gateway", back_populates="oauth_tokens")
5054 app_user: Mapped["EmailUser"] = relationship("EmailUser", foreign_keys=[app_user_email])
5056 # Unique constraint: one token per user per gateway
5057 __table_args__ = (UniqueConstraint("gateway_id", "app_user_email", name="uq_oauth_gateway_user"),)
5060class OAuthState(Base):
5061 """ORM model for OAuth authorization states with TTL for CSRF protection."""
5063 __tablename__ = "oauth_states"
5065 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
5066 gateway_id: Mapped[str] = mapped_column(String(36), ForeignKey("gateways.id", ondelete="CASCADE"), nullable=False)
5067 state: Mapped[str] = mapped_column(String(500), nullable=False, unique=True) # The state parameter
5068 code_verifier: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) # PKCE code verifier (RFC 7636)
5069 app_user_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # Requesting user context for token association
5070 expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
5071 used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
5072 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
5074 # Relationships
5075 gateway: Mapped["Gateway"] = relationship("Gateway")
5077 # Index for efficient lookups
5078 __table_args__ = (Index("idx_oauth_state_lookup", "gateway_id", "state"),)
5081class RegisteredOAuthClient(Base):
5082 """Stores dynamically registered OAuth clients (RFC 7591 client mode).
5084 This model maintains client credentials obtained through Dynamic Client
5085 Registration with upstream Authorization Servers.
5086 """
5088 __tablename__ = "registered_oauth_clients"
5090 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
5091 gateway_id: Mapped[str] = mapped_column(String(36), ForeignKey("gateways.id", ondelete="CASCADE"), nullable=False, index=True)
5093 # Registration details
5094 issuer: Mapped[str] = mapped_column(String(500), nullable=False) # AS issuer URL
5095 client_id: Mapped[str] = mapped_column(String(500), nullable=False)
5096 client_secret_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # Encrypted
5098 # RFC 7591 fields
5099 redirect_uris: Mapped[str] = mapped_column(Text, nullable=False) # JSON array
5100 grant_types: Mapped[str] = mapped_column(Text, nullable=False) # JSON array
5101 response_types: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON array
5102 scope: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True)
5103 token_endpoint_auth_method: Mapped[str] = mapped_column(String(50), default="client_secret_basic")
5105 # Registration management (RFC 7591 section 4)
5106 registration_client_uri: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
5107 registration_access_token_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
5109 # Metadata
5110 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
5111 expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
5112 is_active: Mapped[bool] = mapped_column(Boolean, default=True)
5114 # Relationships
5115 gateway: Mapped["Gateway"] = relationship("Gateway", back_populates="registered_oauth_clients")
5117 # Unique constraint: one registration per gateway+issuer
5118 __table_args__ = (Index("idx_gateway_issuer", "gateway_id", "issuer", unique=True),)
5121class EmailApiToken(Base):
5122 """Email user API token model for token catalog management.
5124 This model provides comprehensive API token management with scoping,
5125 revocation, and usage tracking for email-based users.
5127 Attributes:
5128 id (str): Unique token identifier
5129 user_email (str): Owner's email address
5130 team_id (str): Team the token is associated with (required for team-based access)
5131 name (str): Human-readable token name
5132 jti (str): JWT ID for revocation checking
5133 token_hash (str): Hashed token value for security
5134 server_id (str): Optional server scope limitation
5135 resource_scopes (List[str]): Permission scopes like ['tools.read']
5136 ip_restrictions (List[str]): IP address/CIDR restrictions
5137 time_restrictions (dict): Time-based access restrictions
5138 usage_limits (dict): Rate limiting and usage quotas
5139 created_at (datetime): Token creation timestamp
5140 expires_at (datetime): Optional expiry timestamp
5141 last_used (datetime): Last usage timestamp
5142 is_active (bool): Active status flag
5143 description (str): Token description
5144 tags (List[str]): Organizational tags
5146 Examples:
5147 >>> token = EmailApiToken(
5148 ... user_email="alice@example.com",
5149 ... name="Production API Access",
5150 ... server_id="prod-server-123",
5151 ... resource_scopes=["tools.read", "resources.read"],
5152 ... description="Read-only access to production tools"
5153 ... )
5154 >>> token.is_scoped_to_server("prod-server-123")
5155 True
5156 >>> token.has_permission("tools.read")
5157 True
5158 """
5160 __tablename__ = "email_api_tokens"
5162 # Core identity fields
5163 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
5164 user_email: Mapped[str] = mapped_column(String(255), ForeignKey("email_users.email", ondelete="CASCADE"), nullable=False, index=True)
5165 team_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("email_teams.id", ondelete="SET NULL"), nullable=True, index=True)
5166 name: Mapped[str] = mapped_column(String(255), nullable=False)
5167 jti: Mapped[str] = mapped_column(String(36), unique=True, nullable=False, default=lambda: str(uuid.uuid4()))
5168 token_hash: Mapped[str] = mapped_column(String(255), nullable=False)
5170 # Scoping fields
5171 server_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("servers.id", ondelete="CASCADE"), nullable=True)
5172 resource_scopes: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True, default=list)
5173 ip_restrictions: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True, default=list)
5174 time_restrictions: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, default=dict)
5175 usage_limits: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, default=dict)
5177 # Lifecycle fields
5178 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
5179 expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
5180 last_used: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
5181 is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
5183 # Metadata fields
5184 description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
5185 tags: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True, default=list)
5187 # Unique constraint for user+name+team_id combination (per-team scope).
5188 # The composite UniqueConstraint handles non-NULL team_id rows. SQL NULL != NULL
5189 # semantics mean it cannot protect global-scope tokens (team_id IS NULL), so we add
5190 # a partial unique index for that case — matching the pattern used by resources/prompts.
5191 __table_args__ = (
5192 UniqueConstraint("user_email", "name", "team_id", name="uq_email_api_tokens_user_name_team"),
5193 Index("uq_email_api_tokens_user_name_global", "user_email", "name", unique=True, postgresql_where=text("team_id IS NULL"), sqlite_where=text("team_id IS NULL")),
5194 Index("idx_email_api_tokens_user_email", "user_email"),
5195 Index("idx_email_api_tokens_jti", "jti"),
5196 Index("idx_email_api_tokens_expires_at", "expires_at"),
5197 Index("idx_email_api_tokens_is_active", "is_active"),
5198 )
5200 # Relationships
5201 user: Mapped["EmailUser"] = relationship("EmailUser", back_populates="api_tokens")
5202 team: Mapped[Optional["EmailTeam"]] = relationship("EmailTeam", back_populates="api_tokens")
5203 server: Mapped[Optional["Server"]] = relationship("Server", back_populates="scoped_tokens")
5205 def is_scoped_to_server(self, server_id: str) -> bool:
5206 """Check if token is scoped to a specific server.
5208 Args:
5209 server_id: Server ID to check against.
5211 Returns:
5212 bool: True if token is scoped to the server, False otherwise.
5213 """
5214 return self.server_id == server_id if self.server_id else False
5216 def has_permission(self, permission: str) -> bool:
5217 """Check if token has a specific permission.
5219 Args:
5220 permission: Permission string to check for.
5222 Returns:
5223 bool: True if token has the permission, False otherwise.
5224 """
5225 return permission in (self.resource_scopes or [])
5227 def is_team_token(self) -> bool:
5228 """Check if this is a team-based token.
5230 Returns:
5231 bool: True if token is associated with a team, False otherwise.
5232 """
5233 return self.team_id is not None
5235 def get_effective_permissions(self) -> List[str]:
5236 """Get effective permissions for this token.
5238 For team tokens, this should inherit team permissions.
5239 For personal tokens, this uses the resource_scopes.
5241 Returns:
5242 List[str]: List of effective permissions for this token.
5243 """
5244 if self.is_team_token() and self.team:
5245 # For team tokens, we would inherit team permissions
5246 # This would need to be implemented based on your RBAC system
5247 return self.resource_scopes or []
5248 return self.resource_scopes or []
5250 def is_expired(self) -> bool:
5251 """Check if token is expired.
5253 Returns:
5254 bool: True if token is expired, False otherwise.
5255 """
5256 if not self.expires_at:
5257 return False
5258 expires_at = self.expires_at
5259 if expires_at.tzinfo is None:
5260 expires_at = expires_at.replace(tzinfo=timezone.utc)
5261 return utc_now() > expires_at
5263 def is_valid(self) -> bool:
5264 """Check if token is valid (active and not expired).
5266 Returns:
5267 bool: True if token is valid, False otherwise.
5268 """
5269 return self.is_active and not self.is_expired()
5272class TokenUsageLog(Base):
5273 """Token usage logging for analytics and security monitoring.
5275 This model tracks every API request made with email API tokens
5276 for security auditing and usage analytics.
5278 Attributes:
5279 id (int): Auto-incrementing log ID
5280 token_jti (str): Token JWT ID reference
5281 user_email (str): Token owner's email
5282 timestamp (datetime): Request timestamp
5283 endpoint (str): API endpoint accessed
5284 method (str): HTTP method used
5285 ip_address (str): Client IP address
5286 user_agent (str): Client user agent
5287 status_code (int): HTTP response status
5288 response_time_ms (int): Response time in milliseconds
5289 blocked (bool): Whether request was blocked
5290 block_reason (str): Reason for blocking if applicable
5292 Examples:
5293 >>> log = TokenUsageLog(
5294 ... token_jti="token-uuid-123",
5295 ... user_email="alice@example.com",
5296 ... endpoint="/tools",
5297 ... method="GET",
5298 ... ip_address="192.168.1.100",
5299 ... status_code=200,
5300 ... response_time_ms=45
5301 ... )
5302 """
5304 __tablename__ = "token_usage_logs"
5306 # Primary key
5307 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
5309 # Token reference
5310 token_jti: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
5311 user_email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
5313 # Timestamp
5314 timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False, index=True)
5316 # Request details
5317 endpoint: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
5318 method: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
5319 ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) # IPv6 max length
5320 user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
5322 # Response details
5323 status_code: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
5324 response_time_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
5326 # Security fields
5327 blocked: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
5328 block_reason: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
5330 # Indexes for performance
5331 __table_args__ = (
5332 Index("idx_token_usage_logs_token_jti_timestamp", "token_jti", "timestamp"),
5333 Index("idx_token_usage_logs_user_email_timestamp", "user_email", "timestamp"),
5334 )
5337class TokenRevocation(Base):
5338 """Token revocation blacklist for immediate token invalidation.
5340 This model maintains a blacklist of revoked JWT tokens to provide
5341 immediate token invalidation capabilities.
5343 Attributes:
5344 jti (str): JWT ID (primary key)
5345 revoked_at (datetime): Revocation timestamp
5346 revoked_by (str): Email of user who revoked the token
5347 reason (str): Optional reason for revocation
5349 Examples:
5350 >>> revocation = TokenRevocation(
5351 ... jti="token-uuid-123",
5352 ... revoked_by="admin@example.com",
5353 ... reason="Security compromise"
5354 ... )
5355 """
5357 __tablename__ = "token_revocations"
5359 # JWT ID as primary key
5360 jti: Mapped[str] = mapped_column(String(36), primary_key=True)
5362 # Revocation details
5363 revoked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
5364 revoked_by: Mapped[str] = mapped_column(String(255), ForeignKey("email_users.email"), nullable=False)
5365 reason: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
5367 # Relationship
5368 revoker: Mapped["EmailUser"] = relationship("EmailUser")
5371class SSOProvider(Base):
5372 """SSO identity provider configuration for OAuth2/OIDC authentication.
5374 Stores configuration and credentials for external identity providers
5375 like GitHub, Google, IBM Security Verify, Okta, Microsoft Entra ID,
5376 and any generic OIDC-compliant provider (Keycloak, Auth0, Authentik, etc.).
5378 Attributes:
5379 id (str): Unique provider ID (e.g., 'github', 'google', 'ibm_verify')
5380 name (str): Human-readable provider name
5381 display_name (str): Display name for UI
5382 provider_type (str): Protocol type ('oauth2', 'oidc')
5383 is_enabled (bool): Whether provider is active
5384 client_id (str): OAuth client ID
5385 client_secret_encrypted (str): Encrypted client secret
5386 authorization_url (str): OAuth authorization endpoint
5387 token_url (str): OAuth token endpoint
5388 userinfo_url (str): User info endpoint
5389 issuer (str): OIDC issuer (optional)
5390 jwks_uri (str): OIDC JWKS endpoint for token signature verification (optional)
5391 trusted_domains (List[str]): Auto-approved email domains
5392 scope (str): OAuth scope string
5393 auto_create_users (bool): Auto-create users on first login
5394 team_mapping (dict): Organization/domain to team mapping rules
5395 created_at (datetime): Provider creation timestamp
5396 updated_at (datetime): Last configuration update
5398 Examples:
5399 >>> provider = SSOProvider(
5400 ... id="github",
5401 ... name="github",
5402 ... display_name="GitHub",
5403 ... provider_type="oauth2",
5404 ... client_id="gh_client_123",
5405 ... authorization_url="https://github.com/login/oauth/authorize",
5406 ... token_url="https://github.com/login/oauth/access_token",
5407 ... userinfo_url="https://api.github.com/user",
5408 ... scope="user:email"
5409 ... )
5410 """
5412 __tablename__ = "sso_providers"
5414 # Provider identification
5415 id: Mapped[str] = mapped_column(String(50), primary_key=True) # github, google, ibm_verify, okta, keycloak, entra, or any custom ID
5416 name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
5417 display_name: Mapped[str] = mapped_column(String(100), nullable=False)
5418 provider_type: Mapped[str] = mapped_column(String(20), nullable=False) # oauth2, oidc
5419 is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
5421 # OAuth2/OIDC Configuration
5422 client_id: Mapped[str] = mapped_column(String(255), nullable=False)
5423 client_secret_encrypted: Mapped[str] = mapped_column(Text, nullable=False) # Encrypted storage
5424 authorization_url: Mapped[str] = mapped_column(String(500), nullable=False)
5425 token_url: Mapped[str] = mapped_column(String(500), nullable=False)
5426 userinfo_url: Mapped[str] = mapped_column(String(500), nullable=False)
5427 issuer: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # For OIDC
5428 jwks_uri: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # OIDC JWKS endpoint for token signature verification
5430 # Provider Settings
5431 trusted_domains: Mapped[List[str]] = mapped_column(JSON, default=list, nullable=False)
5432 scope: Mapped[str] = mapped_column(String(200), default="openid profile email", nullable=False)
5433 auto_create_users: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
5434 team_mapping: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
5436 # Provider-specific metadata (e.g., role mappings, claim configurations)
5437 provider_metadata: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
5439 # Timestamps
5440 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
5441 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now, nullable=False)
5443 def __repr__(self):
5444 """String representation of SSO provider.
5446 Returns:
5447 String representation of the SSO provider instance
5448 """
5449 return f"<SSOProvider(id='{self.id}', name='{self.name}', enabled={self.is_enabled})>"
5452class SSOAuthSession(Base):
5453 """Tracks SSO authentication sessions and state.
5455 Maintains OAuth state parameters and callback information during
5456 the SSO authentication flow for security and session management.
5458 Attributes:
5459 id (str): Unique session ID (UUID)
5460 provider_id (str): Reference to SSO provider
5461 state (str): OAuth state parameter for CSRF protection
5462 code_verifier (str): PKCE code verifier (for OAuth 2.1)
5463 nonce (str): OIDC nonce parameter
5464 redirect_uri (str): OAuth callback URI
5465 expires_at (datetime): Session expiration time
5466 user_email (str): User email after successful auth (optional)
5467 created_at (datetime): Session creation timestamp
5469 Examples:
5470 >>> session = SSOAuthSession(
5471 ... provider_id="github",
5472 ... state="csrf-state-token",
5473 ... redirect_uri="https://gateway.example.com/auth/sso-callback/github"
5474 ... )
5475 """
5477 __tablename__ = "sso_auth_sessions"
5479 # Session identification
5480 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
5481 provider_id: Mapped[str] = mapped_column(String(50), ForeignKey("sso_providers.id"), nullable=False)
5483 # OAuth/OIDC parameters
5484 state: Mapped[str] = mapped_column(String(128), nullable=False, unique=True) # CSRF protection
5485 code_verifier: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) # PKCE
5486 nonce: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) # OIDC
5487 redirect_uri: Mapped[str] = mapped_column(String(500), nullable=False)
5489 # Session lifecycle
5490 expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: utc_now() + timedelta(minutes=10), nullable=False) # 10-minute expiration
5491 user_email: Mapped[Optional[str]] = mapped_column(String(255), ForeignKey("email_users.email"), nullable=True)
5492 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
5494 # Relationships
5495 provider: Mapped["SSOProvider"] = relationship("SSOProvider")
5496 user: Mapped[Optional["EmailUser"]] = relationship("EmailUser")
5498 @property
5499 def is_expired(self) -> bool:
5500 """Check if SSO auth session has expired.
5502 Returns:
5503 True if the session has expired, False otherwise
5504 """
5505 now = utc_now()
5506 expires = self.expires_at
5508 # Handle timezone mismatch by converting naive datetime to UTC if needed
5509 if expires.tzinfo is None:
5510 # expires_at is timezone-naive, assume it's UTC
5511 expires = expires.replace(tzinfo=timezone.utc)
5512 elif now.tzinfo is None:
5513 # now is timezone-naive (shouldn't happen with utc_now, but just in case)
5514 now = now.replace(tzinfo=timezone.utc)
5516 return now > expires
5518 def __repr__(self):
5519 """String representation of SSO auth session.
5521 Returns:
5522 str: String representation of the session object
5523 """
5524 return f"<SSOAuthSession(id='{self.id}', provider='{self.provider_id}', expired={self.is_expired})>"
5527# Event listeners for validation
5528def validate_tool_schema(mapper, connection, target):
5529 """
5530 Validate tool schema before insert/update.
5532 Args:
5533 mapper: The mapper being used for the operation.
5534 connection: The database connection.
5535 target: The target object being validated.
5537 Raises:
5538 ValueError: If the tool input schema is invalid.
5540 """
5541 # You can use mapper and connection later, if required.
5542 _ = mapper
5543 _ = connection
5545 allowed_validator_names = {
5546 "Draft4Validator",
5547 "Draft6Validator",
5548 "Draft7Validator",
5549 "Draft201909Validator",
5550 "Draft202012Validator",
5551 }
5553 if hasattr(target, "input_schema"):
5554 schema = target.input_schema
5555 if schema is None:
5556 return
5558 try:
5559 # If $schema is missing, default to Draft 2020-12 as per MCP spec.
5560 if schema.get("$schema") is None:
5561 validator_cls = jsonschema.Draft202012Validator
5562 else:
5563 validator_cls = jsonschema.validators.validator_for(schema)
5565 if validator_cls.__name__ not in allowed_validator_names:
5566 logger.warning(f"Unsupported JSON Schema draft: {validator_cls.__name__}")
5568 validator_cls.check_schema(schema)
5569 except jsonschema.exceptions.SchemaError as e:
5570 logger.warning(f"Invalid tool input schema: {str(e)}")
5571 if settings.json_schema_validation_strict:
5572 raise ValueError(f"Invalid tool input schema: {str(e)}") from e
5575def validate_tool_name(mapper, connection, target):
5576 """
5577 Validate tool name before insert/update. Check if the name matches the required pattern.
5579 Args:
5580 mapper: The mapper being used for the operation.
5581 connection: The database connection.
5582 target: The target object being validated.
5584 Raises:
5585 ValueError: If the tool name contains invalid characters.
5586 """
5587 # You can use mapper and connection later, if required.
5588 _ = mapper
5589 _ = connection
5590 if hasattr(target, "name"):
5591 try:
5592 SecurityValidator.validate_tool_name(target.name)
5593 except ValueError as e:
5594 raise ValueError(f"Invalid tool name: {str(e)}") from e
5597def validate_prompt_schema(mapper, connection, target):
5598 """
5599 Validate prompt argument schema before insert/update.
5601 Args:
5602 mapper: The mapper being used for the operation.
5603 connection: The database connection.
5604 target: The target object being validated.
5606 Raises:
5607 ValueError: If the prompt argument schema is invalid.
5608 """
5609 # You can use mapper and connection later, if required.
5610 _ = mapper
5611 _ = connection
5613 allowed_validator_names = {
5614 "Draft4Validator",
5615 "Draft6Validator",
5616 "Draft7Validator",
5617 "Draft201909Validator",
5618 "Draft202012Validator",
5619 }
5621 if hasattr(target, "argument_schema"):
5622 schema = target.argument_schema
5623 if schema is None:
5624 return
5626 try:
5627 # If $schema is missing, default to Draft 2020-12 as per MCP spec.
5628 if schema.get("$schema") is None:
5629 validator_cls = jsonschema.Draft202012Validator
5630 else:
5631 validator_cls = jsonschema.validators.validator_for(schema)
5633 if validator_cls.__name__ not in allowed_validator_names:
5634 logger.warning(f"Unsupported JSON Schema draft: {validator_cls.__name__}")
5636 validator_cls.check_schema(schema)
5637 except jsonschema.exceptions.SchemaError as e:
5638 logger.warning(f"Invalid prompt argument schema: {str(e)}")
5639 if settings.json_schema_validation_strict:
5640 raise ValueError(f"Invalid prompt argument schema: {str(e)}") from e
5643# Register validation listeners
5645listen(Tool, "before_insert", validate_tool_schema)
5646listen(Tool, "before_update", validate_tool_schema)
5647listen(Tool, "before_insert", validate_tool_name)
5648listen(Tool, "before_update", validate_tool_name)
5649listen(Prompt, "before_insert", validate_prompt_schema)
5650listen(Prompt, "before_update", validate_prompt_schema)
5653def get_db() -> Generator[Session, Any, None]:
5654 """
5655 Dependency to get database session.
5657 Commits the transaction on successful completion to avoid implicit rollbacks
5658 for read-only operations. Rolls back explicitly on exception.
5660 Yields:
5661 SessionLocal: A SQLAlchemy database session.
5663 Raises:
5664 Exception: Re-raises any exception after rolling back the transaction.
5666 Examples:
5667 >>> from mcpgateway.db import get_db
5668 >>> gen = get_db()
5669 >>> db = next(gen)
5670 >>> hasattr(db, 'query')
5671 True
5672 >>> hasattr(db, 'commit')
5673 True
5674 >>> gen.close()
5675 """
5676 db = SessionLocal()
5677 try:
5678 yield db
5679 db.commit()
5680 except Exception:
5681 try:
5682 db.rollback()
5683 except Exception:
5684 try:
5685 db.invalidate()
5686 except Exception:
5687 pass # nosec B110 - Best effort cleanup on connection failure
5688 raise
5689 finally:
5690 db.close()
5693def get_for_update(
5694 db: Session,
5695 model,
5696 entity_id=None,
5697 where: Optional[Any] = None,
5698 skip_locked: bool = False,
5699 nowait: bool = False,
5700 lock_timeout_ms: Optional[int] = None,
5701 options: Optional[List] = None,
5702):
5703 """Get entity with row lock for update operations.
5705 Args:
5706 db: SQLAlchemy Session
5707 model: ORM model class
5708 entity_id: Primary key value (optional if `where` provided)
5709 where: Optional SQLAlchemy WHERE clause to locate rows for conflict detection
5710 skip_locked: If False (default), wait for locked rows. If True, skip locked
5711 rows (returns None if row is locked). Use False for conflict checks and
5712 entity updates to ensure consistency. Use True only for job-queue patterns.
5713 nowait: If True, fail immediately if row is locked (raises OperationalError).
5714 Use this for operations that should not block. Default False.
5715 lock_timeout_ms: Optional lock timeout in milliseconds for PostgreSQL.
5716 If set, the query will wait at most this long for locks before failing.
5717 Only applies to PostgreSQL. Default None (use database default).
5718 options: Optional list of loader options (e.g., selectinload(...))
5720 Returns:
5721 The model instance or None
5723 Raises:
5724 sqlalchemy.exc.OperationalError: If nowait=True and row is locked, or if
5725 lock_timeout_ms is exceeded.
5727 Notes:
5728 - On PostgreSQL this acquires a FOR UPDATE row lock.
5729 - On SQLite (or other backends that don't support FOR UPDATE) it
5730 falls back to a regular select; when ``options`` is None it uses
5731 ``db.get`` for efficiency, otherwise it executes a select with
5732 the provided loader options.
5733 """
5734 dialect = ""
5735 try:
5736 dialect = db.bind.dialect.name
5737 except Exception:
5738 dialect = ""
5740 # Build base select statement. Prefer `where` when provided, otherwise use primary key `entity_id`.
5741 if where is not None:
5742 stmt = select(model).where(where)
5743 elif entity_id is not None:
5744 stmt = select(model).where(model.id == entity_id)
5745 else:
5746 return None
5748 if options:
5749 stmt = stmt.options(*options)
5751 if dialect != "postgresql":
5752 # SQLite and others: no FOR UPDATE support
5753 # Use db.get optimization only when querying by primary key without loader options
5754 if not options and where is None and entity_id is not None:
5755 return db.get(model, entity_id)
5756 return db.execute(stmt).scalar_one_or_none()
5758 # PostgreSQL: set lock timeout if specified
5759 if lock_timeout_ms is not None:
5760 db.execute(text(f"SET LOCAL lock_timeout = '{lock_timeout_ms}ms'"))
5762 # PostgreSQL: apply FOR UPDATE with optional nowait
5763 stmt = stmt.with_for_update(skip_locked=skip_locked, nowait=nowait)
5764 return db.execute(stmt).scalar_one_or_none()
5767# Using the existing get_db generator to create a context manager for fresh sessions
5768fresh_db_session = contextmanager(get_db) # type: ignore
5771def extract_json_field(column, json_path: str, dialect_name: Optional[str] = None):
5772 """Extract a JSON field in a database-agnostic way.
5774 This function provides cross-database compatibility for JSON field extraction,
5775 supporting both SQLite and PostgreSQL backends.
5777 Args:
5778 column: SQLAlchemy column containing JSON data
5779 json_path: JSON path in SQLite format (e.g., '$.\"tool.name\"')
5780 dialect_name: Optional database dialect name to override global backend.
5781 If not provided, uses the global backend from DATABASE_URL.
5782 Use this when querying a different database than the default.
5784 Returns:
5785 SQLAlchemy expression for extracting the JSON field as text
5787 Note:
5788 - For SQLite: Uses json_extract(column, '$.\"key\"')
5789 - For PostgreSQL: Uses column ->> 'key' operator
5790 - Backend-specific behavior is tested via unit tests in test_db.py
5791 """
5792 effective_backend = dialect_name if dialect_name is not None else backend
5794 if effective_backend == "postgresql":
5795 # PostgreSQL uses ->> operator for text extraction
5796 # Convert $.\"key\" or $.\"nested.key\" format to just the key
5797 # Handle both simple keys and nested keys with dots
5798 path_key = json_path.replace('$."', "").replace('"', "")
5799 return column.op("->>")(path_key)
5801 # SQLite and other databases use json_extract function
5802 # Keep the original $.\"key\" format
5803 return func.json_extract(column, json_path)
5806# Create all tables
5807def init_db():
5808 """
5809 Initialize database tables.
5811 Raises:
5812 Exception: If database initialization fails.
5813 """
5814 try:
5815 # Base.metadata.drop_all(bind=engine)
5816 Base.metadata.create_all(bind=engine)
5817 except SQLAlchemyError as e:
5818 raise Exception(f"Failed to initialize database: {str(e)}")
5821# ============================================================================
5822# Structured Logging Models
5823# ============================================================================
5826class StructuredLogEntry(Base):
5827 """Structured log entry for comprehensive logging and analysis.
5829 Stores all log entries with correlation IDs, performance metrics,
5830 and security context for advanced search and analytics.
5831 """
5833 __tablename__ = "structured_log_entries"
5835 # Primary key
5836 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
5838 # Timestamps
5839 timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True, default=utc_now)
5841 # Correlation and request tracking
5842 correlation_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, nullable=True)
5843 request_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, nullable=True)
5845 # Log metadata
5846 level: Mapped[str] = mapped_column(String(20), nullable=False, index=True) # DEBUG, INFO, WARNING, ERROR, CRITICAL
5847 component: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
5848 message: Mapped[str] = mapped_column(Text, nullable=False)
5849 logger: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
5851 # User and request context
5852 user_id: Mapped[Optional[str]] = mapped_column(String(255), index=True, nullable=True)
5853 user_email: Mapped[Optional[str]] = mapped_column(String(255), index=True, nullable=True)
5854 client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) # IPv6 max length
5855 user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
5856 request_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
5857 request_method: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
5859 # Performance data
5860 duration_ms: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
5861 operation_type: Mapped[Optional[str]] = mapped_column(String(100), index=True, nullable=True)
5863 # Security context
5864 is_security_event: Mapped[bool] = mapped_column(Boolean, default=False, index=True, nullable=False)
5865 security_severity: Mapped[Optional[str]] = mapped_column(String(20), index=True, nullable=True) # LOW, MEDIUM, HIGH, CRITICAL
5866 threat_indicators: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
5868 # Structured context data
5869 context: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
5870 error_details: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
5871 performance_metrics: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
5873 # System information
5874 hostname: Mapped[str] = mapped_column(String(255), nullable=False)
5875 process_id: Mapped[int] = mapped_column(Integer, nullable=False)
5876 thread_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
5877 version: Mapped[str] = mapped_column(String(50), nullable=False)
5878 environment: Mapped[str] = mapped_column(String(50), nullable=False, default="production")
5880 # OpenTelemetry trace context
5881 trace_id: Mapped[Optional[str]] = mapped_column(String(32), index=True, nullable=True)
5882 span_id: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
5884 # Indexes for performance
5885 __table_args__ = (
5886 Index("idx_log_correlation_time", "correlation_id", "timestamp"),
5887 Index("idx_log_user_time", "user_id", "timestamp"),
5888 Index("idx_log_level_time", "level", "timestamp"),
5889 Index("idx_log_component_time", "component", "timestamp"),
5890 Index("idx_log_security", "is_security_event", "security_severity", "timestamp"),
5891 Index("idx_log_operation", "operation_type", "timestamp"),
5892 Index("idx_log_trace", "trace_id", "timestamp"),
5893 )
5896class PerformanceMetric(Base):
5897 """Aggregated performance metrics from log analysis.
5899 Stores time-windowed aggregations of operation performance
5900 for analytics and trend analysis.
5901 """
5903 __tablename__ = "performance_metrics"
5905 # Primary key
5906 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
5908 # Timestamp
5909 timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True, default=utc_now)
5911 # Metric identification
5912 operation_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
5913 component: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
5915 # Aggregated metrics
5916 request_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
5917 error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
5918 error_rate: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
5920 # Duration metrics (in milliseconds)
5921 avg_duration_ms: Mapped[float] = mapped_column(Float, nullable=False)
5922 min_duration_ms: Mapped[float] = mapped_column(Float, nullable=False)
5923 max_duration_ms: Mapped[float] = mapped_column(Float, nullable=False)
5924 p50_duration_ms: Mapped[float] = mapped_column(Float, nullable=False)
5925 p95_duration_ms: Mapped[float] = mapped_column(Float, nullable=False)
5926 p99_duration_ms: Mapped[float] = mapped_column(Float, nullable=False)
5928 # Time window
5929 window_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
5930 window_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
5931 window_duration_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
5933 # Additional context
5934 metric_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
5936 __table_args__ = (
5937 Index("idx_perf_operation_time", "operation_type", "window_start"),
5938 Index("idx_perf_component_time", "component", "window_start"),
5939 Index("idx_perf_window", "window_start", "window_end"),
5940 )
5943class SecurityEvent(Base):
5944 """Security event logging for threat detection and audit trails.
5946 Specialized table for security events with enhanced context
5947 and threat analysis capabilities.
5948 """
5950 __tablename__ = "security_events"
5952 # Primary key
5953 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
5955 # Timestamps
5956 timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True, default=utc_now)
5957 detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utc_now)
5959 # Correlation tracking
5960 correlation_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, nullable=True)
5961 log_entry_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("structured_log_entries.id"), index=True, nullable=True)
5963 # Event classification
5964 event_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True) # auth_failure, suspicious_activity, rate_limit, etc.
5965 severity: Mapped[str] = mapped_column(String(20), nullable=False, index=True) # LOW, MEDIUM, HIGH, CRITICAL
5966 category: Mapped[str] = mapped_column(String(50), nullable=False, index=True) # authentication, authorization, data_access, etc.
5968 # User and request context
5969 user_id: Mapped[Optional[str]] = mapped_column(String(255), index=True, nullable=True)
5970 user_email: Mapped[Optional[str]] = mapped_column(String(255), index=True, nullable=True)
5971 client_ip: Mapped[str] = mapped_column(String(45), nullable=False, index=True)
5972 user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
5974 # Event details
5975 description: Mapped[str] = mapped_column(Text, nullable=False)
5976 action_taken: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # blocked, allowed, flagged, etc.
5978 # Threat analysis
5979 threat_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) # 0.0-1.0
5980 threat_indicators: Mapped[Dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
5981 failed_attempts_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
5983 # Resolution tracking
5984 resolved: Mapped[bool] = mapped_column(Boolean, default=False, index=True, nullable=False)
5985 resolved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
5986 resolved_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
5987 resolution_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
5989 # Alert tracking
5990 alert_sent: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
5991 alert_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
5992 alert_recipients: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)
5994 # Additional context
5995 context: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
5997 __table_args__ = (
5998 Index("idx_security_type_time", "event_type", "timestamp"),
5999 Index("idx_security_severity_time", "severity", "timestamp"),
6000 Index("idx_security_user_time", "user_id", "timestamp"),
6001 Index("idx_security_ip_time", "client_ip", "timestamp"),
6002 Index("idx_security_unresolved", "resolved", "severity", "timestamp"),
6003 )
6006# ---------------------------------------------------------------------------
6007# LLM Provider Configuration Models
6008# ---------------------------------------------------------------------------
6011class LLMProviderType:
6012 """Constants for LLM provider types."""
6014 OPENAI = "openai"
6015 AZURE_OPENAI = "azure_openai"
6016 ANTHROPIC = "anthropic"
6017 BEDROCK = "bedrock"
6018 GOOGLE_VERTEX = "google_vertex"
6019 WATSONX = "watsonx"
6020 OLLAMA = "ollama"
6021 OPENAI_COMPATIBLE = "openai_compatible"
6022 COHERE = "cohere"
6023 MISTRAL = "mistral"
6024 GROQ = "groq"
6025 TOGETHER = "together"
6027 @classmethod
6028 def get_all_types(cls) -> List[str]:
6029 """Get list of all supported provider types.
6031 Returns:
6032 List of provider type strings.
6033 """
6034 return [
6035 cls.OPENAI,
6036 cls.AZURE_OPENAI,
6037 cls.ANTHROPIC,
6038 cls.BEDROCK,
6039 cls.GOOGLE_VERTEX,
6040 cls.WATSONX,
6041 cls.OLLAMA,
6042 cls.OPENAI_COMPATIBLE,
6043 cls.COHERE,
6044 cls.MISTRAL,
6045 cls.GROQ,
6046 cls.TOGETHER,
6047 ]
6049 @classmethod
6050 def get_provider_defaults(cls) -> Dict[str, Dict[str, Any]]:
6051 """Get default configuration for each provider type.
6053 Returns:
6054 Dictionary mapping provider type to default config.
6055 """
6056 return {
6057 cls.OPENAI: {
6058 "api_base": "https://api.openai.com/v1",
6059 "default_model": "gpt-4o",
6060 "supports_model_list": True,
6061 "models_endpoint": "/models",
6062 "requires_api_key": True,
6063 "description": "OpenAI GPT models (GPT-4, GPT-4o, etc.)",
6064 },
6065 cls.AZURE_OPENAI: {
6066 "api_base": "https://{resource}.openai.azure.com/openai/deployments/{deployment}",
6067 "default_model": "",
6068 "supports_model_list": False,
6069 "requires_api_key": True,
6070 "description": "Azure OpenAI Service",
6071 },
6072 cls.ANTHROPIC: {
6073 "api_base": "https://api.anthropic.com",
6074 "default_model": "claude-sonnet-4-20250514",
6075 "supports_model_list": False,
6076 "requires_api_key": True,
6077 "description": "Anthropic Claude models",
6078 },
6079 cls.OLLAMA: {
6080 "api_base": "http://localhost:11434",
6081 "default_model": "llama3.2",
6082 "supports_model_list": True,
6083 "models_endpoint": "/api/tags",
6084 "requires_api_key": False,
6085 "description": "Local Ollama server",
6086 },
6087 cls.OPENAI_COMPATIBLE: {
6088 "api_base": "http://localhost:8080/v1",
6089 "default_model": "",
6090 "supports_model_list": True,
6091 "models_endpoint": "/models",
6092 "requires_api_key": False,
6093 "description": "Any OpenAI-compatible API server",
6094 },
6095 cls.COHERE: {
6096 "api_base": "https://api.cohere.ai/v1",
6097 "default_model": "command-r-plus",
6098 "supports_model_list": True,
6099 "models_endpoint": "/models",
6100 "requires_api_key": True,
6101 "description": "Cohere Command models",
6102 },
6103 cls.MISTRAL: {
6104 "api_base": "https://api.mistral.ai/v1",
6105 "default_model": "mistral-large-latest",
6106 "supports_model_list": True,
6107 "models_endpoint": "/models",
6108 "requires_api_key": True,
6109 "description": "Mistral AI models",
6110 },
6111 cls.GROQ: {
6112 "api_base": "https://api.groq.com/openai/v1",
6113 "default_model": "llama-3.3-70b-versatile",
6114 "supports_model_list": True,
6115 "models_endpoint": "/models",
6116 "requires_api_key": True,
6117 "description": "Groq high-speed inference",
6118 },
6119 cls.TOGETHER: {
6120 "api_base": "https://api.together.xyz/v1",
6121 "default_model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
6122 "supports_model_list": True,
6123 "models_endpoint": "/models",
6124 "requires_api_key": True,
6125 "description": "Together AI inference",
6126 },
6127 cls.BEDROCK: {
6128 "api_base": "",
6129 "default_model": "anthropic.claude-3-sonnet-20240229-v1:0",
6130 "supports_model_list": False,
6131 "requires_api_key": False,
6132 "description": "AWS Bedrock (uses IAM credentials)",
6133 },
6134 cls.GOOGLE_VERTEX: {
6135 "api_base": "",
6136 "default_model": "gemini-1.5-pro",
6137 "supports_model_list": False,
6138 "requires_api_key": False,
6139 "description": "Google Vertex AI (uses service account)",
6140 },
6141 cls.WATSONX: {
6142 "api_base": "https://us-south.ml.cloud.ibm.com",
6143 "default_model": "ibm/granite-13b-chat-v2",
6144 "supports_model_list": False,
6145 "requires_api_key": True,
6146 "description": "IBM watsonx.ai",
6147 },
6148 }
6151class LLMProvider(Base):
6152 """ORM model for LLM provider configurations.
6154 Stores credentials and settings for external LLM providers
6155 used by the internal LLM Chat feature.
6157 Attributes:
6158 id: Unique identifier (UUID)
6159 name: Display name (unique)
6160 slug: URL-safe identifier (unique)
6161 provider_type: Provider type (openai, anthropic, etc.)
6162 api_key: Encrypted API key
6163 api_base: Base URL for API requests
6164 api_version: API version (for Azure OpenAI)
6165 config: Provider-specific settings (JSON)
6166 default_model: Default model ID
6167 default_temperature: Default temperature (0.0-2.0)
6168 default_max_tokens: Default max tokens
6169 enabled: Whether provider is enabled
6170 health_status: Current health status (healthy/unhealthy/unknown)
6171 last_health_check: Last health check timestamp
6172 plugin_ids: Attached plugin IDs (JSON)
6173 """
6175 __tablename__ = "llm_providers"
6177 # Primary key
6178 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
6180 # Basic info
6181 name: Mapped[str] = mapped_column(String(255), nullable=False)
6182 slug: Mapped[str] = mapped_column(String(255), nullable=False)
6183 description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
6185 # Provider type
6186 provider_type: Mapped[str] = mapped_column(String(50), nullable=False)
6188 # Credentials (encrypted)
6189 api_key: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
6190 api_base: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
6191 api_version: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
6193 # Provider-specific configuration
6194 config: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
6196 # Default settings
6197 default_model: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
6198 default_temperature: Mapped[float] = mapped_column(Float, default=0.7, nullable=False)
6199 default_max_tokens: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
6201 # Status
6202 enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
6203 health_status: Mapped[str] = mapped_column(String(20), default="unknown", nullable=False)
6204 last_health_check: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
6206 # Plugin integration
6207 plugin_ids: Mapped[List[str]] = mapped_column(JSON, default=list, nullable=False)
6209 # Timestamps
6210 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
6211 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now, nullable=False)
6213 # Audit fields
6214 created_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
6215 modified_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
6217 # Relationships
6218 models: Mapped[List["LLMModel"]] = relationship("LLMModel", back_populates="provider", cascade="all, delete-orphan")
6220 __table_args__ = (
6221 UniqueConstraint("name", name="uq_llm_providers_name"),
6222 UniqueConstraint("slug", name="uq_llm_providers_slug"),
6223 Index("idx_llm_providers_enabled", "enabled"),
6224 Index("idx_llm_providers_type", "provider_type"),
6225 Index("idx_llm_providers_health", "health_status"),
6226 )
6228 def __repr__(self) -> str:
6229 """Return string representation.
6231 Returns:
6232 String representation of the provider.
6233 """
6234 return f"<LLMProvider(id='{self.id}', name='{self.name}', type='{self.provider_type}')>"
6237class LLMModel(Base):
6238 """ORM model for LLM model definitions.
6240 Stores model metadata and capabilities for each provider.
6242 Attributes:
6243 id: Unique identifier (UUID)
6244 provider_id: Foreign key to llm_providers
6245 model_id: Provider's model ID (e.g., gpt-4o)
6246 model_name: Display name
6247 model_alias: Optional routing alias
6248 supports_chat: Whether model supports chat completions
6249 supports_streaming: Whether model supports streaming
6250 supports_function_calling: Whether model supports function/tool calling
6251 supports_vision: Whether model supports vision/images
6252 context_window: Maximum context tokens
6253 max_output_tokens: Maximum output tokens
6254 enabled: Whether model is enabled
6255 deprecated: Whether model is deprecated
6256 """
6258 __tablename__ = "llm_models"
6260 # Primary key
6261 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
6263 # Provider relationship
6264 provider_id: Mapped[str] = mapped_column(String(36), ForeignKey("llm_providers.id", ondelete="CASCADE"), nullable=False)
6266 # Model identification
6267 model_id: Mapped[str] = mapped_column(String(255), nullable=False)
6268 model_name: Mapped[str] = mapped_column(String(255), nullable=False)
6269 model_alias: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
6270 description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
6272 # Capabilities
6273 supports_chat: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
6274 supports_streaming: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
6275 supports_function_calling: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
6276 supports_vision: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
6278 # Limits
6279 context_window: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
6280 max_output_tokens: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
6282 # Status
6283 enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
6284 deprecated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
6286 # Timestamps
6287 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
6288 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now, nullable=False)
6290 # Relationship
6291 provider: Mapped["LLMProvider"] = relationship("LLMProvider", back_populates="models")
6293 __table_args__ = (
6294 UniqueConstraint("provider_id", "model_id", name="uq_llm_models_provider_model"),
6295 Index("idx_llm_models_provider", "provider_id"),
6296 Index("idx_llm_models_enabled", "enabled"),
6297 Index("idx_llm_models_deprecated", "deprecated"),
6298 )
6300 def __repr__(self) -> str:
6301 """Return string representation.
6303 Returns:
6304 String representation of the model.
6305 """
6306 return f"<LLMModel(id='{self.id}', model_id='{self.model_id}', provider_id='{self.provider_id}')>"
6309class AuditTrail(Base):
6310 """Comprehensive audit trail for data access and changes.
6312 Tracks all significant system changes and data access for
6313 compliance and security auditing.
6314 """
6316 __tablename__ = "audit_trails"
6318 # Primary key
6319 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
6321 # Timestamps
6322 timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True, default=utc_now)
6324 # Correlation tracking
6325 correlation_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, nullable=True)
6326 request_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, nullable=True)
6328 # Action details
6329 action: Mapped[str] = mapped_column(String(100), nullable=False, index=True) # create, read, update, delete, execute, etc.
6330 resource_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True) # tool, resource, prompt, user, etc.
6331 resource_id: Mapped[Optional[str]] = mapped_column(String(255), index=True, nullable=True)
6332 resource_name: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
6334 # User context
6335 user_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
6336 user_email: Mapped[Optional[str]] = mapped_column(String(255), index=True, nullable=True)
6337 team_id: Mapped[Optional[str]] = mapped_column(String(36), index=True, nullable=True)
6339 # Request context
6340 client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
6341 user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
6342 request_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
6343 request_method: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
6345 # Change tracking
6346 old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
6347 new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
6348 changes: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
6350 # Data classification
6351 data_classification: Mapped[Optional[str]] = mapped_column(String(50), index=True, nullable=True) # public, internal, confidential, restricted
6352 requires_review: Mapped[bool] = mapped_column(Boolean, default=False, index=True, nullable=False)
6354 # Result
6355 success: Mapped[bool] = mapped_column(Boolean, nullable=False, index=True)
6356 error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
6358 # Additional context
6359 context: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
6361 __table_args__ = (
6362 Index("idx_audit_action_time", "action", "timestamp"),
6363 Index("idx_audit_resource_time", "resource_type", "resource_id", "timestamp"),
6364 Index("idx_audit_user_time", "user_id", "timestamp"),
6365 Index("idx_audit_classification", "data_classification", "timestamp"),
6366 Index("idx_audit_review", "requires_review", "timestamp"),
6367 )
6370if __name__ == "__main__":
6371 # Wait for database to be ready before initializing
6372 wait_for_db_ready(max_tries=int(settings.db_max_retries), interval=int(settings.db_retry_interval_ms) / 1000, sync=True) # Converting ms to s
6374 init_db()
6377@event.listens_for(Gateway, "before_insert")
6378def set_gateway_slug(_mapper, _conn, target):
6379 """Set the slug for a Gateway before insert.
6381 Args:
6382 _mapper: Mapper
6383 _conn: Connection
6384 target: Target Gateway instance
6385 """
6387 target.slug = slugify(target.name)
6390@event.listens_for(A2AAgent, "before_insert")
6391def set_a2a_agent_slug(_mapper, _conn, target):
6392 """Set the slug for an A2AAgent before insert.
6394 Args:
6395 _mapper: Mapper
6396 _conn: Connection
6397 target: Target A2AAgent instance
6398 """
6399 target.slug = slugify(target.name)
6402@event.listens_for(GrpcService, "before_insert")
6403def set_grpc_service_slug(_mapper, _conn, target):
6404 """Set the slug for a GrpcService before insert.
6406 Args:
6407 _mapper: Mapper
6408 _conn: Connection
6409 target: Target GrpcService instance
6410 """
6411 target.slug = slugify(target.name)
6414@event.listens_for(LLMProvider, "before_insert")
6415def set_llm_provider_slug(_mapper, _conn, target):
6416 """Set the slug for an LLMProvider before insert.
6418 Args:
6419 _mapper: Mapper
6420 _conn: Connection
6421 target: Target LLMProvider instance
6422 """
6423 target.slug = slugify(target.name)
6426@event.listens_for(EmailTeam, "before_insert")
6427def set_email_team_slug(_mapper, _conn, target):
6428 """Set the slug for an EmailTeam before insert.
6430 Args:
6431 _mapper: Mapper
6432 _conn: Connection
6433 target: Target EmailTeam instance
6434 """
6435 if not target.slug:
6436 target.slug = slugify(target.name)
6439@event.listens_for(Tool, "before_insert")
6440@event.listens_for(Tool, "before_update")
6441def set_custom_name_and_slug(mapper, connection, target): # pylint: disable=unused-argument
6442 """
6443 Event listener to set custom_name, custom_name_slug, and name for Tool before insert/update.
6445 - Sets custom_name to original_name if not provided.
6446 - Calculates custom_name_slug from custom_name using slugify.
6447 - Updates name to gateway_slug + separator + custom_name_slug.
6448 - Sets display_name to custom_name if not provided.
6450 Note: The gateway relationship must be explicitly set (via target.gateway = gateway_obj)
6451 before adding the tool to the session if gateway namespacing is needed. If only
6452 gateway_id is set without the relationship, we look up the gateway name via a direct
6453 SQL query.
6455 Args:
6456 mapper: SQLAlchemy mapper for the Tool model.
6457 connection: Database connection.
6458 target: The Tool instance being inserted or updated.
6459 """
6460 # Set custom_name to original_name if not provided
6461 if not target.custom_name:
6462 target.custom_name = target.original_name
6463 # Set display_name to custom_name if not provided
6464 if not target.display_name:
6465 target.display_name = target.custom_name
6466 # Always update custom_name_slug from custom_name
6467 target.custom_name_slug = slugify(target.custom_name)
6469 # Get gateway_slug - check for explicitly set gateway relationship first
6470 gateway_slug = ""
6471 if target.gateway:
6472 # Gateway relationship is already loaded
6473 gateway_slug = slugify(target.gateway.name)
6474 elif target.gateway_id:
6475 # Gateway relationship not loaded but gateway_id is set
6476 # Use a cached gateway name if available from gateway_name_cache attribute
6477 if hasattr(target, "gateway_name_cache") and target.gateway_name_cache:
6478 gateway_slug = slugify(target.gateway_name_cache)
6479 else:
6480 # Fall back to querying the database
6481 try:
6482 result = connection.execute(text("SELECT name FROM gateways WHERE id = :gw_id"), {"gw_id": target.gateway_id})
6483 row = result.fetchone()
6484 if row:
6485 gateway_slug = slugify(row[0])
6486 except Exception: # nosec B110 - intentionally proceed without prefix on failure
6487 pass
6489 if gateway_slug:
6490 sep = settings.gateway_tool_name_separator
6491 target.name = f"{gateway_slug}{sep}{target.custom_name_slug}"
6492 else:
6493 target.name = target.custom_name_slug
6496@event.listens_for(Prompt, "before_insert")
6497@event.listens_for(Prompt, "before_update")
6498def set_prompt_name_and_slug(mapper, connection, target): # pylint: disable=unused-argument
6499 """Set name fields for Prompt before insert/update.
6501 - Sets original_name from name if missing (legacy compatibility).
6502 - Sets custom_name to original_name if not provided.
6503 - Sets display_name to custom_name if not provided.
6504 - Calculates custom_name_slug from custom_name.
6505 - Updates name to gateway_slug + separator + custom_name_slug.
6507 Note: The gateway relationship must be explicitly set (via target.gateway = gateway_obj)
6508 before adding the prompt to the session if gateway namespacing is needed. If only
6509 gateway_id is set without the relationship, we look up the gateway name via a direct
6510 SQL query.
6512 Args:
6513 mapper: SQLAlchemy mapper for the Prompt model.
6514 connection: Database connection for the insert/update.
6515 target: Prompt instance being inserted or updated.
6516 """
6517 if not target.original_name:
6518 target.original_name = target.name
6519 if not target.custom_name:
6520 target.custom_name = target.original_name
6521 if not target.display_name:
6522 target.display_name = target.custom_name
6523 target.custom_name_slug = slugify(target.custom_name)
6525 # Get gateway_slug - check for explicitly set gateway relationship first
6526 gateway_slug = ""
6527 if target.gateway:
6528 # Gateway relationship is already loaded
6529 gateway_slug = slugify(target.gateway.name)
6530 elif target.gateway_id:
6531 # Gateway relationship not loaded but gateway_id is set
6532 # Use a cached gateway name if available from gateway_name_cache attribute
6533 if hasattr(target, "gateway_name_cache") and target.gateway_name_cache:
6534 gateway_slug = slugify(target.gateway_name_cache)
6535 else:
6536 # Fall back to querying the database
6537 try:
6538 result = connection.execute(text("SELECT name FROM gateways WHERE id = :gw_id"), {"gw_id": target.gateway_id})
6539 row = result.fetchone()
6540 if row:
6541 gateway_slug = slugify(row[0])
6542 except Exception: # nosec B110 - intentionally proceed without prefix on failure
6543 pass
6545 if gateway_slug:
6546 sep = settings.gateway_tool_name_separator
6547 target.name = f"{gateway_slug}{sep}{target.custom_name_slug}"
6548 else:
6549 target.name = target.custom_name_slug