226 lines
8.1 KiB
Python
226 lines
8.1 KiB
Python
"""
|
|||
|
|
Copyright 2024-2026 ChatterMate
|
||
|
|
|
||
|
|
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
|