← Files Dataiku HeadlessARCHIVED FILE

dataiku_mcp/auth.py

7.2 KB · Nov 11, 2026 · 12:58 UTC

↓ Download file

# Copyright 2026 Dataiku SAS
#
# 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.

"""Authentication and Dataiku client creation."""

import logging
from urllib.parse import urlparse

import dataikuapi
import requests
from dataikuapi.utils import DataikuException

from .config import http, request
from .executors import run_blocking

logger = logging.getLogger("dataiku-mcp")


def get_dss_client() -> dataikuapi.DSSClient:
    """Get a Dataiku API client for the currently active instance."""
    current_instance = request.get_pinned_instance()

    if request.is_http_request():
        client = dataikuapi.DSSClient(
            current_instance.url,
            jwt_bearer_token=request.get_http_dss_token(),
            extra_headers={"X-DKU-Client-Application": "dataiku-headless"},
        )
    elif current_instance.api_ticket is not None:
        client = dataikuapi.DSSClient(
            current_instance.url,
            internal_ticket=current_instance.api_ticket,
            extra_headers={"X-DKU-Client-Application": "dataiku-headless"},
        )
    else:
        client = dataikuapi.DSSClient(
            current_instance.url,
            api_key=current_instance.api_key,
            extra_headers={"X-DKU-Client-Application": "dataiku-headless"},
        )
    client._session.verify = (
        False
        if current_instance.no_check_certificate
        else current_instance.encrypted_rpc_cert_path or True
    )
    return client


def get_dataiku_version(client: dataikuapi.DSSClient) -> str:
    """Return the instance's Dataiku version, or an empty string if unavailable."""
    try:
        return client.get_instance_info().raw.get("dssVersion") or ""
    except Exception:
        # /instance-info is permission-gated. Version reporting should not make
        # get_current_instance fail for credentials that cannot read it.
        return ""


async def exchange_http_token(subject_token: str) -> str:
    """Exchange an MCP-audience token for the selected DSS-audience token."""

    def _exchange() -> str:
        auth_settings = http.get_auth_settings()
        instance = request.get_pinned_instance()
        if auth_settings.provider == "entra":
            token_endpoint = auth_settings.token_endpoint
            client_secret = auth_settings.client_secret
            data = {
                "client_id": auth_settings.client_id,
                "client_secret": client_secret,
                "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
                "assertion": subject_token,
                "scope": instance.delegated_scope,
                "requested_token_use": "on_behalf_of",
            }
            client_auth = None
        else:
            delegation = auth_settings.delegation
            token_endpoint = delegation.token_endpoint
            client_secret = delegation.client_secret
            data = {
                "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
                "subject_token": subject_token,
                "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
                "audience": instance.delegated_audience,
                "scope": instance.delegated_scope,
            }
            client_auth = (delegation.client_id, client_secret)
        try:
            response = requests.post(
                token_endpoint,
                data=data,
                auth=client_auth,
                timeout=10,
            )
            response.raise_for_status()
        except requests.RequestException as err:
            response = err.response
            details = {}
            if response is not None:
                try:
                    payload = response.json()
                except ValueError:
                    payload = {}
                if isinstance(payload, dict):
                    details = payload

            description = str(details.get("error_description", ""))
            for secret in (subject_token, client_secret):
                if secret:
                    description = description.replace(secret, "<redacted>")
            description = " ".join(description.split())[:500]
            logger.warning(
                "DSS token exchange failed: provider=%s instance=%s endpoint=%s "
                "status=%s exception=%s error=%s description=%s "
                "correlation_id=%s trace_id=%s",
                auth_settings.provider,
                instance.name,
                urlparse(token_endpoint).netloc,
                response.status_code if response is not None else None,
                type(err).__name__,
                details.get("error"),
                description or None,
                details.get("correlation_id"),
                details.get("trace_id"),
            )
            raise PermissionError("DSS token exchange failed.") from err

        try:
            payload = response.json()
        except ValueError as err:
            logger.warning(
                "DSS token exchange returned invalid JSON: provider=%s "
                "instance=%s status=%s content_type=%s",
                auth_settings.provider,
                instance.name,
                response.status_code,
                response.headers.get("content-type"),
            )
            raise PermissionError(
                "DSS token exchange returned an invalid response."
            ) from err
        if not isinstance(payload, dict):
            logger.warning(
                "DSS token exchange returned a non-object response: "
                "provider=%s instance=%s status=%s",
                auth_settings.provider,
                instance.name,
                response.status_code,
            )
            raise PermissionError("DSS token exchange returned an invalid response.")

        delegated_token = payload.get("access_token")
        if not isinstance(delegated_token, str) or not delegated_token:
            logger.warning(
                "DSS token exchange response contained no access token: "
                "provider=%s instance=%s status=%s error=%s",
                auth_settings.provider,
                instance.name,
                response.status_code,
                payload.get("error"),
            )
            raise PermissionError("DSS token exchange returned no access token.")
        return delegated_token

    return await run_blocking(_exchange)


async def require_admin() -> None:
    """Raise a concise error unless the configured credentials are an admin."""

    def _run():
        try:
            get_dss_client().get_general_settings()
        except DataikuException as err:
            raise PermissionError(
                "Dataiku administrator access could not be verified for this operation: "
                f"{err}"
            ) from None

    await run_blocking(_run)

SHA-256: fb58c4d71b26c6b75b56c46a208d2fa98c67d67bf1df4e543bdc1568946d3c7c