Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Globus app configurable environment #1001

Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

Added
~~~~~

- Added the configuration parameter ``GlobusAppConfig.environment``. (:pr:`NUMBER`)

25 changes: 18 additions & 7 deletions src/globus_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,22 @@ def __init__(
app_name: str | None = None,
transport_params: dict[str, t.Any] | None = None,
):
# if an environment was passed, it will be used, but otherwise lookup
# the env var -- and in the special case of `production` translate to
# `default`, regardless of the source of that value
# logs the environment when it isn't `default`
self.environment = config.get_environment_name(environment)
# Determine the client's environment.
if app is not None:
# If we're using a GlobusApp, the client's environment must either match the
# app's or be omitted.
if environment is not None and environment != app.config.environment:
raise exc.GlobusSDKUsageError(
f"[Environment Mismatch] {type(self).__name__}'s environment "
f"({environment}) does not match the GlobusApp's configured"
f"environment ({app.config.environment})."
)

self.environment = app.config.environment
else:
# Otherwise, figure out the environment from the provided kwarg or the
# GLOBUS_SDK_ENVIRONMENT environment variable.
self.environment = config.get_environment_name(environment)

if self.service_name == "_base":
# service_name=="_base" means that either there was no inheritance (direct
Expand All @@ -101,7 +112,7 @@ def __init__(
)

# append the base_path to the base URL
self.base_url = utils.slash_join(base_url, self.base_path)
self.base_url: str = utils.slash_join(base_url, self.base_path)

self.transport = self.transport_class(**(transport_params or {}))
log.debug(f"initialized transport of type {type(self.transport)}")
Expand All @@ -123,7 +134,7 @@ def __init__(

# set application name if available from app_name or app with app_name
# taking precedence if both are present
self._app_name = None
self._app_name: str | None = None
if app_name is not None:
self.app_name = app_name
elif app is not None:
Expand Down
41 changes: 32 additions & 9 deletions src/globus_sdk/experimental/globus_app/globus_app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import abc
import dataclasses
import os
import sys
from dataclasses import dataclass
Expand All @@ -12,6 +13,7 @@
NativeAppAuthClient,
Scope,
)
from globus_sdk import config as sdk_config
from globus_sdk._types import UUIDLike
from globus_sdk.authorizers import GlobusAuthorizer
from globus_sdk.exc import GlobusSDKUsageError
Expand Down Expand Up @@ -40,7 +42,7 @@
from typing import Protocol


def _default_filename(app_name: str) -> str:
def _default_filename(app_name: str, environment: str) -> str:
r"""
construct the filename for the default JSONTokenStorage to use

Expand All @@ -50,15 +52,20 @@ def _default_filename(app_name: str) -> str:
on Linux and macOS, we use
~/.globus/app/{app_name}/tokens.json
"""
environment_prefix = f"{environment}-"
if environment == "production":
environment_prefix = ""
filename = f"{environment_prefix}tokens.json"

if sys.platform == "win32":
# try to get the app data dir, preferring the local appdata
datadir = os.getenv("LOCALAPPDATA", os.getenv("APPDATA"))
if not datadir:
home = os.path.expanduser("~")
datadir = os.path.join(home, "AppData", "Local")
return os.path.join(datadir, "globus", "app", app_name, "tokens.json")
return os.path.join(datadir, "globus", "app", app_name, filename)
else:
return os.path.expanduser(f"~/.globus/app/{app_name}/tokens.json")
return os.path.expanduser(f"~/.globus/app/{app_name}/{filename}")


class TokenValidationErrorHandler(Protocol):
Expand Down Expand Up @@ -99,6 +106,8 @@ class GlobusAppConfig:
:param token_validation_error_handler: A callable that will be called when a
token validation error is encountered. The default behavior is to retry the
login flow automatically.
:param environment: The Globus environment being targeted by this app. This is
predominately for internal use and can be ignored in most cases.
"""

login_flow_manager: LoginFlowManager | type[LoginFlowManager] | None = None
Expand All @@ -107,6 +116,9 @@ class GlobusAppConfig:
token_validation_error_handler: TokenValidationErrorHandler | None = (
resolve_by_login_flow
)
environment: str = dataclasses.field(
default_factory=sdk_config.get_environment_name
)


_DEFAULT_CONFIG = GlobusAppConfig()
Expand Down Expand Up @@ -160,10 +172,18 @@ def __init__(
self.app_name = app_name
self.config = config

if login_client and (client_id or client_secret):
raise GlobusSDKUsageError(
"login_client is mutually exclusive with client_id and client_secret."
)
if login_client:
if client_id or client_secret:
raise GlobusSDKUsageError(
"login_client is mutually exclusive with client_id and "
"client_secret."
)
if login_client.environment != self.config.environment:
raise GlobusSDKUsageError(
f"[Environment Mismatch] The login_client's environment "
f"({login_client.environment}) does not match the GlobusApp's "
f"configured environment ({self.config.environment})."
)

self.client_id: UUIDLike | None
if login_client:
Expand All @@ -180,7 +200,7 @@ def __init__(
self._token_storage = self.config.token_storage
else:
self._token_storage = JSONTokenStorage(
filename=_default_filename(self.app_name)
filename=_default_filename(self.app_name, self.config.environment)
)

# construct ValidatingTokenStorage around the TokenStorage and
Expand Down Expand Up @@ -264,7 +284,7 @@ def get_authorizer(self, resource_server: str) -> GlobusAuthorizer:
if self.config.token_validation_error_handler:
# Dispatch to the configured error handler if one is set then retry.
self.config.token_validation_error_handler(self, e)
return self.get_authorizer(resource_server)
return self._authorizer_factory.get_authorizer(resource_server)
raise e

def add_scope_requirements(
Expand Down Expand Up @@ -378,11 +398,13 @@ def _initialize_login_client(self, client_secret: str | None) -> None:
app_name=self.app_name,
client_id=self.client_id,
client_secret=client_secret,
environment=self.config.environment,
)
else:
self._login_client = NativeAppAuthClient(
app_name=self.app_name,
client_id=self.client_id,
environment=self.config.environment,
)

def _initialize_authorizer_factory(self) -> None:
Expand Down Expand Up @@ -479,6 +501,7 @@ def _initialize_login_client(self, client_secret: str | None) -> None:
client_id=self.client_id,
client_secret=client_secret,
app_name=self.app_name,
environment=self.config.environment,
)

def _initialize_authorizer_factory(self) -> None:
Expand Down
20 changes: 15 additions & 5 deletions tests/unit/experimental/globus_app/test_globus_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def test_user_app_native():


def test_user_app_login_client():
mock_client = mock.Mock()
mock_client = mock.Mock(environment="production")
user_app = UserApp("test-app", login_client=mock_client)

assert user_app.app_name == "test-app"
Expand All @@ -91,10 +91,20 @@ def test_user_app_both_client_and_id():

with pytest.raises(GlobusSDKUsageError) as exc:
UserApp("test-app", login_client=mock_client, client_id=client_id)
assert (
str(exc.value)
== "login_client is mutually exclusive with client_id and client_secret."
)

expected = "login_client is mutually exclusive with client_id and client_secret."
assert str(exc.value) == expected


def test_user_app_login_client_environment_mismatch():
mock_client = mock.Mock(environment="sandbox")

with pytest.raises(GlobusSDKUsageError) as exc:
config = GlobusAppConfig(environment="preview")
UserApp("test-app", login_client=mock_client, config=config)

expected = "[Environment Mismatch] The login_client's environment (sandbox) does not match the GlobusApp's configured environment (preview)." # noqa
assert str(exc.value) == expected


def test_user_app_default_token_storage():
Expand Down
70 changes: 46 additions & 24 deletions tests/unit/experimental/test_client_integration.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,47 @@
import pytest

import globus_sdk
from globus_sdk import GlobusSDKUsageError
from globus_sdk._testing import load_response
from globus_sdk.experimental.globus_app import UserApp
from globus_sdk.experimental.globus_app import GlobusApp, GlobusAppConfig, UserApp
from globus_sdk.experimental.tokenstorage import MemoryTokenStorage


@pytest.fixture
def app() -> GlobusApp:
config = GlobusAppConfig(token_storage=MemoryTokenStorage())
return UserApp("test-app", client_id="client_id", config=config)


def test_client_inherits_environment_from_globus_app():
config = GlobusAppConfig(token_storage=MemoryTokenStorage(), environment="sandbox")
app = UserApp("test-app", client_id="client_id", config=config)

client = globus_sdk.AuthClient(app=app)

assert client.environment == "sandbox"


def test_client_environment_does_not_match_the_globus_app_environment():
config = GlobusAppConfig(token_storage=MemoryTokenStorage(), environment="sandbox")
app = UserApp("test-app", client_id="client_id", config=config)

with pytest.raises(GlobusSDKUsageError) as exc:
globus_sdk.AuthClient(app=app, environment="preview")

expected = "[Environment Mismatch] AuthClient's environment (preview) does not match the GlobusApp's configuredenvironment (sandbox)." # noqa
assert str(exc.value) == expected


def test_transfer_client_default_scopes():
app = UserApp("test-app", client_id="client_id")
def test_transfer_client_default_scopes(app):
globus_sdk.TransferClient(app=app)

assert [str(s) for s in app.get_scope_requirements("transfer.api.globus.org")] == [
"urn:globus:auth:scope:transfer.api.globus.org:all"
]


def test_transfer_client_add_app_data_access_scope():
app = UserApp("test-app", client_id="client_id")
def test_transfer_client_add_app_data_access_scope(app):
client = globus_sdk.TransferClient(app=app)

client.add_app_data_access_scope("collection_id")
Expand All @@ -22,11 +50,12 @@ def test_transfer_client_add_app_data_access_scope():
assert expected in str_list


def test_transfer_client_add_app_data_access_scope_chaining():
app = UserApp("test-app", client_id="client_id")
globus_sdk.TransferClient(app=app).add_app_data_access_scope(
"collection_id_1"
).add_app_data_access_scope("collection_id_2")
def test_transfer_client_add_app_data_access_scope_chaining(app):
(
globus_sdk.TransferClient(app=app)
.add_app_data_access_scope("collection_id_1")
.add_app_data_access_scope("collection_id_2")
)

str_list = [str(s) for s in app.get_scope_requirements("transfer.api.globus.org")]
expected_1 = "urn:globus:auth:scope:transfer.api.globus.org:all[*https://auth.globus.org/scopes/collection_id_1/data_access]" # noqa
Expand All @@ -35,8 +64,7 @@ def test_transfer_client_add_app_data_access_scope_chaining():
assert expected_2 in str_list


def test_auth_client_default_scopes():
app = UserApp("test-app", client_id="client_id")
def test_auth_client_default_scopes(app):
globus_sdk.AuthClient(app=app)

str_list = [str(s) for s in app.get_scope_requirements("auth.globus.org")]
Expand All @@ -45,35 +73,31 @@ def test_auth_client_default_scopes():
assert "email" in str_list


def test_groups_client_default_scopes():
app = UserApp("test-app", client_id="client_id")
def test_groups_client_default_scopes(app):
globus_sdk.GroupsClient(app=app)

assert [str(s) for s in app.get_scope_requirements("groups.api.globus.org")] == [
"urn:globus:auth:scope:groups.api.globus.org:view_my_groups_and_memberships"
]


def test_search_client_default_scopes():
app = UserApp("test-app", client_id="client_id")
def test_search_client_default_scopes(app):
globus_sdk.SearchClient(app=app)

assert [str(s) for s in app.get_scope_requirements("search.api.globus.org")] == [
"urn:globus:auth:scope:search.api.globus.org:search"
]


def test_timer_client_default_scopes():
app = UserApp("test-app", client_id="client_id")
def test_timer_client_default_scopes(app):
globus_sdk.TimerClient(app=app)

timer_client_id = "524230d7-ea86-4a52-8312-86065a9e0417"
str_list = [str(s) for s in app.get_scope_requirements(timer_client_id)]
assert str_list == [f"https://auth.globus.org/scopes/{timer_client_id}/timer"]


def test_flows_client_default_scopes():
app = UserApp("test-app", client_id="client_id")
def test_flows_client_default_scopes(app):
globus_sdk.FlowsClient(app=app)

flows_client_id = "eec9b274-0c81-4334-bdc2-54e90e689b9a"
Expand All @@ -83,21 +107,19 @@ def test_flows_client_default_scopes():
assert f"https://auth.globus.org/scopes/{flows_client_id}/run_status" in str_list


def test_specific_flow_client_default_scopes():
app = UserApp("test-app", client_id="client_id")
def test_specific_flow_client_default_scopes(app):
globus_sdk.SpecificFlowClient("flow_id", app=app)

assert [str(s) for s in app.get_scope_requirements("flow_id")] == [
"https://auth.globus.org/scopes/flow_id/flow_flow_id_user"
]


def test_gcs_client_default_scopes():
def test_gcs_client_default_scopes(app):
meta = load_response(globus_sdk.GCSClient.get_gcs_info).metadata
endpoint_client_id = meta["endpoint_client_id"]
domain_name = meta["domain_name"]

app = UserApp("test-app", client_id="client_id")
globus_sdk.GCSClient(domain_name, app=app)

assert [str(s) for s in app.get_scope_requirements(endpoint_client_id)] == [
Expand Down