""" 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. CRUD + connection testing for guardrailed ticket DB connectors. Credentials are encrypted at rest and never returned by any endpoint. """ import asyncio from datetime import datetime, timezone from typing import List from uuid import UUID from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from app.core.logger import get_logger from app.core.outbound_policy import require_public_target from app.core.security import encrypt_api_key from app.database import get_db from app.models.schemas.ticket_db_connector import ( DBConnectorCreate, DBConnectorDiscoverRequest, DBConnectorDiscoverResponse, DBConnectorOut, DBConnectorUpdate, ) from app.models.ticket_db_connector import TicketDBConnector from app.models.user import User from app.api.tickets import require_any_permission from app.services.db_connector_service import DBConnectorConfig, first_hop, test_connection from app.services.ticket_access import check_ticketing_access logger = get_logger(__name__) router = APIRouter() manage_connectors = require_any_permission("manage_ticket_connectors", "manage_organization") # Map API secret field -> encrypted model column. Omitting a secret on update # keeps the stored value; the plaintext never round-trips to the client. _SSH_SECRET_COLUMNS = { "ssh_password": "encrypted_ssh_password", "ssh_private_key": "encrypted_ssh_private_key", "ssh_private_key_passphrase": "encrypted_ssh_key_passphrase", } def _config_from_discover(payload, organization_id) -> DBConnectorConfig: return DBConnectorConfig( id=None, organization_id=organization_id, name="draft", engine=payload.engine, host=payload.host, port=payload.port, database=payload.database, username=payload.username, password=payload.password, ssh_enabled=payload.ssh_enabled, ssh_host=payload.ssh_host, ssh_port=payload.ssh_port, ssh_username=payload.ssh_username, ssh_password=payload.ssh_password, ssh_private_key=payload.ssh_private_key, ssh_private_key_passphrase=payload.ssh_private_key_passphrase, ) def _get_connector_or_404(db: Session, connector_id: UUID, user: User) -> TicketDBConnector: connector = ( db.query(TicketDBConnector) .filter( TicketDBConnector.id == connector_id, TicketDBConnector.organization_id == user.organization_id, ) .first() ) if connector is None: raise HTTPException(status_code=404, detail="Connector not found") return connector async def _discover(config: DBConnectorConfig) -> DBConnectorDiscoverResponse: try: result = await asyncio.to_thread(test_connection, config) return DBConnectorDiscoverResponse(ok=True, tables=result["tables"]) except Exception as e: return DBConnectorDiscoverResponse(ok=False, error=str(e)[:500]) @router.get("", response_model=List[DBConnectorOut]) async def list_connectors( current_user: User = Depends(manage_connectors), db: Session = Depends(get_db), ): check_ticketing_access(db, current_user.organization_id) return ( db.query(TicketDBConnector) .filter(TicketDBConnector.organization_id == current_user.organization_id) .order_by(TicketDBConnector.created_at) .all() ) @router.post("/discover", response_model=DBConnectorDiscoverResponse) async def discover_tables( payload: DBConnectorDiscoverRequest, current_user: User = Depends(manage_connectors), db: Session = Depends(get_db), ): """Pre-save connection test: connect with the given credentials and return the discoverable tables/columns for the allowlist picker.""" check_ticketing_access(db, current_user.organization_id) require_public_target(*first_hop(payload.ssh_enabled, payload.ssh_host, payload.host)) return await _discover(_config_from_discover(payload, current_user.organization_id)) @router.post("", response_model=DBConnectorOut, status_code=201) async def create_connector( payload: DBConnectorCreate, current_user: User = Depends(manage_connectors), db: Session = Depends(get_db), ): check_ticketing_access(db, current_user.organization_id) require_public_target(*first_hop(payload.ssh_enabled, payload.ssh_host, payload.host)) connector = TicketDBConnector( organization_id=current_user.organization_id, name=payload.name, engine=payload.engine, host=payload.host, port=payload.port, database=payload.database, username=payload.username, encrypted_password=encrypt_api_key(payload.password), enabled=payload.enabled, allowed_tables=payload.allowed_tables, masked_columns=payload.masked_columns, row_scope=payload.row_scope, row_scope_key=payload.row_scope_key, max_rows=payload.max_rows, statement_timeout_ms=payload.statement_timeout_ms, ssh_enabled=payload.ssh_enabled, ssh_host=payload.ssh_host, ssh_port=payload.ssh_port, ssh_username=payload.ssh_username, ) for field_name, column in _SSH_SECRET_COLUMNS.items(): value = getattr(payload, field_name, None) if value: setattr(connector, column, encrypt_api_key(value)) db.add(connector) db.commit() db.refresh(connector) return connector @router.patch("/{connector_id}", response_model=DBConnectorOut) async def update_connector( connector_id: UUID, payload: DBConnectorUpdate, current_user: User = Depends(manage_connectors), db: Session = Depends(get_db), ): check_ticketing_access(db, current_user.organization_id) connector = _get_connector_or_404(db, connector_id, current_user) data = payload.model_dump(exclude_unset=True) require_public_target(*first_hop( data.get("ssh_enabled", connector.ssh_enabled), data.get("ssh_host", connector.ssh_host), data.get("host", connector.host), )) password = data.pop("password", None) if password: connector.encrypted_password = encrypt_api_key(password) # SSH secrets map to encrypted_* columns; omitting one keeps the stored value. for field_name, column in _SSH_SECRET_COLUMNS.items(): if field_name in data: value = data.pop(field_name) if value: setattr(connector, column, encrypt_api_key(value)) for key, value in data.items(): setattr(connector, key, value) db.commit() db.refresh(connector) return connector @router.delete("/{connector_id}", status_code=204) async def delete_connector( connector_id: UUID, current_user: User = Depends(manage_connectors), db: Session = Depends(get_db), ): check_ticketing_access(db, current_user.organization_id) connector = _get_connector_or_404(db, connector_id, current_user) db.delete(connector) db.commit() @router.post("/{connector_id}/test", response_model=DBConnectorDiscoverResponse) async def test_saved_connector( connector_id: UUID, current_user: User = Depends(manage_connectors), db: Session = Depends(get_db), ): """Test a saved connector with its stored credentials; refreshes the discoverable table list and records the outcome.""" check_ticketing_access(db, current_user.organization_id) connector = _get_connector_or_404(db, connector_id, current_user) result = await _discover(DBConnectorConfig.from_model(connector)) connector.last_test_at = datetime.now(timezone.utc) connector.last_test_ok = result.ok db.commit() return result