Files

319 lines
12 KiB
Python
Raw Permalink Normal View History

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Execution layer for guardrailed ticket DB connectors. All functions here are
synchronous (drivers are sync) — callers run them via asyncio.to_thread.
The read-only guarantee is enforced at the SESSION level, independently of
the SQL guardrails: default_transaction_read_only for Postgres, SESSION
TRANSACTION READ ONLY for MySQL, plus statement timeouts on both.
"""
import io
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
from uuid import UUID
from app.core.logger import get_logger
from app.core.outbound_policy import pin_outbound_target
from app.core.security import decrypt_api_key
logger = get_logger(__name__)
CONNECT_TIMEOUT_SECONDS = 8
SSH_CONNECT_TIMEOUT_SECONDS = 10
# Schemas that are never discovered or queried.
SYSTEM_SCHEMAS = (
"pg_catalog", "information_schema", "pg_toast",
"mysql", "performance_schema", "sys",
)
@dataclass
class DBConnectorConfig:
"""Plain snapshot of a TicketDBConnector row (secrets decrypted) so the
executor never holds ORM objects across sessions/threads."""
id: Optional[UUID]
organization_id: Optional[UUID]
name: str
engine: str
host: str
port: int
database: str
username: str
password: str
allowed_tables: List[str] = field(default_factory=list)
masked_columns: List[str] = field(default_factory=list)
# {"table": "customer_column"} — tables restricted to the ticket
# customer's own rows. See sql_guardrails._apply_row_scope.
row_scope: dict = field(default_factory=dict)
row_scope_key: str = "email"
max_rows: int = 100
statement_timeout_ms: int = 5000
# SSH tunnel (bastion/jump host) — the common production topology where
# the database isn't directly reachable.
ssh_enabled: bool = False
ssh_host: Optional[str] = None
ssh_port: int = 22
ssh_username: Optional[str] = None
ssh_password: Optional[str] = None
ssh_private_key: Optional[str] = None
ssh_private_key_passphrase: Optional[str] = None
@classmethod
def from_model(cls, connector) -> "DBConnectorConfig":
return cls(
id=connector.id,
organization_id=connector.organization_id,
name=connector.name,
engine=str(connector.engine),
host=connector.host,
port=connector.port,
database=connector.database,
username=connector.username,
password=decrypt_api_key(connector.encrypted_password),
allowed_tables=list(connector.allowed_tables or []),
masked_columns=list(connector.masked_columns or []),
row_scope=dict(connector.row_scope or {}),
row_scope_key=connector.row_scope_key or "email",
max_rows=connector.max_rows or 100,
statement_timeout_ms=connector.statement_timeout_ms or 5000,
ssh_enabled=bool(connector.ssh_enabled),
ssh_host=connector.ssh_host,
ssh_port=connector.ssh_port or 22,
ssh_username=connector.ssh_username,
ssh_password=(
decrypt_api_key(connector.encrypted_ssh_password)
if connector.encrypted_ssh_password else None
),
ssh_private_key=(
decrypt_api_key(connector.encrypted_ssh_private_key)
if connector.encrypted_ssh_private_key else None
),
ssh_private_key_passphrase=(
decrypt_api_key(connector.encrypted_ssh_key_passphrase)
if connector.encrypted_ssh_key_passphrase else None
),
)
def _load_ssh_key(key_text: str, passphrase: Optional[str]):
"""Parse a PEM/OpenSSH private key of any common type from a string,
without writing it to disk."""
import paramiko
last_error = None
# DSA is obsolete — Ed25519/RSA/ECDSA cover every key type a modern host
# issues.
for key_class in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey):
try:
return key_class.from_private_key(
io.StringIO(key_text), password=passphrase or None
)
except Exception as e: # wrong type or bad passphrase — try the next
last_error = e
raise ValueError(f"Unsupported or unreadable SSH private key: {last_error}")
@contextmanager
def _ssh_tunnel(config: DBConnectorConfig, ssh_address: Optional[str] = None):
"""Open an SSH local-forward tunnel to the database and yield the local
(host, port) to connect through. ``ssh_address`` is the bastion's vetted
IP when the outbound policy pinned one. Always torn down on exit."""
from sshtunnel import SSHTunnelForwarder
kwargs = {
"ssh_username": config.ssh_username,
"remote_bind_address": (config.host, config.port),
# 127.0.0.1:0 — the OS assigns a free local port per tunnel.
"local_bind_address": ("127.0.0.1", 0),
"set_keepalive": 15.0,
}
if config.ssh_private_key:
kwargs["ssh_pkey"] = _load_ssh_key(
config.ssh_private_key, config.ssh_private_key_passphrase
)
elif config.ssh_password:
kwargs["ssh_password"] = config.ssh_password
else:
raise ValueError("SSH tunnel needs a private key or password")
tunnel = SSHTunnelForwarder((ssh_address or config.ssh_host, config.ssh_port), **kwargs)
tunnel.start()
try:
yield "127.0.0.1", tunnel.local_bind_port
finally:
try:
tunnel.stop()
except Exception as e:
logger.warning(f"SSH tunnel teardown warning (non-critical): {e}")
def _driver_connect(config: DBConnectorConfig, host: str, port: int,
hostaddr: Optional[str] = None):
"""Open a read-only DB session with a statement timeout, to (host, port)
— which is the tunnel's local endpoint when tunneling. ``hostaddr`` is
the vetted IP to dial instead of resolving ``host`` again."""
if config.engine == "mysql":
import pymysql
conn = pymysql.connect(
# pymysql has no separate address field; no TLS is configured
# here, so the name isn't needed for verification.
host=hostaddr or host,
port=port,
user=config.username,
password=config.password,
database=config.database,
connect_timeout=CONNECT_TIMEOUT_SECONDS,
read_timeout=max(1, config.statement_timeout_ms // 1000 + 1),
)
with conn.cursor() as cursor:
cursor.execute("SET SESSION TRANSACTION READ ONLY")
# max_execution_time bounds SELECTs server-side (milliseconds).
cursor.execute(f"SET SESSION max_execution_time = {int(config.statement_timeout_ms)}")
return conn
import psycopg
# libpq dials hostaddr and keeps host for TLS verification.
pinned = {"hostaddr": hostaddr} if hostaddr else {}
return psycopg.connect(
host=host,
**pinned,
port=port,
dbname=config.database,
user=config.username,
password=config.password,
connect_timeout=CONNECT_TIMEOUT_SECONDS,
options=(
f"-c statement_timeout={int(config.statement_timeout_ms)} "
"-c default_transaction_read_only=on"
),
)
def first_hop(ssh_enabled: bool, ssh_host: Optional[str], host: Optional[str]) -> Tuple[Optional[str], str]:
"""The host this server dials first, with its field label, for the
outbound policy: the SSH bastion when tunnelling, else the database. A
database behind a bastion is reached from the bastion, and may be private."""
if ssh_enabled:
return ssh_host, "SSH host"
return host, "Database host"
@contextmanager
def _connection(config: DBConnectorConfig):
"""Yield a read-only DB connection, opening an SSH tunnel first when the
connector is configured for one. Tunnel and connection are both closed on
exit — a fresh pair per call keeps the (short-lived) investigation queries
isolated."""
# Vetted on every connect, not only on save, and the vetted address is
# the one dialled: DNS can change, even between two lookups.
pinned = pin_outbound_target(*first_hop(config.ssh_enabled, config.ssh_host, config.host))
if config.ssh_enabled:
if not config.ssh_host or not config.ssh_username:
raise ValueError("SSH tunnel is enabled but host/username are missing")
with _ssh_tunnel(config, pinned) as (host, port):
conn = _driver_connect(config, host, port)
try:
yield conn
finally:
try:
conn.close()
except Exception:
pass
else:
conn = _driver_connect(config, config.host, config.port, pinned)
try:
yield conn
finally:
try:
conn.close()
except Exception:
pass
def run_readonly_query(
config: DBConnectorConfig, sql: str
) -> Tuple[List[str], List[tuple]]:
"""Execute validated SQL in a read-only session; fetches at most
max_rows + 1 (the validator already forced a LIMIT — this is belt and
braces)."""
with _connection(config) as conn:
with conn.cursor() as cursor:
cursor.execute(sql)
columns = [d[0] for d in (cursor.description or [])]
rows = cursor.fetchmany(config.max_rows + 1)
return columns, list(rows)
def test_connection(config: DBConnectorConfig) -> Dict:
"""Connect and discover user tables + columns (for the allowlist picker).
Returns {"tables": [{"schema", "table", "columns": [{"name", "type"}]}]}."""
placeholders = ", ".join(["%s"] * len(SYSTEM_SCHEMAS))
sql = (
"SELECT table_schema, table_name, column_name, data_type "
"FROM information_schema.columns "
f"WHERE table_schema NOT IN ({placeholders}) "
"ORDER BY table_schema, table_name, ordinal_position"
)
with _connection(config) as conn:
with conn.cursor() as cursor:
cursor.execute(sql, SYSTEM_SCHEMAS)
rows = cursor.fetchall()
tables: Dict[Tuple[str, str], List[Dict]] = {}
for schema, table, column, data_type in rows:
tables.setdefault((schema, table), []).append({"name": column, "type": data_type})
return {
"tables": [
{"schema": schema, "table": table, "columns": columns}
for (schema, table), columns in tables.items()
]
}
def describe_columns(config: DBConnectorConfig, schema: str, table: str) -> List[Dict]:
"""Column names/types of one table via information_schema."""
with _connection(config) as conn:
with conn.cursor() as cursor:
cursor.execute(
"SELECT column_name, data_type FROM information_schema.columns "
"WHERE table_schema = %s AND table_name = %s ORDER BY ordinal_position",
(schema, table),
)
return [{"name": name, "type": data_type} for name, data_type in cursor.fetchall()]
def format_result_table(
columns: List[str], rows: List[tuple], max_rows: int, max_value_chars: int = 200
) -> str:
"""Compact text rendering of a result set for the model."""
if not columns:
return "(no result)"
shown = rows[:max_rows]
lines = [" | ".join(columns)]
for row in shown:
lines.append(
" | ".join(
(str(value)[:max_value_chars] if value is not None else "NULL")
for value in row
)
)
suffix = f"({len(shown)} row{'s' if len(shown) != 1 else ''}"
if len(rows) > max_rows:
suffix += ", truncated"
suffix += ")"
lines.append(suffix)
return "\n".join(lines)