diff --git a/changelog.d/20251022_162752_derek_session_update_more_stuff.md b/changelog.d/20251022_162752_derek_session_update_more_stuff.md new file mode 100644 index 000000000..87165c5e1 --- /dev/null +++ b/changelog.d/20251022_162752_derek_session_update_more_stuff.md @@ -0,0 +1,5 @@ + +### Enhancements + +* Added the parameter `globus session update --scope` to specify additional scopes + to request when updating session tokens. diff --git a/src/globus_cli/commands/flows/start.py b/src/globus_cli/commands/flows/start.py index f36cc38c3..0beae7b2c 100644 --- a/src/globus_cli/commands/flows/start.py +++ b/src/globus_cli/commands/flows/start.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import os import string import typing as t @@ -7,6 +8,7 @@ import click import globus_sdk +from globus_sdk.gare import to_gare from globus_cli._click_compat import shim_get_metavar from globus_cli.commands.flows._fields import flow_run_format_fields @@ -21,6 +23,7 @@ ) from globus_cli.termio import display from globus_cli.types import JsonValue +from globus_cli.utils import CLIAuthRequirementsError if t.TYPE_CHECKING: from click.shell_completion import CompletionItem @@ -250,16 +253,46 @@ def start_command( flow_client = login_manager.get_specific_flow_client(flow_id) auth_client = login_manager.get_auth_client() - - response = flow_client.run_flow( - body=input_document_json, - label=label, - tags=list(tags), - run_managers=list(managers), - run_monitors=list(monitors), - activity_notification_policy=notify_policy, - ) + flow_scope = flow_client.scopes.user.scope_string + + with scope_injected_into_raised_gares(flow_scope): + response = flow_client.run_flow( + body=input_document_json, + label=label, + tags=list(tags), + run_managers=list(managers), + run_monitors=list(monitors), + activity_notification_policy=notify_policy, + ) fields = flow_run_format_fields(auth_client, response.data) display(response, fields=fields, text_mode=display.RECORD) + + +@contextlib.contextmanager +def scope_injected_into_raised_gares(scope: str) -> t.Iterator[None]: + """ + A context manager which catches GARE-convertible GlobusAPIErrors and reraises + as an exact copy of the original GARE but with the supplied scope injected. + + Due to SDK Error mutability limitations, these GAREs are raised as + CLIAuthRequirementsErrors. All other categories of errors raise as normal. + + :param scope: The scope to inject into any caught GAREs. + :raises CLIAuthRequirementsError: with modified GARE if a GARE-compatible + GlobusAPIError is caught. + """ + + try: + yield + except globus_sdk.GlobusAPIError as e: + gare = to_gare(e) + if gare: + scopes = gare.authorization_parameters.required_scopes or [] + if scope not in scopes: + scopes.append(scope) + + gare.authorization_parameters.required_scopes = scopes + raise CLIAuthRequirementsError("", gare=gare) + raise diff --git a/src/globus_cli/commands/session/update.py b/src/globus_cli/commands/session/update.py index 523a7e0d7..049e7efef 100644 --- a/src/globus_cli/commands/session/update.py +++ b/src/globus_cli/commands/session/update.py @@ -85,6 +85,13 @@ def _update_session_params_identities_case( @click.argument( "identities", type=IdentityType(allow_domains=True), nargs=-1, required=False ) +@click.option( + "--scope", + "scopes", + multiple=True, + type=str, + help="One or more additional scope strings to request during authentication", +) @click.option( "--policy", "policies", @@ -101,6 +108,7 @@ def session_update( login_manager: LoginManager, *, identities: tuple[ParsedIdentity, ...], + scopes: tuple[str, ...], no_local_server: bool, policies: list[str] | None, all: bool, @@ -156,5 +164,6 @@ def session_update( "\nYou have successfully updated your CLI session.\n" "Use 'globus session show' to see the updated session." ), + scopes=list(scopes), session_params=session_params, ) diff --git a/src/globus_cli/exception_handling/hooks/auth_requirements.py b/src/globus_cli/exception_handling/hooks/auth_requirements.py index 7067ed8b9..4f8442cca 100644 --- a/src/globus_cli/exception_handling/hooks/auth_requirements.py +++ b/src/globus_cli/exception_handling/hooks/auth_requirements.py @@ -40,7 +40,7 @@ def handle_internal_auth_requirements( ) def session_hook(exception: globus_sdk.GlobusAPIError) -> None: """ - Expects an exception with a valid authorization_paramaters info field. + Expects an exception with a valid authorization_parameters info field. """ message = exception.info.authorization_parameters.session_message if message: diff --git a/src/globus_cli/exception_handling/messages.py b/src/globus_cli/exception_handling/messages.py index c2a4c83d7..6e124c0c1 100644 --- a/src/globus_cli/exception_handling/messages.py +++ b/src/globus_cli/exception_handling/messages.py @@ -8,7 +8,7 @@ import os import click -import globus_sdk +import globus_sdk.gare from globus_cli.login_manager import is_client_login from globus_cli.types import JsonValue @@ -107,9 +107,11 @@ def emit_session_update_message( policies: list[str] | None, identities: list[str] | None, domains: list[str] | None, + scopes: list[str] | None = None, message: str = DEFAULT_SESSION_REAUTH_MESSAGE, ) -> None: click.echo(message) + scope_args = "".join(f" --scope '{s}'" for s in scopes or ()) if identities or domains: # check both values in this assignment to convince mypy of correctness @@ -119,18 +121,18 @@ def emit_session_update_message( update_target = " ".join(target_list) click.echo( "\nPlease run:\n\n" - f" globus session update {update_target}\n\n" + f" globus session update {update_target}{scope_args}\n\n" "to re-authenticate with the required identities." ) elif policies: click.echo( "\nPlease run:\n\n" - f" globus session update --policy '{','.join(policies)}'\n\n" + f" globus session update --policy '{','.join(policies)}'{scope_args}\n\n" "to re-authenticate with the required identities." ) else: click.echo( - '\nPlease use "globus session update" to re-authenticate ' + f'\nPlease use "globus session update {scope_args}" to re-authenticate ' "with specific identities." ) @@ -155,22 +157,25 @@ def emit_message_for_gare( gare: globus_sdk.gare.GARE, message: str | None = None ) -> None: required_scopes = gare.authorization_parameters.required_scopes - if required_scopes: - emit_consent_required_message( - required_scopes=required_scopes, - message=message or DEFAULT_CONSENT_REAUTH_MESSAGE, - ) - session_policies = gare.authorization_parameters.session_required_policies session_identities = gare.authorization_parameters.session_required_identities session_domains = gare.authorization_parameters.session_required_single_domain - if session_policies or session_identities or session_domains: + + requires_update = bool(session_policies or session_identities or session_domains) + + if requires_update: emit_session_update_message( policies=session_policies, identities=session_identities, domains=session_domains, + scopes=required_scopes, message=message or DEFAULT_SESSION_REAUTH_MESSAGE, ) + elif required_scopes: + emit_consent_required_message( + required_scopes=required_scopes, + message=message or DEFAULT_CONSENT_REAUTH_MESSAGE, + ) def pretty_json(data: JsonValue, compact: bool = False) -> str: diff --git a/tests/functional/flows/test_start_flow.py b/tests/functional/flows/test_start_flow.py index 1322e0e15..125cd91da 100644 --- a/tests/functional/flows/test_start_flow.py +++ b/tests/functional/flows/test_start_flow.py @@ -6,6 +6,7 @@ import pytest import responses +from globus_sdk.scopes import SpecificFlowScopes from globus_sdk.testing import RegisteredResponse, load_response @@ -84,7 +85,8 @@ def test_start_flow_prompts_session_reconsent_on_gare(run_line, add_flow_login): instruct the user to update their session based on the supplied GARE. """ flow_id = str(uuid.uuid4()) - required_policy_id = str(uuid.uuid4()) + flow_scope = SpecificFlowScopes(flow_id).user + policy_id = str(uuid.uuid4()) add_flow_login(flow_id) @@ -100,7 +102,7 @@ def test_start_flow_prompts_session_reconsent_on_gare(run_line, add_flow_login): "Globus Flows detected an unsatisfied session policy for this " "resource." ), - "session_required_policies": [required_policy_id], + "session_required_policies": [policy_id], }, "error": { "code": "AUTHENTICATION_POLICY_REQUIRED", @@ -116,7 +118,8 @@ def test_start_flow_prompts_session_reconsent_on_gare(run_line, add_flow_login): result = run_line(f"globus flows start {flow_id} --input {{}}", assert_exit_code=4) - assert f"globus session update --policy '{required_policy_id}'" in result.stdout + login_cmd = f"globus session update --policy '{policy_id}' --scope '{flow_scope}'" + assert login_cmd in result.stdout def test_start_flow_rejects_non_object_input(run_line, add_flow_login): diff --git a/tests/functional/flows/test_update_flow.py b/tests/functional/flows/test_update_flow.py index 949874af5..b66e50a4f 100644 --- a/tests/functional/flows/test_update_flow.py +++ b/tests/functional/flows/test_update_flow.py @@ -58,6 +58,8 @@ def test_update_flow_text_output(run_line, load_identities_for_flow): "Keywords", "Owner", "Subscription ID", + "High Assurance", + "Authentication Policy ID", "Created At", "Updated At", "Administrators", diff --git a/tests/functional/test_session_update.py b/tests/functional/test_session_update.py index 50ab5a18a..9a67a5b0d 100644 --- a/tests/functional/test_session_update.py +++ b/tests/functional/test_session_update.py @@ -55,11 +55,18 @@ def test_username_flow(run_line, userinfo_mocker, mock_remote_session, mock_link assert call_kwargs["session_params"]["session_required_identities"] == user_id -def test_domain_flow(run_line, userinfo_mocker, mock_remote_session, mock_link_flow): +@pytest.mark.parametrize("with_scopes", [True, False]) +def test_domain_flow( + run_line, userinfo_mocker, mock_remote_session, mock_link_flow, with_scopes +): mock_remote_session.return_value = True userinfo_mocker.configure_unlinked() - result = run_line("globus session update uchicago.edu") + scopes_args = "" + if with_scopes: + scopes_args = " --scope urn:globus:auth:scope:example.api:all --scope foo" + + result = run_line(f"globus session update uchicago.edu{scopes_args}") assert "You have successfully updated your CLI session." in result.output @@ -73,8 +80,9 @@ def test_domain_flow(run_line, userinfo_mocker, mock_remote_session, mock_link_f ) +@pytest.mark.parametrize("with_scopes", [True, False]) def test_all_flow( - run_line, userinfo_mocker, mock_remote_session, mock_local_server_flow + run_line, userinfo_mocker, mock_remote_session, mock_local_server_flow, with_scopes ): mock_remote_session.return_value = False meta = userinfo_mocker.configure( @@ -84,7 +92,11 @@ def test_all_flow( ids = [x["sub"] for x in meta["identity_set"]] - result = run_line("globus session update --all") + scopes_args = "" + if with_scopes: + scopes_args = " --scope urn:globus:auth:scope:example.api:all --scope foo" + + result = run_line(f"globus session update --all{scopes_args}") assert "You have successfully updated your CLI session." in result.output @@ -97,11 +109,18 @@ def test_all_flow( ) == set(ids) -def test_policy_flow(run_line, userinfo_mocker, mock_remote_session, mock_link_flow): +@pytest.mark.parametrize("with_scopes", [True, False]) +def test_policy_flow( + run_line, userinfo_mocker, mock_remote_session, mock_link_flow, with_scopes +): mock_remote_session.return_value = True userinfo_mocker.configure_unlinked() - result = run_line("globus session update --policy foo,bar") + scopes_args = "" + if with_scopes: + scopes_args = " --scope urn:globus:auth:scope:example.api:all --scope foo" + + result = run_line(f"globus session update --policy foo,bar{scopes_args}") assert "You have successfully updated your CLI session." in result.output