Skip to content
Merged
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
9 changes: 8 additions & 1 deletion dataall_cli/bind_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import json
import logging
from pathlib import Path
from typing import Any, Callable, Dict, Optional

import click
from dataall_core.dataall_client import DataallClient
from dataall_core.profile import get_profile

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -45,10 +47,15 @@ def _bind_function(

def func(**kwargs: Any) -> None:
logger.debug("I am the '{}' command".format(name))
profile = kwargs.get("profile", "default")
if get_profile(profile=profile, config_path=Path(config_path)) is None:
raise click.ClickException(
f"Profile '{profile}' is not configured; run: dataall_cli configure --profile {profile}"
)
da_client = DataallClient(
schema_path=schema_path, schema_version=schema_version
).client(
profile=kwargs.get("profile", "default"),
profile=profile,
config_path=config_path,
custom_headers=custom_headers,
)
Expand Down
176 changes: 158 additions & 18 deletions dataall_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
import logging
import os
from pathlib import Path
from typing import Any, Dict, List, Optional

import click
from dataall_core.dataall_client import DataallClient
from dataall_core.profile import CONFIG_PATH

from dataall_cli.bind_commands import bind
from dataall_cli.utils import save_config
from dataall_cli.utils import discover_from_frontend, frontend_origin, save_config

DA_CONFIG_PATH = os.getenv("dataall_config_path", CONFIG_PATH)
CREDS_PATH = os.getenv("dataall_creds_path", None)
Expand Down Expand Up @@ -50,72 +51,205 @@ def dataall_cli() -> None:
)


AUTH_TYPES = ["CognitoAuth", "CustomAuth", "OidcBrowserAuth"]
DEFAULT_REDIRECT_URI = "http://localhost:8765/callback"
DEFAULT_FALLBACK_REDIRECT_URI = "http://localhost:8766/callback"
DEFAULT_SCOPES = "openid offline_access"
DISCOVERED = "dataall_discovered"
DISCOVERED_FOR = "dataall_discovered_for"

DOMAIN_PROMPT = "Enter data.all's domain URL (e.g. https://<DOMAIN>.com)"
IDP_PROMPT = "Enter data.all Identity Provider Domain (e.g. https://<IdP-DOMAIN>.com)"
ISSUER_PROMPT = (
"Enter OIDC issuer URL (e.g. https://<ORG>.okta.com/oauth2/<AUTH-SERVER-ID>)"
)
SECRET_PROMPT = "Enter IdP client secret (if applicable)"
AUTH_SERVER_PROMPT = "Enter IdP custom auth server (if applicable)"
FRONT_PAGE_PROMPT = "Enter data.all front page URL (leave empty to configure manually)"


class AuthScopedOption(click.Option):
"""Option whose prompt depends on ``--auth_type``.

``scoped`` maps an auth type to ``{"prompt": text, "default": value}``. A spec
without ``prompt`` uses its default silently; auth types not listed get ``None``;
without ``scoped`` the option prompts normally. Values discovered from
``--dataall_url`` are used without prompting.
"""

def __init__(
self,
*args: Any,
scoped: Optional[Dict[str, Dict[str, Any]]] = None,
**kwargs: Any,
) -> None:
self.scoped = scoped
kwargs.setdefault("prompt", True)
super().__init__(*args, **kwargs)

def prompt_for_value(self, ctx: click.Context) -> Any:
"""Return the value for the active auth type, prompting only when needed."""
discovered = ctx.meta.get(DISCOVERED, {})
if self.name in discovered:
return discovered[self.name]
if self.scoped is None:
return super().prompt_for_value(ctx)
spec = self.scoped.get(str(ctx.params.get("auth_type")))
if spec is None:
return None
if "prompt" not in spec:
return spec.get("default")
return click.prompt(
spec["prompt"],
default=spec.get("default"),
type=self.type,
value_proc=lambda x: self.process_value(ctx, x),
)


def _discover(
ctx: click.Context, _param: click.Parameter, value: Optional[str]
) -> Optional[str]:
if not value or ctx.meta.get(DISCOVERED_FOR) == value:
return value
ctx.meta[DISCOVERED_FOR] = value
try:
found = discover_from_frontend(value)
except Exception as e:
click.echo(f"Could not read settings from {value}: {e}", err=True)
found = {"frontend_url": frontend_origin(value)}
chosen = ctx.params.get("auth_type")
detected = found.get("auth_type")
if chosen and detected and chosen != detected:
click.echo(
f"The front page uses {detected} but --auth_type {chosen} was given; "
"keeping only the page URL",
err=True,
)
found = {"frontend_url": found["frontend_url"]}
for key, item in found.items():
click.echo(f"Discovered {key}: {item}", err=True)
ctx.meta[DISCOVERED] = found
return value


def _for(auth_types: List[str], **spec: Any) -> Dict[str, Dict[str, Any]]:
return {auth_type: dict(spec) for auth_type in auth_types}


@dataall_cli.command()
@click.option(
"--dataall_url",
prompt=FRONT_PAGE_PROMPT,
default="",
show_default=False,
expose_value=False,
callback=_discover,
help="data.all front page URL; the auth type, IdP, client id and API endpoint are read from it",
)
@click.option(
"--auth_type",
type=click.Choice(["CognitoAuth", "CustomAuth"]),
cls=AuthScopedOption,
type=click.Choice(AUTH_TYPES),
default="CognitoAuth",
prompt="Select authentication type",
help="Authentication type: Cognito or Custom",
help="Authentication type: Cognito, Custom (username/password) or OIDC browser login",
)
@click.option(
"--client_id",
cls=AuthScopedOption,
required=True,
prompt="Enter data.all app client id",
scoped=_for(AUTH_TYPES, prompt="Enter data.all app client id"),
help="data.all app client id",
)
@click.option(
"--api_endpoint_url",
cls=AuthScopedOption,
required=True,
prompt="Enter data.all API endpoint url",
scoped=_for(AUTH_TYPES, prompt="Enter data.all API endpoint url"),
help="data.all API endpoint url",
)
@click.option(
"--redirect_uri",
cls=AuthScopedOption,
required=True,
prompt="Enter data.all's domain URL (e.g. https://<DOMAIN>.com)",
help="data.all domain URL",
scoped={
**_for(["CognitoAuth", "CustomAuth"], prompt=DOMAIN_PROMPT),
"OidcBrowserAuth": {"default": DEFAULT_REDIRECT_URI},
},
help="OAuth redirect URI: the data.all domain URL, or the loopback URI registered for the CLI",
)
@click.option(
"--idp_domain_url",
cls=AuthScopedOption,
required=True,
prompt="Enter data.all Identity Provider Domain (e.g. https://<IdP-DOMAIN>.com)",
help="data.all IdP domain URL",
scoped={
**_for(["CognitoAuth", "CustomAuth"], prompt=IDP_PROMPT),
"OidcBrowserAuth": {"prompt": ISSUER_PROMPT},
},
help="Identity provider domain, or the OIDC issuer URL for browser login",
)
@click.option(
"--client_secret",
cls=AuthScopedOption,
required=False,
prompt="Enter IdP client secret (if applicable)",
default="",
help="profile name for dataall_cli configured user",
scoped=_for(["CognitoAuth", "CustomAuth"], prompt=SECRET_PROMPT, default=""),
help="IdP client secret, if the app has one",
)
@click.option(
"--auth_server",
prompt="Enter IdP custom auth server (if applicable)",
default="default",
cls=AuthScopedOption,
required=False,
scoped=_for(
["CognitoAuth", "CustomAuth"], prompt=AUTH_SERVER_PROMPT, default="default"
),
help="identity provider's custom authorization server used to get well-known openid config",
)
@click.option(
"--scopes",
cls=AuthScopedOption,
required=False,
scoped={"OidcBrowserAuth": {"default": DEFAULT_SCOPES}},
help="OIDC scopes for browser login; use 'openid' if the IdP rejects offline_access",
)
@click.option(
"--frontend_url",
cls=AuthScopedOption,
required=False,
scoped={
"OidcBrowserAuth": {"prompt": "Enter data.all front page URL", "default": ""}
},
help="data.all UI URL sent as Origin header; some deployments only accept API calls carrying it",
)
@click.option(
"--fallback_redirect_uri",
default=DEFAULT_FALLBACK_REDIRECT_URI,
help="second loopback URI tried when the first port is busy (browser login)",
)
@click.option(
"--profile",
prompt="Enter data.all profile name",
default="default",
help="profile name for dataall_cli configured user",
)
def configure(
auth_type: str,
client_id: str,
api_endpoint_url: str,
auth_type: str,
redirect_uri: str,
idp_domain_url: str,
client_secret: str,
auth_server: str,
client_secret: Optional[str],
auth_server: Optional[str],
scopes: Optional[str],
frontend_url: Optional[str],
fallback_redirect_uri: str,
profile: str,
) -> None:
"""Configure data.all client for a given user, use profile to setup multiple user profiles."""
click.echo("Configuring data.all CLI...", err=True)

try:
profile_params_dict = {
profile_params_dict: Dict[str, Any] = {
"client_id": client_id,
"api_endpoint_url": api_endpoint_url,
"auth_type": auth_type,
Expand All @@ -131,6 +265,12 @@ def configure(
"session_token_endpoint": session_token_endpoint,
}
)
if auth_type == "OidcBrowserAuth":
profile_params_dict.update(
{"scopes": scopes, "fallback_redirect_uri": fallback_redirect_uri}
)
if frontend_url:
profile_params_dict["frontend_url"] = frontend_url.rstrip("/")
if CREDS_PATH:
profile_params_dict.update(
{
Expand Down
3 changes: 2 additions & 1 deletion dataall_cli/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""data.all cli utils."""

from .config import load_config, save_config
from .discovery import discover_from_frontend, frontend_origin

__all__ = ["load_config", "save_config"]
__all__ = ["discover_from_frontend", "frontend_origin", "load_config", "save_config"]
54 changes: 54 additions & 0 deletions dataall_cli/utils/discovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Discover CLI settings from a deployed data.all front page."""

import re
from typing import Dict
from urllib.parse import urljoin, urlparse

import httpx

BUNDLE_PATTERN = re.compile(r'src="([^"]*static/js/main\.[^"]+\.js)"')
VALUE_PATTERN = re.compile(r'(REACT_APP_[A-Z_]+):"([^"]*)"')
API_SUFFIX = "/graphql/api"


def frontend_origin(dataall_url: str) -> str:
"""Return ``scheme://host`` of a data.all URL."""
parsed = urlparse(dataall_url)
return f"{parsed.scheme}://{parsed.netloc}"


def discover_from_frontend(dataall_url: str) -> Dict[str, str]:
"""Read the auth type, identity provider, client id and API endpoint from the front page bundle.

Returns ``frontend_url`` plus whichever values were found.
"""
found = {"frontend_url": frontend_origin(dataall_url)}
base = dataall_url.rstrip("/") + "/"
index = httpx.get(base, follow_redirects=True, timeout=30)
index.raise_for_status()
match = BUNDLE_PATTERN.search(index.text)
if not match:
return found
bundle = httpx.get(urljoin(base, match.group(1)), follow_redirects=True, timeout=60)
bundle.raise_for_status()
values = dict(VALUE_PATTERN.findall(bundle.text))

if values.get("REACT_APP_CUSTOM_AUTH") and values.get("REACT_APP_CUSTOM_AUTH_URL"):
found["auth_type"] = "OidcBrowserAuth"
found["idp_domain_url"] = values["REACT_APP_CUSTOM_AUTH_URL"]
if values.get("REACT_APP_CUSTOM_AUTH_CLIENT_ID"):
found["client_id"] = values["REACT_APP_CUSTOM_AUTH_CLIENT_ID"]
elif values.get("REACT_APP_COGNITO_APP_CLIENT_ID"):
found["auth_type"] = "CognitoAuth"
found["client_id"] = values["REACT_APP_COGNITO_APP_CLIENT_ID"]
domain = values.get("REACT_APP_COGNITO_DOMAIN", "")
if domain:
found["idp_domain_url"] = domain if "://" in domain else f"https://{domain}"
if values.get("REACT_APP_COGNITO_REDIRECT_SIGNIN"):
found["redirect_uri"] = values["REACT_APP_COGNITO_REDIRECT_SIGNIN"]
api = values.get("REACT_APP_GRAPHQL_API", "")
if api:
found["api_endpoint_url"] = (
api[: -len(API_SUFFIX)] if api.endswith(API_SUFFIX) else api
)
return found
Loading
Loading