Skip to content
Merged

0.8a3 #399

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
8 changes: 8 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ SANDBOX=local # code execution backend: 'local' (default) or 'docke
# .env to preserve sessions. See DEVELOPMENT.md "Server Migration Checklist".
# FLASK_SECRET_KEY=

# Kusto delegated Microsoft sign-in (Authorization Code + PKCE).
# Register a public-client Entra application for your deployment. Client and
# tenant IDs are public identifiers, but they are deployment-specific and
# should not be copied from another organization. No client secret is needed.
# KUSTO_OAUTH_CLIENT_ID=<application-client-id>
# KUSTO_OAUTH_TENANT_ID=<tenant-id-or-organizations>
# KUSTO_OAUTH_REDIRECT_URI=http://localhost:5567/api/auth/kusto/callback

# Data directory — where workspaces and user data are stored on disk.
# Useful for server deployments with large datasets or dedicated storage volumes.
# Resolution order: --data-dir CLI flag > DATA_FORMULATOR_HOME env var > ~/.data_formulator
Expand Down
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,17 @@ https://github.com/user-attachments/assets/8e4f8a08-6423-4227-a1f7-559e0126ce31

## News 🔥🔥🔥

[07-17-2026] **Data Formulator 0.8 alpha 2** — A more connected, conversational way to work with data:
[07-23-2026] **Data Formulator 0.8 alpha** (a1–a3, latest: 0.8.0a3) includes:

- **Explore large datasets through conversation.** Connect a database, then ask the agent to find the right tables, filter the data, or update your selection as your question evolves. You can review the resulting filters, previews, and data sources before anything is loaded.
- **Keep the whole analysis in one conversation.** Agents can load data without losing track of what you asked. Questions, explanations, and results stay together in the Data Thread, so you can pick up from any step or branch into a new question, column, or chart.
- **Choose a chart that fits the question.** The gallery now includes bullet, connected scatter, ECDF, Gantt, range area, slope, sparkline, and violin charts, along with better recommendations. Files you attach also stay available to the analyst as the exploration continues.
- **Spend less time troubleshooting.** This release improves long-running sessions, model routing, data isolation, installation across platforms, dependency security, and MySQL data freshness. Persistent logs and an in-app log viewer make problems easier to track down.
- **Conversational database loading.** Agents can discover relevant tables, propose filters, preview results, and revise a loading plan through conversation before importing data.
- **Unified Data Thread.** Questions, clarifications, explanations, tables, and charts share one conversation history, with branching from earlier steps into new questions, calculated columns, or visualizations.
- **Expanded chart gallery, powered by Flint.** New bullet, connected scatter, ECDF, Gantt, range area, slope, sparkline, and violin charts, along with improved chart recommendations. Try the open-source [Flint chart language](https://microsoft.github.io/flint-chart/) in your own applications.
- **Persistent analyst attachments.** CSV, JSON, Excel, and other attached files remain available to the analyst throughout an exploration instead of being embedded once in a prompt.
- **Databricks connector.** Browse Unity Catalog catalogs, schemas, and tables, then load Databricks data into the exploration workflow.
- **Microsoft authentication for enterprise connectors.** SQL Server supports passwordless Microsoft Entra ID authentication through `az login`, including an in-app flow for local deployments. Kusto supports delegated Microsoft sign-in alongside Azure default identity and service principal authentication.
- **Connector setup and diagnostics.** Connection forms separate connection, scope, and source-specific authentication options. Persistent server logs and an in-app log viewer help diagnose failures.

> Preview with `pip install --pre data_formulator==0.8.0a2` or `uvx --from data_formulator==0.8.0a2 data_formulator`.
> Preview with `pip install --pre data_formulator==0.8.0a3` or `uvx data_formulator@0.8.0a3`.

> Install the latest stable release (0.7) with `pip install data_formulator` or run instantly with `uvx data_formulator`.

Expand Down
4 changes: 4 additions & 0 deletions py-src/data_formulator/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,10 @@ def _register_blueprints():
from data_formulator.auth.gateways.oidc_gateway import auth_tokens_bp
app.register_blueprint(auth_tokens_bp)

# Kusto delegated Microsoft sign-in is independent of application SSO.
from data_formulator.auth.gateways.kusto_oauth_gateway import kusto_oauth_bp
app.register_blueprint(kusto_oauth_bp)

# Register backend OIDC gateway (auto-detected: active when OIDC_CLIENT_SECRET is set)
from data_formulator.auth.providers.oidc import is_backend_oidc_mode
if is_backend_oidc_mode():
Expand Down
252 changes: 252 additions & 0 deletions py-src/data_formulator/auth/gateways/kusto_oauth_gateway.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

"""Microsoft delegated OAuth flow for Kusto connector access."""

from __future__ import annotations

import base64
import hashlib
import html
import json
import os
import re
import secrets
import time
import urllib.parse
from typing import Any

import requests as http
from flask import Blueprint, Response, request, session

from data_formulator.errors import AppError, ErrorCode


kusto_oauth_bp = Blueprint(
"kusto_oauth", __name__, url_prefix="/api/auth/kusto",
)

_STATE_KEY = "kusto_oauth_state"
_KUSTO_HOST_SUFFIXES = (
".kusto.windows.net",
".kusto.chinacloudapi.cn",
".kusto.usgovcloudapi.net",
)
_LOGIN_HOSTS = {
"login.microsoftonline.com",
"login.microsoftonline.us",
"login.chinacloudapi.cn",
"login.windows.net",
}


def _oauth_config(authority_host: str | None = None) -> dict[str, str]:
tenant_id = os.environ.get("KUSTO_OAUTH_TENANT_ID", "organizations")
if not re.fullmatch(r"[A-Za-z0-9.-]+", tenant_id):
raise AppError(ErrorCode.INVALID_REQUEST, "Invalid Kusto OAuth tenant")
authority_host = os.environ.get("KUSTO_OAUTH_AUTHORITY_HOST") or authority_host
authority_host = (authority_host or "https://login.microsoftonline.com").rstrip("/")
parsed_authority = urllib.parse.urlparse(authority_host)
if parsed_authority.scheme != "https" or parsed_authority.hostname not in _LOGIN_HOSTS:
raise AppError(ErrorCode.INVALID_REQUEST, "Invalid Kusto OAuth authority")
return {
"client_id": os.environ.get("KUSTO_OAUTH_CLIENT_ID", ""),
"client_secret": os.environ.get("KUSTO_OAUTH_CLIENT_SECRET", ""),
"authorize_url": f"{authority_host}/{tenant_id}/oauth2/v2.0/authorize",
"token_url": f"{authority_host}/{tenant_id}/oauth2/v2.0/token",
}


def _callback_url() -> str:
return os.environ.get(
"KUSTO_OAUTH_REDIRECT_URI",
request.url_root.rstrip("/") + "/api/auth/kusto/callback",
)


def _normalize_cluster(cluster: str) -> str:
parsed = urllib.parse.urlparse(cluster.strip())
hostname = (parsed.hostname or "").lower()
if (
parsed.scheme != "https"
or not hostname.endswith(_KUSTO_HOST_SUFFIXES)
or parsed.username
or parsed.password
or parsed.path not in ("", "/")
or parsed.query
or parsed.fragment
):
raise AppError(
ErrorCode.INVALID_REQUEST,
"Enter a valid HTTPS Azure Data Explorer cluster URL",
)
origin = f"https://{hostname}"
if parsed.port:
origin += f":{parsed.port}"
return origin


def _cluster_auth_metadata(cluster: str) -> tuple[str, str]:
"""Return the SDK-compatible resource scope and login endpoint."""
origin = _normalize_cluster(cluster)
try:
response = http.get(
f"{origin}/v1/rest/auth/metadata",
allow_redirects=False,
timeout=10,
)
response.raise_for_status()
azure_ad = response.json()["AzureAD"]
resource = str(azure_ad["KustoServiceResourceId"]).rstrip("/")
login_endpoint = str(azure_ad["LoginEndpoint"]).rstrip("/")
resource_url = urllib.parse.urlparse(resource)
login_url = urllib.parse.urlparse(login_endpoint)
if (
resource_url.scheme != "https"
or not (resource_url.hostname or "").endswith(_KUSTO_HOST_SUFFIXES)
or login_url.scheme != "https"
or login_url.hostname not in _LOGIN_HOSTS
):
raise ValueError("untrusted Kusto authentication metadata")
return f"{resource}/.default", login_endpoint
except Exception:
if origin.endswith(".kusto.windows.net"):
return "https://kusto.kusto.windows.net/.default", "https://login.microsoftonline.com"
raise AppError(
ErrorCode.SERVICE_UNAVAILABLE,
"Could not discover authentication settings for this Kusto cloud",
)


def _frontend_origin(value: str) -> str:
parsed = urllib.parse.urlparse(value)
origin = f"{parsed.scheme}://{parsed.netloc}"
request_origin = request.url_root.rstrip("/")
configured = {
item.strip().rstrip("/")
for item in os.environ.get("KUSTO_OAUTH_ALLOWED_ORIGINS", "").split(",")
if item.strip()
}
is_local = parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1"}
if not parsed.netloc or parsed.path or parsed.params or parsed.query or parsed.fragment:
raise AppError(ErrorCode.INVALID_REQUEST, "Invalid Data Formulator origin")
if origin != request_origin and origin not in configured and not is_local:
raise AppError(ErrorCode.INVALID_REQUEST, "Data Formulator origin is not allowed")
return origin


def _pkce_challenge(verifier: str) -> str:
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")


def _popup_response(origin: str, payload: dict[str, Any]) -> Response:
nonce = secrets.token_urlsafe(16)
payload_json = json.dumps(payload).replace("<", "\\u003c")
origin_json = json.dumps(origin).replace("<", "\\u003c")
body = f"""<!doctype html>
<html><head><meta charset="utf-8"><title>Microsoft sign-in</title></head>
<body><p>You can close this window.</p><script nonce="{html.escape(nonce)}">
if (window.opener) {{ window.opener.postMessage({payload_json}, {origin_json}); }}
window.close();
</script></body></html>"""
response = Response(body, content_type="text/html; charset=utf-8")
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
response.headers["Cache-Control"] = "no-store"
response.headers["Content-Security-Policy"] = f"default-src 'none'; script-src 'nonce-{nonce}'"
return response


@kusto_oauth_bp.route("/login")
def kusto_login():
"""Start an Authorization Code + PKCE flow for the selected cluster."""
scope, login_endpoint = _cluster_auth_metadata(
request.args.get("kusto_cluster", ""),
)
config = _oauth_config(login_endpoint)
if not config["client_id"]:
raise AppError(ErrorCode.SERVICE_UNAVAILABLE, "Kusto Microsoft sign-in is not configured")

origin = _frontend_origin(request.args.get("df_origin", ""))
state = secrets.token_urlsafe(32)
verifier = secrets.token_urlsafe(64)
states = {
key: value
for key, value in session.get(_STATE_KEY, {}).items()
if time.time() - value.get("created_at", 0) <= 600
}
states[state] = {
"verifier": verifier,
"origin": origin,
"scope": scope,
"token_url": config["token_url"],
"created_at": time.time(),
}
session[_STATE_KEY] = states

params = {
"client_id": config["client_id"],
"response_type": "code",
"redirect_uri": _callback_url(),
"response_mode": "query",
"scope": f"openid profile offline_access {scope}",
"state": state,
"code_challenge": _pkce_challenge(verifier),
"code_challenge_method": "S256",
"prompt": "select_account",
}
location = f"{config['authorize_url']}?{urllib.parse.urlencode(params)}"
response = Response(status=302)
response.headers["Location"] = location
return response


@kusto_oauth_bp.route("/callback")
def kusto_callback():
"""Exchange the Microsoft authorization code and notify the opener."""
state = request.args.get("state", "")
states = session.get(_STATE_KEY, {})
pending = states.pop(state, None)
session[_STATE_KEY] = states
if not pending or time.time() - pending.get("created_at", 0) > 600:
raise AppError(ErrorCode.INVALID_REQUEST, "Invalid or expired Kusto OAuth state")

origin = pending["origin"]
idp_error = request.args.get("error")
if idp_error:
return _popup_response(origin, {
"type": "df-sso-auth",
"error": "Microsoft sign-in failed",
})

code = request.args.get("code")
if not code:
return _popup_response(origin, {
"type": "df-sso-auth",
"error": "Microsoft did not return an authorization code",
})

config = _oauth_config()
token_data = {
"client_id": config["client_id"],
"grant_type": "authorization_code",
"code": code,
"redirect_uri": _callback_url(),
"scope": f"openid profile offline_access {pending['scope']}",
"code_verifier": pending["verifier"],
}
if config["client_secret"]:
token_data["client_secret"] = config["client_secret"]
token_response = http.post(pending["token_url"], data=token_data, timeout=15)
if not token_response.ok:
return _popup_response(origin, {
"type": "df-sso-auth",
"error": "Microsoft token exchange failed",
})

tokens = token_response.json()
return _popup_response(origin, {
"type": "df-sso-auth",
"access_token": tokens["access_token"],
"refresh_token": tokens.get("refresh_token"),
"expires_in": tokens.get("expires_in", 3600),
})
Loading
Loading