Skip to content
Merged

Dev #404

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions py-src/data_formulator/data_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import inspect
import json as _json
import logging
import re
import threading
import time
from pathlib import Path
Expand Down Expand Up @@ -1241,6 +1242,7 @@ def list_connectors():
"source": "admin" if is_admin else "user",
"deletable": not is_admin,
"source_type": connector._loader_class.__name__,
"type_name": connector._loader_class.DISPLAY_NAME or connector._icon,
"display_name": connector._display_name,
"icon": connector._icon,
"connected": connected,
Expand Down Expand Up @@ -1402,6 +1404,24 @@ def _connectors_jail(identity: str, *, mkdir: bool = True) -> ConfinedDir:
return ConfinedDir(_connectors_dir(identity), mkdir=mkdir)


_CONNECTOR_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,120}$")


def _validate_connector_id_for_fs(connector_id: str) -> str:
"""Validate connector id before any filesystem-derived usage.

Allows stable connector IDs used by this app (alnum plus ``_.:-``),
rejects separators/whitespace and other special characters.
"""
value = str(connector_id).strip()
if not _CONNECTOR_ID_RE.fullmatch(value):
raise AppError(
ErrorCode.VALIDATION_ERROR,
"Invalid connector_id format",
)
return value


def _safe_source_filename(source_id: str) -> str:
"""Sanitise a source_id into a safe, collision-resistant filename component.

Expand Down Expand Up @@ -1444,6 +1464,37 @@ def _remove_user_connector(identity: str, connector_id: str) -> None:
logger.warning("Failed to remove connector spec '%s': %s", connector_id, e)


def _update_user_connector_display_name(identity: str, connector_id: str, display_name: str) -> None:
connector_id = _validate_connector_id_for_fs(connector_id)
jail = _connectors_jail(identity, mkdir=False)
filename = f"{_safe_source_filename(connector_id)}.json"
if not jail.exists(filename):
raise AppError(ErrorCode.CONNECTOR_ERROR, f"Connector config not found: {connector_id}")
entry = _json.loads(jail.read_text(filename))
entry["display_name"] = display_name
jail.write_text(filename, _json.dumps(entry, ensure_ascii=False, indent=2))


@connectors_bp.route("/api/connectors/<path:connector_id>", methods=["PATCH"])
def update_connector(connector_id: str):
"""Rename a user connector without changing its stable source ID."""
if connector_id in _ADMIN_CONNECTOR_IDS:
raise AppError(ErrorCode.ACCESS_DENIED, "Admin connectors cannot be renamed")
connector_id = _validate_connector_id_for_fs(connector_id)

display_name = str((request.get_json() or {}).get("display_name", "")).strip()
if not display_name:
raise AppError(ErrorCode.INVALID_REQUEST, "display_name is required")
if len(display_name) > 120:
raise AppError(ErrorCode.VALIDATION_ERROR, "display_name must be 120 characters or fewer")

_registry_key, connector = _resolve_connector_with_key({"connector_id": connector_id})
identity = DataConnector._get_identity()
_update_user_connector_display_name(identity, connector._source_id, display_name)
connector._display_name = display_name
return json_ok({"id": connector_id, "display_name": display_name})


@connectors_bp.route("/api/connectors/<path:connector_id>", methods=["DELETE"])
def delete_connector(connector_id: str):
"""Delete a **user** connector instance, clear vault credentials, and remove from config.
Expand Down
6 changes: 3 additions & 3 deletions py-src/data_formulator/data_loader/athena_data_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ def list_params() -> list[dict[str, Any]]:
{"name": "aws_secret_access_key", "type": "string", "required": False, "default": "", "sensitive": True, "tier": "auth", "description": "AWS secret access key (not required if using aws_profile)"},
{"name": "aws_session_token", "type": "string", "required": False, "default": "", "sensitive": True, "tier": "auth", "description": "AWS session token (required for temporary credentials)"},
{"name": "region_name", "type": "string", "required": True, "default": "us-east-1", "tier": "connection", "description": "AWS region name"},
{"name": "workgroup", "type": "string", "required": False, "default": "primary", "tier": "connection", "description": "Athena workgroup name (output location is fetched from workgroup configuration)"},
{"name": "output_location", "type": "string", "required": False, "default": "", "tier": "connection", "description": "S3 output location for query results (e.g., s3://bucket/path/). If empty, uses workgroup configuration."},
{"name": "workgroup", "type": "string", "required": False, "default": "primary", "tier": "connection", "advanced": True, "description": "Athena workgroup name (output location is fetched from workgroup configuration)"},
{"name": "output_location", "type": "string", "required": False, "default": "", "tier": "connection", "advanced": True, "description": "S3 output location for query results (e.g., s3://bucket/path/). If empty, uses workgroup configuration."},
{"name": "database", "type": "string", "required": False, "default": "", "tier": "filter", "description": "Default database/catalog to use for queries"},
{"name": "query_timeout", "type": "number", "required": False, "default": 300, "tier": "connection", "description": "Query execution timeout in seconds (default: 300 = 5 minutes)"}
{"name": "query_timeout", "type": "number", "required": False, "default": 300, "tier": "connection", "advanced": True, "description": "Query execution timeout in seconds (default: 300 = 5 minutes)"}
]
return params_list

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def list_params() -> list[dict[str, Any]]:
{"name": "credential_chain", "type": "string", "required": False, "default": "cli;managed_identity;env", "tier": "auth", "description": "Ordered list of Azure credential providers (cli;managed_identity;env)"},
{"name": "account_key", "type": "string", "required": False, "default": "", "sensitive": True, "tier": "auth", "description": "Azure storage account key"},
{"name": "sas_token", "type": "string", "required": False, "default": "", "sensitive": True, "tier": "auth", "description": "Azure SAS token"},
{"name": "endpoint", "type": "string", "required": False, "default": "blob.core.windows.net", "tier": "connection", "description": "Azure endpoint override"}
{"name": "endpoint", "type": "string", "required": False, "default": "blob.core.windows.net", "tier": "connection", "advanced": True, "description": "Azure endpoint override"}
]
return params_list

Expand Down
2 changes: 1 addition & 1 deletion py-src/data_formulator/data_loader/bigquery_data_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def list_params() -> list[dict[str, Any]]:
{"name": "project_id", "type": "text", "required": True, "tier": "connection", "description": "Google Cloud Project ID", "default": ""},
{"name": "dataset_id", "type": "text", "required": False, "tier": "filter", "description": "Dataset ID(s) - leave empty for all, or specify one (e.g., 'billing') or multiple separated by commas (e.g., 'billing,enterprise_collected,ga_api')", "default": ""},
{"name": "credentials_path", "type": "text", "required": False, "tier": "auth", "description": "Path to service account JSON file (optional)", "default": ""},
{"name": "location", "type": "text", "required": False, "tier": "connection", "description": "BigQuery location (default: US)", "default": "US"}
{"name": "location", "type": "text", "required": False, "tier": "connection", "advanced": True, "description": "BigQuery location (default: US)", "default": "US"}
]

@staticmethod
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def list_params() -> list[dict[str, Any]]:
"required": False,
"default": "true",
"tier": "connection",
"advanced": True,
"description": "Include files in subdirectories",
},
{
Expand All @@ -62,6 +63,7 @@ def list_params() -> list[dict[str, Any]]:
"required": False,
"default": "",
"tier": "connection",
"advanced": True,
"description": "Glob pattern to filter files (e.g. '*.csv')",
},
]
Expand Down
2 changes: 1 addition & 1 deletion py-src/data_formulator/data_loader/mongodb_data_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class MongoDBDataLoader(ExternalDataLoader):
def list_params() -> list[dict[str, Any]]:
params_list = [
{"name": "host", "type": "string", "required": True, "default": "localhost", "tier": "connection", "description": "server address"},
{"name": "port", "type": "int", "required": False, "default": 27017, "tier": "connection", "description": "server port"},
{"name": "port", "type": "int", "required": False, "default": 27017, "tier": "connection", "advanced": True, "description": "server port"},
{"name": "username", "type": "string", "required": False, "default": "", "tier": "auth", "description": "leave blank if no auth"},
{"name": "password", "type": "string", "required": False, "default": "", "sensitive": True, "tier": "auth", "description": "leave blank if no auth"},
{"name": "database", "type": "string", "required": True, "default": "", "tier": "connection", "description": "database name"},
Expand Down
1 change: 1 addition & 0 deletions py-src/data_formulator/data_loader/mssql_data_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def list_params() -> list[dict[str, Any]]:
"required": False,
"default": "1433",
"tier": "connection",
"advanced": True,
"description": "SQL Server port (default: 1433)",
},
{
Expand Down
2 changes: 1 addition & 1 deletion py-src/data_formulator/data_loader/mysql_data_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def list_params() -> list[dict[str, Any]]:
{"name": "user", "type": "string", "required": True, "default": "root", "tier": "auth", "description": "MySQL username"},
{"name": "password", "type": "string", "required": False, "default": "", "sensitive": True, "tier": "auth", "description": "leave blank for no password"},
{"name": "host", "type": "string", "required": True, "default": "localhost", "tier": "connection", "description": "server address"},
{"name": "port", "type": "int", "required": False, "default": 3306, "tier": "connection", "description": "server port"},
{"name": "port", "type": "int", "required": False, "default": 3306, "tier": "connection", "advanced": True, "description": "server port"},
{"name": "database", "type": "string", "required": False, "default": "", "tier": "filter", "description": "Database name (leave empty to browse all databases)"}
]
return params_list
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def list_params() -> list[dict[str, Any]]:
{"name": "user", "type": "string", "required": True, "default": "postgres", "tier": "auth", "description": "PostgreSQL username"},
{"name": "password", "type": "string", "required": False, "default": "", "sensitive": True, "tier": "auth", "description": "leave blank for no password"},
{"name": "host", "type": "string", "required": True, "default": "localhost", "tier": "connection", "description": "PostgreSQL host"},
{"name": "port", "type": "string", "required": False, "default": "5432", "tier": "connection", "description": "PostgreSQL port"},
{"name": "port", "type": "string", "required": False, "default": "5432", "tier": "connection", "advanced": True, "description": "PostgreSQL port"},
{"name": "database", "type": "string", "required": False, "default": "", "tier": "filter", "description": "Database name (leave empty to browse all databases)"}
]
return params_list
Expand Down
15 changes: 14 additions & 1 deletion py-src/data_formulator/datalake/naming.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@

from __future__ import annotations

import re


_SAFE_SOURCE_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")


def safe_source_id(source_id: str) -> str:
"""Sanitise a ``source_id`` into a filesystem-safe, collision-resistant string.
Expand All @@ -24,6 +29,7 @@ def safe_source_id(source_id: str) -> str:
* ``/`` and ``\\`` → ``_`` (path separators)
* ``:`` → ``--`` (Windows-unsafe, and preserves uniqueness so that
``mysql:prod`` and ``mysql_prod`` map to different filenames)
* final value must match ``[A-Za-z0-9._-]+`` and not be ``.`` or ``..``

Examples::

Expand All @@ -34,7 +40,14 @@ def safe_source_id(source_id: str) -> str:
>>> safe_source_id("a:b/c\\\\d")
'a--b_c_d'
"""
return source_id.replace("/", "_").replace("\\", "_").replace(":", "--")
sanitized = source_id.replace("/", "_").replace("\\", "_").replace(":", "--")
if not sanitized or sanitized in {".", ".."}:
raise ValueError("source_id must not be empty or relative-dot segments")
if len(sanitized) > 255:
raise ValueError("source_id is too long")
if not _SAFE_SOURCE_ID_RE.fullmatch(sanitized):
raise ValueError("source_id contains unsupported characters")
return sanitized


__all__ = ["safe_source_id"]
38 changes: 38 additions & 0 deletions src/app/connectorNames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const CONNECTION_IDENTITY_KEYS = [
'host',
'server',
'server_hostname',
'endpoint',
'url',
'account_name',
'bucket',
'project_id',
'kusto_cluster',
'database',
'root_dir',
] as const;

const conciseIdentity = (value: string): string => {
const trimmed = value.trim().replace(/\/$/, '');
if (!trimmed) return '';

try {
const parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
return parsed.host || trimmed;
} catch {
return trimmed;
}
};

export const deriveConnectorDisplayName = (
loaderName: string,
params: Record<string, unknown>,
): string => {
for (const key of CONNECTION_IDENTITY_KEYS) {
const value = params[key];
if (typeof value !== 'string') continue;
const identity = conciseIdentity(value);
if (identity) return `${loaderName} · ${identity}`;
}
return loaderName;
};
1 change: 1 addition & 0 deletions src/app/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export const CONNECTOR_URLS = {
DATA_LOADERS: '/api/data-loaders',
LIST: '/api/connectors',
CREATE: '/api/connectors',
UPDATE: (id: string) => `/api/connectors/${id}`,
DELETE: (id: string) => `/api/connectors/${id}`,
} as const;

Expand Down
1 change: 1 addition & 0 deletions src/components/ComponentType.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ export interface ConnectorAuthPath {
export interface ConnectorInstance {
id: string;
source_type: string;
type_name?: string;
display_name: string;
icon: string;
connected: boolean;
Expand Down
22 changes: 8 additions & 14 deletions src/components/ConnectorFormCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { apiRequest } from '../app/apiClient';
import { deriveConnectorDisplayName } from '../app/connectorNames';
import { CONNECTOR_URLS } from '../app/utils';
import { dfActions } from '../app/dfSlice';
import { AppDispatch } from '../app/store';
Expand Down Expand Up @@ -59,12 +60,12 @@ export const ConnectorFormCard: React.FC<ConnectorFormCardProps> = ({ messageId,
const [metaError, setMetaError] = useState<string>('');
const [loadingMeta, setLoadingMeta] = useState(true);
const [expanded, setExpanded] = useState(defaultExpanded);
const [connectionName, setConnectionName] = useState<string>(prompt.connectionName || '');
// Connected-state: collapsible details panel (non-sensitive only).
const [connExpanded, setConnExpanded] = useState(false);
const [connDetails, setConnDetails] = useState<Array<{ label: string; value: string }>>([]);

const createdIdRef = useRef<string | null>(prompt.connectorId ?? null);
const generatedNameRef = useRef(prompt.connectionName || '');
const seededRef = useRef(false);

// Fetch the connector's param/auth schema. The agent only sends the type;
Expand All @@ -85,9 +86,6 @@ export const ConnectorFormCard: React.FC<ConnectorFormCardProps> = ({ messageId,
}));
}
setMeta(found);
if (found && !connectionName) {
setConnectionName(found.name);
}
})
.catch(() => {
if (!cancelled) {
Expand Down Expand Up @@ -171,24 +169,26 @@ export const ConnectorFormCard: React.FC<ConnectorFormCardProps> = ({ messageId,
// create-on-connect: called by DataLoaderForm right before it connects.
const handleBeforeConnect = useCallback(async (params: Record<string, any>): Promise<string> => {
if (createdIdRef.current) return createdIdRef.current;
const displayName = deriveConnectorDisplayName(meta?.name || sourceType, params);
const { data } = await apiRequest<any>(CONNECTOR_URLS.CREATE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
loader_type: sourceType,
display_name: connectionName.trim() || meta?.name || sourceType,
display_name: displayName,
icon: sourceType,
params,
persist: true,
}),
});
createdIdRef.current = data.id;
generatedNameRef.current = displayName;
return data.id;
}, [sourceType, connectionName, meta]);
}, [sourceType, meta]);

const handleConnected = useCallback(async () => {
const cid = createdIdRef.current;
let resolvedName = connectionName.trim() || meta?.name || sourceType;
let resolvedName = generatedNameRef.current || meta?.name || sourceType;
if (cid) {
try {
const { data } = await apiRequest<any>(CONNECTOR_URLS.LIST, { method: 'GET' });
Expand Down Expand Up @@ -234,7 +234,7 @@ export const ConnectorFormCard: React.FC<ConnectorFormCardProps> = ({ messageId,
attachments: [],
hidden: true,
}));
}, [messageId, connectionName, meta, sourceType, dispatch, t]);
}, [messageId, meta, sourceType, dispatch, t]);

const cardSx = {
mt: 1,
Expand Down Expand Up @@ -349,12 +349,6 @@ export const ConnectorFormCard: React.FC<ConnectorFormCardProps> = ({ messageId,
authPaths={meta.auth_paths}
compact
hideInstructions
connectionName={{
label: t('upload.connectionNameLabel', { defaultValue: 'connection name' }),
value: connectionName,
placeholder: meta.name,
onChange: setConnectionName,
}}
onImport={() => {}}
onFinish={(status, message) => {
dispatch(dfActions.addMessages({
Expand Down
Loading
Loading