diff --git a/index.qmd b/index.qmd index ac083d8..21c7d7e 100644 --- a/index.qmd +++ b/index.qmd @@ -1,7 +1,5 @@ --- title: "🤠 The Sheriff" -output: gfm -output-file: "README.md" --- ## 🌵 On the Frontier diff --git a/notebooks/images/groups_client-get-my_g.png b/notebooks/images/groups_client-get-my_g.png new file mode 100644 index 0000000..8652c4b Binary files /dev/null and b/notebooks/images/groups_client-get-my_g.png differ diff --git a/notebooks/sheriff.qmd b/notebooks/sheriff.qmd index 5ffbf08..88a3190 100644 --- a/notebooks/sheriff.qmd +++ b/notebooks/sheriff.qmd @@ -36,25 +36,30 @@ all that's needed. import os from rich.console import Console from pathlib import Path - -console = Console() +from sheriff.validate_globus import issue_globus_transfer, GlobusClearance class Sheriff: """ A simple class that checks for frontier citizenship by looking for a frontier.yml file in the environment variable or the current working directory. """ - def __init__(self, frontier_dirname="frontier", frontier_file="frontier.yml"): + def __init__( + self, + console: Console, + frontier_dirname: str = "frontier", + frontier_file: str = "frontier.yml", + ): """ Initializes the Sheriff Class. Args: frontier_dirname (str): The name of the directory that contains the frontier.yml file. Default is "frontier". frontier_file (str): The name of the frontier file. Default is "frontier.yml". """ + self.console = console self.frontier_dirname = frontier_dirname self.frontier_file = frontier_file self.frontier_path = None - console.print("🤠 There's a new sheriff in town! Let's take a look at your papers...") + self.console.print("🤠 The sheriff's in town! Let's take a look at your papers...") def check_citizen(self): """ @@ -67,8 +72,8 @@ A simple class that checks for frontier citizenship by looking for a frontier.ym if env_path and os.path.isfile(env_path): self.frontier_path = env_path - console.print(f"🪪 Found frontier file at: {self.frontier_path}") - console.print("🏞️ Welcome to the frontier, citizen!") + self.console.print(f"🪪 Found frontier file at: {self.frontier_path}") + self.console.print("🏞️ Welcome to the frontier, citizen!") return True # Check current working directory @@ -81,12 +86,34 @@ A simple class that checks for frontier citizenship by looking for a frontier.ym if os.path.isfile(frontier_file_path): self.frontier_path = frontier_file_path - console.print(f"🪪 Found frontier file at: {self.frontier_path}") - console.print("🏞️ Welcome to the frontier, citizen!") + self.console.print(f"🪪 Found frontier file at: {self.frontier_path}") + self.console.print("🏞️ Welcome to the frontier, citizen!") return True - console.print("❌ No frontier file found. Please set the THE_FRONTIER environment variable or navigate to a directory within the frontier to validate your citizenship.") + self.console.print("❌ No frontier file found. Please set the THE_FRONTIER environment variable or navigate to a directory within the frontier to validate your citizenship.") return False + + def check_globus_access(self, globus_info: dict) -> GlobusClearance: + """ + Checks for globus access by validating the globus credentials provided in the + manifest. Runs transfer if credentials are valid and source and destination + endpoints are provided. Returns a GlobusClearance dataclass indicating the + result of the validation and transfer operation. + Args: + globus_info (dict): A dictionary containing the globus credentials and information. + Returns: + GlobusClearance: A dataclass indicating the validation result and associated objects. + """ + self.console.print("🔍 Checking your Globus access...") + + clearance = issue_globus_transfer(globus_info, self.console, issue_transfer=False) + + if clearance.cleared: + self.console.print("✅ Globus access validated. You're good to go!") + else: + self.console.print("❌ Globus access validation failed. Please check your credentials and try again.") + + return clearance ``` With this simple class definition, we can now @@ -107,7 +134,8 @@ def inspect(): """ Hey there, partner! The sheriff is here to check your papers and make sure you're a citizen of The Frontier. Let's see if you have what it takes to be a part of this wild and wonderful land. """ - sheriff = Sheriff() + console = Console() + sheriff = Sheriff(console=console) sheriff.check_citizen() ``` diff --git a/notebooks/validate_globus.qmd b/notebooks/validate_globus.qmd new file mode 100644 index 0000000..fd54b1f --- /dev/null +++ b/notebooks/validate_globus.qmd @@ -0,0 +1,308 @@ +--- +title: "Validate Globus Credentials" +subtitle: "How to Validate Globus Credentials Passed from `Stagecoach`" + +filters: + - sorting-hat + - ripper +extensions: + sorting-hat: + keep: + - python + ripper: + include-yaml: false + output-name: "validate_globus" + output-dir: "../src/sheriff" +format: gfm +--- + +We want to have a secure way of knowing whether a user +has the correct permissions to have data delivered to them +via globus. This kind of validation falls under the +"authorization" category of security, and as such +should be handled by the `Sheriff` class. As a reminder, +the `Stagecoach` class is a handler for delivering +data to townspeople, and works by issuing users a manifest. +This manifest is a JSON file that users fill in with their +credentials and desired data from the Frontier Gold Mine. +Once the user returns the manifest, and the `Stagecoach` +recognizes that the user wants access to data via `globus`, +the `Stagecoach` passes the Globus credentials along to +the `Sheriff` for validation. The `Sheriff` then needs to +validate the credentials to ensure that the user has the +correct permissions to access the data. This validation process +uses the `Globus SDK` to check the credentials against the +Globus service. If the credentials are valid, the `Sheriff` +returns a success message, allowing the `Stagecoach` to proceed +with the data delivery. If the credentials are invalid, the +`Sheriff` returns an error message, and the `Stagecoach` can +inform the user of the issue and prompt them to correct their +credentials in the manifest. This process ensures that only +authorized users can access the data, maintaining the security +of The Frontier's Gold Mine resources. + +## Tinkering with the SDK + +Globus SDK in Python works as below. Once you've set +up an App in the [Developer panel](https://globus-sdk-python.readthedocs.io/en/stable/user_guide/getting_started/register_app.html) of the Globus website, +you can use the (non-secure) client UUID to create a user app: + +```{python} +#| sorting-hat: remove +import globus_sdk + +CLIENT_ID = "7723dff4-fa63-4639-903b-ba6541e24e98" + +my_app = globus_sdk.UserApp("sheriff-prototype", client_id=CLIENT_ID) +``` + +The first method shows us that login is required to use this app: + +```{python} +#| sorting-hat: remove +my_app.login_required() +``` + +Next, we can use this app to define a client group: + +```{python} +#| sorting-hat: remove +groups_client = globus_sdk.GroupsClient(app=my_app) +``` + +What's great is that this method forces the current user +to log in to their globus account: + +![Globus Login](images/groups_client-get-my_g.png) + +And from there, I can see the groups I'm allowed to access defined +in the Globus dashboard: + + +```{python} +#| sorting-hat: remove +groups_client.get_my_groups() +``` + +The explicit login can be called deliberately: + +```{python} +#| sorting-hat: remove +# my_app.login() +``` + +We can see that the UserApp object is the main functionality +we're looking for in the `Sheriff` class, as it deals +solely with the authentication and authorization of users: + +> GlobusApp provides a number of useful abstractions in the SDK. It handles login flows and storage of tokens, coupled with later retrieval of those tokens for use. It can keep track of which clients have been created and registered with an app, and therefore make intelligent decisions about how and when to prompt users to login. + +So, to make sure someone is logged in, we can simply create +a function that triggers this login flow. Fortunately, +Globus has provided an example we can work with. Let's +take an example of trying to get this file: + +`/n/holylabs/cgolden_lab/Lab/projects/sandbox/ReaProject/MadagascarWeatherStations/_pkgdown.yml` + +Let's list my available data: + +```{python} +#| sorting-hat: remove +holyoke_collection = "1156ed9e-6984-11ea-af52-0201714f6eab" +source_fpath = "/n/holylabs/cgolden_lab/Lab/projects/sandbox/ReaProject/MadagascarWeatherStations/_pkgdown.yml" + +dest_fpath = "/n/holylabs/cgolden_lab/Lab/projects/test_tinashe/_pkgdown.yml" +tc = globus_sdk.TransferClient(app=my_app) +``` + +The `.get_endpoint()` method is used to get the +endpoint information for a given collection. An important +piece of information we'll need is the data_access scope of +the endpoint, which is a little bit of a complicated discussion; +the tldr I believe is that the transfer client can +be modified to have the correct access scope but only if you +check for it first; hence, the `.get_endpoint()` step. There +are two cases under which the endpoint will NOT have data_access: + +1. If the endpoint `entity_type` IS NOT `GCSv5_mapped_collection`, and +2. If the endpoint IS `high_assurance`. + +If either of the above is true, then the endpoint will need special +access and additional steps. If neither of the above is true, then the endpoint +should be accessible with the default client + +```{python} +#| sorting-hat: remove +doc = tc.get_endpoint(holyoke_collection) +doc["entity_type"] != "GCSv5_mapped_collection" +``` + +```{python} +#| sorting-hat: remove +doc["high_assurance"] == True +``` + +So neither of these are true, which means that the endpoint should be accessible +with the default client. If it weren't the case, we would have +to modify the client with `tc.add_app_data_access_scope(SRC_COLLECTION)`. + +```{python} +#| sorting-hat: remove +tc.add_app_data_access_scope(holyoke_collection) +``` + +```{python} +#| sorting-hat: remove +transfer_request = globus_sdk.TransferData(holyoke_collection, holyoke_collection) +transfer_request.add_item(source_fpath, dest_fpath) +``` + +To be sure that this transfer is valid, we can first +do a dir_stat on the collection. If we get a successful +response, we can assume that the transfer will work in future: + +```{python} +#| sorting-hat: remove +resp = tc.operation_stat(holyoke_collection, "~") +assert resp.http_status == 200 +``` + +Finally, we can wrap the transfer submission in a +try-except block to catch any errors that may arise +from invalid credentials or permissions: + +```{python} +#| sorting-hat: remove +try: + task = tc.submit_transfer(transfer_request) + print(f"Submitted transfer. Task ID: {task['task_id']}.") +except globus_sdk.GlobusAPIError as e: + print(f"Transfer submission failed: {e}") +``` + +That works! So now we can create a validation +function that allows the sheriff to validate +the globus section of a stagecoach manifest. + +The manifest will have the following shape: + +```yaml +globus: + use_globus: true + globus_source_endpoint: fancy-uuid + globus_source_path: /path/on/source/endpoint + globus_destination_endpoint: fancy-uuid + globus_destination_path: /path/on/destination/endpoint +``` + +We'll need to validate that the user has the kind of access +necessary. The return will be a dataclass that the stagecoach +can accept the requisite transfer object from and execute. + +```{python} +#| sorting-hat: keep +from rich.console import Console +from globus_sdk import GlobusAppConfig, UserApp, TransferClient, GlobusAPIError, TransferData +from dataclasses import dataclass + +@dataclass +class GlobusClearance: + transfer_id : dict | None + source_collection: str | None + destination_collection: str | None + cleared: bool + +def issue_globus_transfer( + globus_info: dict, + console: Console, + stagecoach_app_id: str = "7723dff4-fa63-4639-903b-ba6541e24e98", + issue_transfer: bool = False + ) -> GlobusClearance: + """ + Validates the globus credentials provided in the manifest. + Args: + globus_info (dict): A dictionary containing the globus credentials and information. + console (Console): Rich console object for output messages. + stagecoach_app_id (str): The Globus application client ID for authentication. + issue_transfer (bool): Whether to actually issue a transfer after validation. Defaults to False. + Returns: + GlobusClearance: A dataclass indicating the validation result and associated objects. + """ + try: + + if not globus_info.get("use_globus", False): + console.print("Globus access not requested.") + return GlobusClearance( + transfer_id = None, + source_collection = None, + destination_collection = None, + cleared = True + ) + + app_name = "Frontier-Sheriff_" + globus_info.get("globus_username", "Globus-Validator") + + with UserApp( + app_name, + client_id=stagecoach_app_id, + config=GlobusAppConfig(auto_redrive_gares=True), + ) as app: + with TransferClient(app=app) as client: + src_collection = globus_info.get("globus_source_endpoint") + dst_collection = globus_info.get("globus_destination_endpoint") + src_path = globus_info.get("globus_source_path") + dst_path = globus_info.get("globus_destination_path") + + client.add_app_data_access_scope(src_collection) + client.add_app_data_access_scope(dst_collection) + + transfer_request = TransferData(src_collection, dst_collection) + transfer_request.add_item(src_path, dst_path) + + # Attempt to stat the source collection to validate access + resp = client.operation_stat(src_collection, src_path) + if resp.http_status == 200: + console.print("✅ Globus credentials validated successfully.") + else: + console.print(f"❌ Failed to validate globus credentials. Operation stat returned status: {resp.http_status}") + raise GlobusAPIError(resp) + + if issue_transfer: + task = client.submit_transfer(transfer_request) + console.print(f"Submitted transfer. Task ID: {task['task_id']}.") + return GlobusClearance( + transfer_id = task, + source_collection = src_collection, + destination_collection = dst_collection, + cleared = True + ) + else: + return GlobusClearance( + transfer_id = None, + source_collection = src_collection, + destination_collection = dst_collection, + cleared = True + ) + + except GlobusAPIError as e: + console.print_exception() + return GlobusClearance( + transfer_id = None, + source_collection = globus_info.get("globus_source_endpoint", None), + destination_collection = globus_info.get("globus_destination_endpoint", None), + cleared = False + ) # Return False if there's an API error, along with a clearance object indicating failure + except Exception as e: + console.print_exception() + return GlobusClearance( + transfer_id = None, + source_collection = globus_info.get("globus_source_endpoint", None), + destination_collection = globus_info.get("globus_destination_endpoint", None), + cleared = False + ) # Return False for any other unexpected errors, along with a clearance object indicating failure +``` + + +When that sends back a clearance object with `cleared=True`, the `Stagecoach` +can proceed with the transfer using the provided `transfer_client`. +If `cleared=False`, the `Stagecoach` can inform the user of the +issue and prompt them to correct their credentials in the manifest. diff --git a/pyproject.toml b/pyproject.toml index b6acfc4..c7fb979 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sheriff" -version = "0.1.1" +version = "0.1.2" description = "A simple tool to validate frontier citizenship" readme = "README.md" authors = [ @@ -8,6 +8,7 @@ authors = [ ] requires-python = ">=3.12" dependencies = [ + "globus-sdk>=4.5.0", "great-docs>=0.10.0", "ipykernel>=7.2.0", "jupyter>=1.1.1", diff --git a/src/sheriff/sheriff.py b/src/sheriff/sheriff.py index 3b00186..3b9a78d 100644 --- a/src/sheriff/sheriff.py +++ b/src/sheriff/sheriff.py @@ -1,25 +1,30 @@ import os from rich.console import Console from pathlib import Path - -console = Console() +from sheriff.validate_globus import issue_globus_transfer, GlobusClearance class Sheriff: """ A simple class that checks for frontier citizenship by looking for a frontier.yml file in the environment variable or the current working directory. """ - def __init__(self, frontier_dirname="frontier", frontier_file="frontier.yml"): + def __init__( + self, + console: Console, + frontier_dirname: str = "frontier", + frontier_file: str = "frontier.yml", + ): """ Initializes the Sheriff Class. Args: frontier_dirname (str): The name of the directory that contains the frontier.yml file. Default is "frontier". frontier_file (str): The name of the frontier file. Default is "frontier.yml". """ + self.console = console self.frontier_dirname = frontier_dirname self.frontier_file = frontier_file self.frontier_path = None - console.print("🤠 There's a new sheriff in town! Let's take a look at your papers...") + self.console.print("🤠 The sheriff's in town! Let's take a look at your papers...") def check_citizen(self): """ @@ -32,8 +37,8 @@ def check_citizen(self): if env_path and os.path.isfile(env_path): self.frontier_path = env_path - console.print(f"🪪 Found frontier file at: {self.frontier_path}") - console.print("🏞️ Welcome to the frontier, citizen!") + self.console.print(f"🪪 Found frontier file at: {self.frontier_path}") + self.console.print("🏞️ Welcome to the frontier, citizen!") return True # Check current working directory @@ -46,12 +51,34 @@ def check_citizen(self): if os.path.isfile(frontier_file_path): self.frontier_path = frontier_file_path - console.print(f"🪪 Found frontier file at: {self.frontier_path}") - console.print("🏞️ Welcome to the frontier, citizen!") + self.console.print(f"🪪 Found frontier file at: {self.frontier_path}") + self.console.print("🏞️ Welcome to the frontier, citizen!") return True - console.print("❌ No frontier file found. Please set the THE_FRONTIER environment variable or navigate to a directory within the frontier to validate your citizenship.") + self.console.print("❌ No frontier file found. Please set the THE_FRONTIER environment variable or navigate to a directory within the frontier to validate your citizenship.") return False + + def check_globus_access(self, globus_info: dict) -> GlobusClearance: + """ + Checks for globus access by validating the globus credentials provided in the + manifest. Runs transfer if credentials are valid and source and destination + endpoints are provided. Returns a GlobusClearance dataclass indicating the + result of the validation and transfer operation. + Args: + globus_info (dict): A dictionary containing the globus credentials and information. + Returns: + GlobusClearance: A dataclass indicating the validation result and associated objects. + """ + self.console.print("🔍 Checking your Globus access...") + + clearance = issue_globus_transfer(globus_info, self.console, issue_transfer=False) + + if clearance.cleared: + self.console.print("✅ Globus access validated. You're good to go!") + else: + self.console.print("❌ Globus access validation failed. Please check your credentials and try again.") + + return clearance import typer @@ -66,7 +93,8 @@ def inspect(): """ Hey there, partner! The sheriff is here to check your papers and make sure you're a citizen of The Frontier. Let's see if you have what it takes to be a part of this wild and wonderful land. """ - sheriff = Sheriff() + console = Console() + sheriff = Sheriff(console=console) sheriff.check_citizen() diff --git a/src/sheriff/validate_globus.py b/src/sheriff/validate_globus.py new file mode 100644 index 0000000..adc1ddc --- /dev/null +++ b/src/sheriff/validate_globus.py @@ -0,0 +1,98 @@ +from rich.console import Console +from globus_sdk import GlobusAppConfig, UserApp, TransferClient, GlobusAPIError, TransferData +from dataclasses import dataclass + +@dataclass +class GlobusClearance: + transfer_id : dict | None + source_collection: str | None + destination_collection: str | None + cleared: bool + +def issue_globus_transfer( + globus_info: dict, + console: Console, + stagecoach_app_id: str = "7723dff4-fa63-4639-903b-ba6541e24e98", + issue_transfer: bool = False + ) -> GlobusClearance: + """ + Validates the globus credentials provided in the manifest. + Args: + globus_info (dict): A dictionary containing the globus credentials and information. + console (Console): Rich console object for output messages. + stagecoach_app_id (str): The Globus application client ID for authentication. + issue_transfer (bool): Whether to actually issue a transfer after validation. Defaults to False. + Returns: + GlobusClearance: A dataclass indicating the validation result and associated objects. + """ + try: + + if not globus_info.get("use_globus", False): + console.print("Globus access not requested.") + return GlobusClearance( + transfer_id = None, + source_collection = None, + destination_collection = None, + cleared = True + ) + + app_name = "Frontier-Sheriff_" + globus_info.get("globus_username", "Globus-Validator") + + with UserApp( + app_name, + client_id=stagecoach_app_id, + config=GlobusAppConfig(auto_redrive_gares=True), + ) as app: + with TransferClient(app=app) as client: + src_collection = globus_info.get("globus_source_endpoint") + dst_collection = globus_info.get("globus_destination_endpoint") + src_path = globus_info.get("globus_source_path") + dst_path = globus_info.get("globus_destination_path") + + client.add_app_data_access_scope(src_collection) + client.add_app_data_access_scope(dst_collection) + + transfer_request = TransferData(src_collection, dst_collection) + transfer_request.add_item(src_path, dst_path) + + # Attempt to stat the source collection to validate access + resp = client.operation_stat(src_collection, src_path) + if resp.http_status == 200: + console.print("✅ Globus credentials validated successfully.") + else: + console.print(f"❌ Failed to validate globus credentials. Operation stat returned status: {resp.http_status}") + raise GlobusAPIError(resp) + + if issue_transfer: + task = client.submit_transfer(transfer_request) + console.print(f"Submitted transfer. Task ID: {task['task_id']}.") + return GlobusClearance( + transfer_id = task, + source_collection = src_collection, + destination_collection = dst_collection, + cleared = True + ) + else: + return GlobusClearance( + transfer_id = None, + source_collection = src_collection, + destination_collection = dst_collection, + cleared = True + ) + + except GlobusAPIError as e: + console.print_exception() + return GlobusClearance( + transfer_id = None, + source_collection = globus_info.get("globus_source_endpoint", None), + destination_collection = globus_info.get("globus_destination_endpoint", None), + cleared = False + ) # Return False if there's an API error, along with a clearance object indicating failure + except Exception as e: + console.print_exception() + return GlobusClearance( + transfer_id = None, + source_collection = globus_info.get("globus_source_endpoint", None), + destination_collection = globus_info.get("globus_destination_endpoint", None), + cleared = False + ) # Return False for any other unexpected errors, along with a clearance object indicating failure diff --git a/user_guide/notebooks/images/groups_client-get-my_g.png b/user_guide/notebooks/images/groups_client-get-my_g.png new file mode 100644 index 0000000..8652c4b Binary files /dev/null and b/user_guide/notebooks/images/groups_client-get-my_g.png differ diff --git a/user_guide/notebooks/sheriff.md b/user_guide/notebooks/sheriff.md index 88efe2b..fdd7b4f 100644 --- a/user_guide/notebooks/sheriff.md +++ b/user_guide/notebooks/sheriff.md @@ -19,25 +19,30 @@ now, this is all that’s needed. import os from rich.console import Console from pathlib import Path - -console = Console() +from sheriff.validate_globus import issue_globus_transfer, GlobusClearance class Sheriff: """ A simple class that checks for frontier citizenship by looking for a frontier.yml file in the environment variable or the current working directory. """ - def __init__(self, frontier_dirname="frontier", frontier_file="frontier.yml"): + def __init__( + self, + console: Console, + frontier_dirname: str = "frontier", + frontier_file: str = "frontier.yml", + ): """ Initializes the Sheriff Class. Args: frontier_dirname (str): The name of the directory that contains the frontier.yml file. Default is "frontier". frontier_file (str): The name of the frontier file. Default is "frontier.yml". """ + self.console = console self.frontier_dirname = frontier_dirname self.frontier_file = frontier_file self.frontier_path = None - console.print("🤠 There's a new sheriff in town! Let's take a look at your papers...") + self.console.print("🤠 The sheriff's in town! Let's take a look at your papers...") def check_citizen(self): """ @@ -50,8 +55,8 @@ A simple class that checks for frontier citizenship by looking for a frontier.ym if env_path and os.path.isfile(env_path): self.frontier_path = env_path - console.print(f"🪪 Found frontier file at: {self.frontier_path}") - console.print("🏞️ Welcome to the frontier, citizen!") + self.console.print(f"🪪 Found frontier file at: {self.frontier_path}") + self.console.print("🏞️ Welcome to the frontier, citizen!") return True # Check current working directory @@ -64,12 +69,34 @@ A simple class that checks for frontier citizenship by looking for a frontier.ym if os.path.isfile(frontier_file_path): self.frontier_path = frontier_file_path - console.print(f"🪪 Found frontier file at: {self.frontier_path}") - console.print("🏞️ Welcome to the frontier, citizen!") + self.console.print(f"🪪 Found frontier file at: {self.frontier_path}") + self.console.print("🏞️ Welcome to the frontier, citizen!") return True - console.print("❌ No frontier file found. Please set the THE_FRONTIER environment variable or navigate to a directory within the frontier to validate your citizenship.") + self.console.print("❌ No frontier file found. Please set the THE_FRONTIER environment variable or navigate to a directory within the frontier to validate your citizenship.") return False + + def check_globus_access(self, globus_info: dict) -> GlobusClearance: + """ + Checks for globus access by validating the globus credentials provided in the + manifest. Runs transfer if credentials are valid and source and destination + endpoints are provided. Returns a GlobusClearance dataclass indicating the + result of the validation and transfer operation. + Args: + globus_info (dict): A dictionary containing the globus credentials and information. + Returns: + GlobusClearance: A dataclass indicating the validation result and associated objects. + """ + self.console.print("🔍 Checking your Globus access...") + + clearance = issue_globus_transfer(globus_info, self.console, issue_transfer=False) + + if clearance.cleared: + self.console.print("✅ Globus access validated. You're good to go!") + else: + self.console.print("❌ Globus access validation failed. Please check your credentials and try again.") + + return clearance ``` With this simple class definition, we can now create a script that @@ -88,7 +115,8 @@ def inspect(): """ Hey there, partner! The sheriff is here to check your papers and make sure you're a citizen of The Frontier. Let's see if you have what it takes to be a part of this wild and wonderful land. """ - sheriff = Sheriff() + console = Console() + sheriff = Sheriff(console=console) sheriff.check_citizen() ``` diff --git a/user_guide/notebooks/validate_globus.md b/user_guide/notebooks/validate_globus.md new file mode 100644 index 0000000..0e8be84 --- /dev/null +++ b/user_guide/notebooks/validate_globus.md @@ -0,0 +1,213 @@ +# Validate Globus Credentials + + +We want to have a secure way of knowing whether a user has the correct +permissions to have data delivered to them via globus. This kind of +validation falls under the “authorization” category of security, and as +such should be handled by the `Sheriff` class. As a reminder, the +`Stagecoach` class is a handler for delivering data to townspeople, and +works by issuing users a manifest. This manifest is a JSON file that +users fill in with their credentials and desired data from the Frontier +Gold Mine. Once the user returns the manifest, and the `Stagecoach` +recognizes that the user wants access to data via `globus`, the +`Stagecoach` passes the Globus credentials along to the `Sheriff` for +validation. The `Sheriff` then needs to validate the credentials to +ensure that the user has the correct permissions to access the data. +This validation process uses the `Globus SDK` to check the credentials +against the Globus service. If the credentials are valid, the `Sheriff` +returns a success message, allowing the `Stagecoach` to proceed with the +data delivery. If the credentials are invalid, the `Sheriff` returns an +error message, and the `Stagecoach` can inform the user of the issue and +prompt them to correct their credentials in the manifest. This process +ensures that only authorized users can access the data, maintaining the +security of The Frontier’s Gold Mine resources. + +## Tinkering with the SDK + +Globus SDK in Python works as below. Once you’ve set up an App in the +[Developer +panel](https://globus-sdk-python.readthedocs.io/en/stable/user_guide/getting_started/register_app.html) +of the Globus website, you can use the (non-secure) client UUID to +create a user app: + +The first method shows us that login is required to use this app: + +Next, we can use this app to define a client group: + +What’s great is that this method forces the current user to log in to +their globus account: + +![Globus Login](images/groups_client-get-my_g.png) + +And from there, I can see the groups I’m allowed to access defined in +the Globus dashboard: + +The explicit login can be called deliberately: + +We can see that the UserApp object is the main functionality we’re +looking for in the `Sheriff` class, as it deals solely with the +authentication and authorization of users: + +> GlobusApp provides a number of useful abstractions in the SDK. It +> handles login flows and storage of tokens, coupled with later +> retrieval of those tokens for use. It can keep track of which clients +> have been created and registered with an app, and therefore make +> intelligent decisions about how and when to prompt users to login. + +So, to make sure someone is logged in, we can simply create a function +that triggers this login flow. Fortunately, Globus has provided an +example we can work with. Let’s take an example of trying to get this +file: + +`/n/holylabs/cgolden_lab/Lab/projects/sandbox/ReaProject/MadagascarWeatherStations/_pkgdown.yml` + +Let’s list my available data: + +The `.get_endpoint()` method is used to get the endpoint information for +a given collection. An important piece of information we’ll need is the +data_access scope of the endpoint, which is a little bit of a +complicated discussion; the tldr I believe is that the transfer client +can be modified to have the correct access scope but only if you check +for it first; hence, the `.get_endpoint()` step. There are two cases +under which the endpoint will NOT have data_access: + +1. If the endpoint `entity_type` IS NOT `GCSv5_mapped_collection`, and +2. If the endpoint IS `high_assurance`. + +If either of the above is true, then the endpoint will need special +access and additional steps. If neither of the above is true, then the +endpoint should be accessible with the default client + +So neither of these are true, which means that the endpoint should be +accessible with the default client. If it weren’t the case, we would +have to modify the client with +`tc.add_app_data_access_scope(SRC_COLLECTION)`. + +To be sure that this transfer is valid, we can first do a dir_stat on +the collection. If we get a successful response, we can assume that the +transfer will work in future: + +Finally, we can wrap the transfer submission in a try-except block to +catch any errors that may arise from invalid credentials or permissions: + +That works! So now we can create a validation function that allows the +sheriff to validate the globus section of a stagecoach manifest. + +The manifest will have the following shape: + +We’ll need to validate that the user has the kind of access necessary. +The return will be a dataclass that the stagecoach can accept the +requisite transfer object from and execute. + +``` python +from rich.console import Console +from globus_sdk import GlobusAppConfig, UserApp, TransferClient, GlobusAPIError, TransferData +from dataclasses import dataclass + +@dataclass +class GlobusClearance: + transfer_id : dict | None + source_collection: str | None + destination_collection: str | None + cleared: bool + +def issue_globus_transfer( + globus_info: dict, + console: Console, + stagecoach_app_id: str = "7723dff4-fa63-4639-903b-ba6541e24e98", + issue_transfer: bool = False + ) -> GlobusClearance: + """ + Validates the globus credentials provided in the manifest. + Args: + globus_info (dict): A dictionary containing the globus credentials and information. + console (Console): Rich console object for output messages. + stagecoach_app_id (str): The Globus application client ID for authentication. + issue_transfer (bool): Whether to actually issue a transfer after validation. Defaults to False. + Returns: + GlobusClearance: A dataclass indicating the validation result and associated objects. + """ + try: + + if not globus_info.get("use_globus", False): + console.print("Globus access not requested.") + return GlobusClearance( + transfer_id = None, + source_collection = None, + destination_collection = None, + cleared = True + ) + + app_name = "Frontier-Sheriff_" + globus_info.get("globus_username", "Globus-Validator") + + with UserApp( + app_name, + client_id=stagecoach_app_id, + config=GlobusAppConfig(auto_redrive_gares=True), + ) as app: + with TransferClient(app=app) as client: + src_collection = globus_info.get("globus_source_endpoint") + dst_collection = globus_info.get("globus_destination_endpoint") + src_path = globus_info.get("globus_source_path") + dst_path = globus_info.get("globus_destination_path") + + client.add_app_data_access_scope(src_collection) + client.add_app_data_access_scope(dst_collection) + + transfer_request = TransferData(src_collection, dst_collection) + transfer_request.add_item(src_path, dst_path) + + # Attempt to stat the source collection to validate access + resp = client.operation_stat(src_collection, src_path) + if resp.http_status == 200: + console.print("✅ Globus credentials validated successfully.") + else: + console.print(f"❌ Failed to validate globus credentials. Operation stat returned status: {resp.http_status}") + raise GlobusAPIError(resp) + + if issue_transfer: + task = client.submit_transfer(transfer_request) + console.print(f"Submitted transfer. Task ID: {task['task_id']}.") + return GlobusClearance( + transfer_id = task, + source_collection = src_collection, + destination_collection = dst_collection, + cleared = True + ) + else: + return GlobusClearance( + transfer_id = None, + source_collection = src_collection, + destination_collection = dst_collection, + cleared = True + ) + + except GlobusAPIError as e: + console.print_exception() + return GlobusClearance( + transfer_id = None, + source_collection = globus_info.get("globus_source_endpoint", None), + destination_collection = globus_info.get("globus_destination_endpoint", None), + cleared = False + ) # Return False if there's an API error, along with a clearance object indicating failure + except Exception as e: + console.print_exception() + return GlobusClearance( + transfer_id = None, + source_collection = globus_info.get("globus_source_endpoint", None), + destination_collection = globus_info.get("globus_destination_endpoint", None), + cleared = False + ) # Return False for any other unexpected errors, along with a clearance object indicating failure +``` + +When that sends back a clearance object with `cleared=True`, the +`Stagecoach` can proceed with the transfer using the provided +`transfer_client`. If `cleared=False`, the `Stagecoach` can inform the +user of the issue and prompt them to correct their credentials in the +manifest. + +# Script file + +The code for this document can be found here: + +- [../src/sheriff/validate_globus.py](../src/sheriff/validate_globus.py) diff --git a/uv.lock b/uv.lock index 139663c..2f6809e 100644 --- a/uv.lock +++ b/uv.lock @@ -328,6 +328,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, ] +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + [[package]] name = "debugpy" version = "1.8.20" @@ -394,6 +447,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, ] +[[package]] +name = "globus-sdk" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/3819840b716d26146026b0ac2faa4131fa9b8fa7b65f2493006434f505f7/globus_sdk-4.5.0.tar.gz", hash = "sha256:33f888e369e4d3fb183ecd8cdea1a0be40d930dc13549dc5c8b3326940398bc5", size = 278531, upload-time = "2026-03-30T20:35:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/03/7c993819f1d891c345f56396c8aed226aa8c7ec37bc0cb3c5120b6c1ec64/globus_sdk-4.5.0-py3-none-any.whl", hash = "sha256:5936cded28b8c6212870f54e3d3ae2c2d48d8061995117f52731992407bf66ab", size = 431789, upload-time = "2026-03-30T20:34:59.649Z" }, +] + [[package]] name = "great-docs" version = "0.10.0" @@ -1275,6 +1342,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1616,6 +1697,7 @@ name = "sheriff" version = "0.1.1" source = { editable = "." } dependencies = [ + { name = "globus-sdk" }, { name = "great-docs" }, { name = "ipykernel" }, { name = "jupyter" }, @@ -1628,6 +1710,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "globus-sdk", specifier = ">=4.5.0" }, { name = "great-docs", specifier = ">=0.10.0" }, { name = "ipykernel", specifier = ">=7.2.0" }, { name = "jupyter", specifier = ">=1.1.1" },